diff --git a/openspec/changes/enterprise-identity-platform/design.md b/openspec/changes/enterprise-identity-platform/design.md new file mode 100644 index 00000000..9cf0fa9f --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/design.md @@ -0,0 +1,247 @@ +## Context + +本设计是统一身份认证与企业身份平台的第一批可部署切片。动机与用户可见范围见 [proposal.md](./proposal.md),行为契约见 `specs/`,分批合并、部署和启用策略见 [rollout-plan.md](./rollout-plan.md)。 + +SkillHub 已有四类认证入口:local password、公共 OAuth/OIDC、CLI Device Flow 和 API Token。历史 OAuth 流程同时承担上游 claims 解析、账号查找/创建、资料写入和 session 建立;这种结构无法安全扩展到企业自带身份源。另一方面,企业登录在首次认证前就需要一个稳定租户边界,用来约束连接、预供给成员、已验证域名和撤权,但这个 Organization 不能与 Namespace 或目录同步混为一体。 + +本批次跨 `domain`、`auth`、`infra`、`app` 和 `web`,涉及新的持久化模型、匿名回调、Secret、出站网络和兼容迁移,因此必须支持默认关闭、组织灰度、可观测回滚和真实协议验证。 + +## Goals / Non-Goals + +**Goals:** + +- 建立稳定的“协议边缘 → 已验证事实 → 统一身份决策 → 平台主体 → 会话”主链路。 +- 将 Provider 协议与登录上下文解耦:同一 Provider 可以用于平台公共登录,也可以在另一个批次中用于企业 Organization 登录,但账号决策只走统一核心。 +- 用最小 Organization/Membership 模型承载租户隔离、预供给、状态检查和 authority version,不依赖 Namespace。 +- 首批把动态 OIDC 做到可配置、可测试、可激活、可灰度、可撤权和可回滚。 +- 将已有公共 OAuth 登录接入同一账号状态与资料权威规则,同时保留 legacy 回读能力。 +- 为 SAML、CAS 和 credential-based Adapter 冻结足够小的扩展边界,而不提前实现这些协议。 +- 提供可自动化的迁移、安全、协议、浏览器和回滚验收入口。 + +**Non-Goals:** + +- 不在本批次实现 SAML、CAS、LDAP end-user login 或厂商专用 Adapter。 +- 不实现 Directory Connection、SCIM、用户/群组同步、调和任务或组织通讯录 UI。 +- 不把 Organization 自动映射为 Namespace,不创建 group-to-Namespace mapping 或 source-aware grant。 +- 不实现 Identity Link 或 Account Merge V2;首次登录不能把已绑定账号仅凭 email 静默合并。 +- 不改变 Skill、Namespace、CLI Sync 或发布生命周期。 +- 不让协议 Adapter 直接访问账号、Membership、Namespace 或 session 仓储。 + +## Decisions + +### 登录体验:独立页面与配置驱动 + +登录页复用现有 `/api/v1/auth/methods`,增加同一 DTO 形状下的 `ENTERPRISE_DISCOVERY` capability;不新增独立“登录体验配置”API。Catalog 根据现有 OIDC 双开关、Organization allowlist 和 identity core ACTIVE 声明通用发现入口,不读取或公开租户清单。具体 Organization 和 ACTIVE connection 仍由既有 discovery 与 redirect gateway 判断。 + +外部入口链路是服务端 OAuth2 registration 配置 → AuthMethodCatalog → 页面按钮,不由前端写死 GitHub、钉钉、飞书列表,也不依赖企业发现开关。当前过滤规则是 client ID 非空且不含 placeholder;没有新增每个来源的 enabled 字段。来源名称使用配置的 client-name,跳转使用服务端 actionUrl。新增厂商仍需后端协议适配、稳定 subject 解析和真实授权验收,配置一个显示名称不能替代这些能力。 + +登录、注册及 OAuth 成功后优先返回合法的应用内 returnTo;缺省返回首页 `/` 而非控制台。导航栏登录链接携带当前路径、查询参数和锚点;显式 CLI 授权路径保留。不采用未经校验的外部地址或浏览器 referrer 作为返回目标。 + +桌面使用固定视口的左右分屏:左侧品牌与静态背景位置不随表单内容变化,右侧登录流程顶端对齐;小高度或放大场景只允许右侧 main 独立滚动,页头与条款保持可见。移动端只保留品牌入口和表单。企业发现与账号密码使用分段切换,同时只显示一种表单,切换保留输入;只有一种方式时不显示空分段。公共 OAuth 为紧凑按钮,并仅展示实际配置的 Provider。请求失败提供重试,空 catalog 明确提示联系管理员;未知 method type 不推断交互。 + +企业发现成功后用返回的组织名称和 loginOptions 替换查询表单,提供“更换企业”回到输入,并明确跳转企业身份源授权页面;不收集企业密码,不将此流程与组织同步耦合。`/register` 与 `/login` 共用 AuthShell,通过站内路由切换,保留现有用户名、邮箱、密码校验与注册 API,不再复用旧 Card/Tabs 页面。 + +本轮不增加企业专属域名、动态租户品牌、单 IdP 自动跳转、本地账号禁用或独立 break-glass 管理入口。这些需要后续独立产品与安全决策。本地账号继续按现有服务端 catalog 始终可用;direct password 与 session bootstrap 保留已有前后端运行配置约束。图中虚构的黑色 S Logo 替换为项目正式 BrandMark。 + +背景为本地打包的约 7 KiB WebP,不新增 Prompt、动画或绘图库,不新增持久运行服务。 + +2026-09-17 人工反馈后收紧尺寸:表单最大宽度 440px,密码登录输入和主按钮高 40px,右侧主标题 24px,左侧标题 36–48px。混合方式下隐藏与分段标签重复的可见标题,但保留语义标题。普通桌面验收必须检查右侧 main 无溢出,不能仅检查 document 无溢出;小高度、放大、校验错误增加内容时保留可访问的局部滚动,不用隐藏滚动条掩盖裁切。 + +分段代表身份类型,固定使用“企业账号 / 个人账号”,不带“登录”。`/register` 主标题使用“创建个人账号”,说明与主标题随登录/注册动作切换,去掉原有重复创建副标题;分段和左侧位置保持一致。个人注册不会创建企业身份或授予组织权限。 + +### Provider 协议与登录上下文分离 + +统一身份认证的第一层抽象是 Provider 协议,不是“企业/非企业”。GitHub、GitLab、飞书、钉钉、标准 OIDC、CAS、SAML、LDAP gateway 都是 Provider 或 Adapter;它们输出同一种已验证身份事实。登录上下文决定这些事实如何被消费: + +```text +Provider Adapter + -> ProviderAuthenticationResult / IdentityAssertion + -> External Identity Login Core + -> loginContext: PUBLIC_PLATFORM | ENTERPRISE_ORGANIZATION | CLI_BROWSER_AUTH + -> PlatformPrincipal / Enterprise session / Device authorization +``` + +`PUBLIC_PLATFORM` 只证明“这个外部账号可以登录 SkillHub 平台账号”,不得创建 Organization Membership、企业会话或 Namespace 权限。`ENTERPRISE_ORGANIZATION` 在已确定 Organization/LoginConnection 后运行,额外执行企业成员、连接、authority version 和 session-origin guard。二者共享协议客户端、subject 解析和资料可信度判断,但不能共享租户凭证、连接生命周期或权限副作用。 + +因此飞书、钉钉既可以是公共快捷登录,也可以是某个企业配置的 SSO。产品入口应分开: + +```text +个人账号登录 + - local password + - GitHub / GitLab / Feishu public / DingTalk public + +企业账号登录 + - 输入企业邮箱或企业标识 + - discovery 返回该企业已激活的 OIDC / Feishu enterprise / DingTalk enterprise connection +``` + +首批统一身份核心应先证明所有外部公共登录都会经过同一个账号状态、绑定、资料权威和 session 决策门禁;公共 OAuth 的持久化仍可保留现有 legacy binding,避免第一批迁移存量 GitHub/GitLab 身份。企业 Organization 登录复用同一核心并增加企业上下文。不能再新增 `FeishuLoginService`、`DingTalkLoginService` 这类各自建号、绑定和建 session 的旁路。 + +### 现有飞书、钉钉 PR 与统一身份架构的接入边界(设计建议,尚未实现) + +2026-09-17 只读核对的开放 PR:飞书 [#696](https://github.com/iflytek/skillhub/pull/696),head `d3f1d5e65af8139a6021eca52a5d7a114156f3c3`;钉钉 [#467](https://github.com/iflytek/skillhub/pull/467),head `0b21fe2f34c6901abf5bc017e37f1d432e66fe33`。这里只说明接入方向,不构成代码 Review 通过或合并准备结论。 + +两者当前主要提供部署级公共 OAuth 登录:配置一个 Provider registration,匿名 catalog 暴露按钮,通过旧 callback/claims/binding 路径登录。它们不等同于每个 Organization 单独配置凭证、策略与成员上下文的企业登录。当前本地公共 OAuth 已经过 LegacyPlatformIdentityCoreBridge 的统一决策评估,但旧 binding persistence 仍是权威;企业 redirect 则经 Adapter、IdentityAssertion、EnterpriseIdentityAssociationService 和企业 session。不能描述为所有写入路径已经统一迁移完成。 + +接入分三批,而不是为每家厂商复制身份核心: + +1. **公共登录兼容批次**:复用 PR 的 authorization/token/userinfo/claims 处理、图标与测试,保留既有 Provider code、回调和部署配置语义;把加载分派接到当前 OAuthLoginFlowService 或其统一核心替代入口。避免在 SecurityConfig 不断叠加厂商专属 if;此批仅提供平台级登录,不建立企业或 Namespace 权限。 +2. **公共登录统一核心批次**:将 GitHub/GitLab/Feishu/DingTalk 等公共 Provider 的已验证事实统一送入 platform-scoped 统一身份决策门禁;ACTIVE 模式下 Denied/Conflict 必须在写入 active 或 pending legacy binding 前 fail closed。legacy binding 继续作为公共 OAuth 的写入与回滚权威;V60 可以保留只读影子回填和一致性校验,但不代表运行时写入权威已经切到 V2。LEGACY/SHADOW/ACTIVE 模式、冲突 fail closed 和资料权威规则必须覆盖所有公共 Provider,而不仅是 GitHub/GitLab。 +3. **企业连接批次**:在独立需求下将厂商协议实现封装为 RedirectAuthenticationAdapter,并提供对应配置校验/连接测试与 registry 装配。由现有 LoginConnection 版本、secret reference、组织 allowlist 和 discovery 选择企业连接;Adapter 仅输出验证后的 IdentityAssertion,关联、JIT、账号状态与 session 仍由既有核心决定。无需等待 SCIM/通讯录同步,但不自动授予 Namespace 角色。 + +公共按钮与企业连接可以并存:公共按钮使用平台配置,企业入口先确定 Organization/connection 再跳转其身份源。共享协议客户端与验证逻辑,不共享租户凭证或放宽租户边界。前端公共图标 resolver 当前只识别 GitHub/GitLab/OIDC,接入厂商 PR 时需同步支持其已提供的真实图标;不能只合后端然后声称完整入口已接通。 + +身份坐标必须包含可信的组织/连接上下文、issuer 与 typed subject;裸 open_id/unionId/userId、昵称或邮箱不能代表全球唯一企业身份。钉钉 PR 的 unionId→openId→userId fallback 在字段可用性变化时可能改变主 subject,接入前需定义稳定主 subject 和有证明的 alias/迁移策略,不能静默改键。飞书的 open_id 需保留应用作用域;union_id 不自动证明跨应用或跨组织可合号。邮箱必须有可靠的验证依据才进入 VerifiedEmail,禁止仅因返回邮箱或名称相同而绑定。 + +组织、部门与人员目录同步属于后续 provisioning connection,不能复用登录 token 暗中拉取通讯录。实现前分别验证公共登录回归、企业连接隔离、旧 binding 兼容、验证 email、账号禁用与回调防重放;当前没有执行真实飞书/钉钉登录验收。 + +### 1. Organization 是认证租户边界,不是 Namespace 的别名 + +`Organization` 表示企业身份与治理边界;`Namespace` 表示 Skill 协作和授权边界。一个组织未来可以管理多个 Namespace,一个账号也可以属于多个组织。第一批只实现 Organization、Membership、Domain 和组织角色,二者之间没有自动授权关系。 + +将企业直接等同于 Namespace 会使多部门、多 Namespace、共享 Namespace 和后续目录来源无法表达。复用 Namespace membership 虽然改动小,但会把登录准入和 Skill 权限耦合,停用时容易误删手工授权,因此不采用。第一批公开角色只包含 `ORG_OWNER`、`IDENTITY_ADMIN`、`LOGIN_SECRET_ADMIN`、`MEMBER_ADMIN` 和 `ORG_AUDITOR`;Directory/Entitlement 角色留到对应能力真正交付时再通过向前迁移增加。 + +### 2. 协议 Adapter 只产生已验证事实 + +认证链路分为五层: + +```text +OIDC Adapter + -> IdentityAssertion(已验证事实) + -> ExternalIdentityLoginModule(统一关联与 guard) + -> PlatformPrincipal(稳定平台账号) + -> EnterpriseBrowserSessionService(来源与 authority version) + -> Spring Session +``` + +Adapter 负责协议特有的签名、issuer、audience、时效、state/nonce/PKCE 等校验,并输出标准化 `IdentityAssertion`。控制面的同协议 Adapter 还负责把管理员录入的协议 subject 转换为与 Assertion 完全相同的 typed identity coordinate;Organization 应用服务不得理解 OIDC、SAML 或 CAS 的 issuer/subject 结构。统一身份核心独占以下决策:已有 binding、预供给 subject、verified email、JIT、账号状态、成员状态、资料权威和会话创建。 + +每个 provider 实现完整 login service 会复制安全规则并产生协议间不一致,因此不采用。顶层扩展契约按 interaction model 区分 redirect、credential 和 passive assertion,避免为了兼容所有协议而设计万能接口。 + +### 3. 登录连接使用控制面与数据面分离 + +`LoginConnection` 保存稳定 handle、组织、Adapter key 和生命周期;`LoginConnectionRevision` 保存不可变 typed configuration、能力、schema/contract version、Secret binding version 和关联策略。管理员创建 revision 后必须先测试,再原子激活。正在执行的登录事务绑定启动时的 runtime snapshot,不读取半更新配置。 + +连接状态采用 `DRAFT → ACTIVE ↔ SUSPENDED → DISABLED` 的受控转换,`DISABLED` 为终态。匿名入口只接受不可枚举的 public handle,不接受数据库 ID、issuer URL 或任意 callback origin。 + +配置编辑直接覆盖当前记录无法解释进行中的登录,也无法安全回切,因此不采用。 + +### 4. External Identity V2 使用复合身份坐标 + +企业外部身份唯一键至少包含: + +```text +(organization_id, connection_id, issuer, subject_type, subject_value) +``` + +平台级公共 OAuth 使用 platform scope 的等价坐标参与统一身份决策门禁。`subject` 必须是协议保证稳定的标识;显示名和 email 不是主键。Release 1 可以把 GitHub/GitLab 既有 `identity_binding` 影子回填到 External Identity V2 以做一致性校验,但 legacy binding 仍保留为公共 OAuth 的写入、兜底和回滚权威。后续如要把运行时写入权威切到 V2,必须作为独立批次完成新写入同步、历史回填验证、回滚和真实登录验收;冲突时 fail closed。 + +仅用 `provider + subject` 无法区分企业自建 issuer;仅用 email 会导致地址复用或验证强度不足时账号接管,均不采用。 + +### 5. 首次登录关联采用确定性优先级 + +统一核心按以下顺序执行,并在一个事务中收敛: + +1. 已存在且有效的 External Identity binding。 +2. 同 Organization、Connection 下预供给的 immutable subject。 +3. revision 显式开启后的 verified-email correlation。 +4. revision 同时允许、domain 已验证且没有历史冲突时的 JIT。 +5. 拒绝。 + +数据库唯一约束承担最终并发仲裁;重复预供给在写入前检查,但数据库仍是并发竞争的最终裁决者,失败统一返回稳定冲突而不是 500,并回滚同事务新建的 Membership。首次登录竞争失败只允许有界重读,不能留下孤立账号。`SUSPENDED`、`DEPROVISIONED`、已撤销预供给 subject、歧义 email、已绑定账号和非 ACTIVE 组织均阻止自动关联。未来 Identity Link 必须走独立重新认证流程。 + +### 6. email 是受限信号,不是全局身份键 + +只有 Adapter 明确证明 email 已验证、Organization 持有 ACTIVE verified domain、连接 revision 显式开启 correlation 时,email 才能参与关联。默认关闭 verified-email correlation 和 JIT。上游 email 变化不会迁移已有 binding。 + +组织域名验证使用 challenge 生命周期,匿名 discovery 只能根据已验证 domain 返回相同的公开连接信息,不能查询账号或 Membership,从而避免 local-part 枚举。 + +### 7. 动态 OIDC 采用固定回调和一次性事务 + +OIDC start 创建带过期时间的一次性事务,保存 connection revision snapshot、state、nonce、PKCE verifier、returnTo 和浏览器绑定;Redis 只保存服务端状态。浏览器 Cookie 只含随机绑定值,使用 `Secure`、`HttpOnly`、`SameSite=Lax` 和受限 path。 + +回调必须同时匹配 public handle、state、浏览器绑定和未消费事务;成功或失败后清理绑定 Cookie。callback base 由 operator 明确配置,不信任请求 Host 或 Forwarded header。`returnTo` 只接受本地相对路径。 + +OIDC ID Token 校验至少覆盖 issuer、签名、算法、kid、audience/azp、expiry、issued-at 和 nonce。metadata/JWKS/token endpoint 均执行 HTTPS、主机/IP、重定向、超时和响应大小限制;不将 code、state、token、email 或 Secret 写入日志。 + +### 8. Secret 使用 envelope encryption 和版本绑定 + +OIDC client secret 不写入 typed configuration、API 响应、审计详情或日志。Secret 单独保存为加密 envelope,记录用途、key id、版本和状态;revision 只保存 Secret version reference。运行时只有 materializer 在构造不可变 snapshot 时短暂解密。 + +创建带 Secret 的连接或新 revision 需要 `ROTATE_LOGIN_SECRETS` 组织权限;激活、暂停和禁用要求显式确认。第一批不提供批量轮换或跨连接复用。 + +### 9. 企业会话保存来源并按版本 fail closed + +企业浏览器会话持久化 `organization`、`membership`、`connection`、`external identity`、assurance、认证时间及 Organization/Membership authority version,并只保存 session key 的不可逆摘要。每次进入企业边界时重新检查账号、组织、成员、连接和 identity 状态以及版本。 + +暂停或停用会递增 authority version,旧会话随即失效。Device Flow 在批准与兑换之间也重新校验;API Token 继续代表同一 Platform Account,但企业资源访问必须使用当前 Membership 判定,不能继承浏览器 Cookie 的来源。 + +### 10. 既有认证采用兼容迁移 + +local login、公共 OAuth、Device Flow 和 API Token 的路由与响应保持不变。公共 OAuth 将已验证 facts 送入统一核心;身份核心支持 `LEGACY` 和 `ACTIVE` 模式。V54–V60 只做向前 schema/constraint/backfill,不删除旧表或旧 binding,因此回滚应用时无需执行 destructive down migration。 + +动态企业 OIDC 把控制面装配开关、匿名登录数据面开关和 Organization allowlist 分开。控制面可以先启用来配置、探测和激活连接,同时保持匿名 discovery/start/callback 关闭;只有身份核心对目标 Organization 为 `ACTIVE`、登录数据面开启且 Organization 位于 allowlist 时,动态登录才可执行。身份核心切回 `LEGACY` 时公共 OAuth 完全走旧 binding。 + +认证专属的 Login Connection、revision、Secret、External Identity、认证操作和 Session origin +属于 `skillhub-auth` 安全边界。它们的聚合、Repository port 和 JPA adapter 在 auth 内闭合, +`skillhub-domain` 只承载跨产品能力共享的 Platform Account 与 Organization 业务域;app 只能 +通过 auth 公开 port 编排,不得依赖 Spring Data Repository 或协议实现。这是对仓库既有 +ApiToken、IdentityBinding、LocalCredential 和 Account Merge 归属的延续,不允许扩展成 +普通业务聚合跳过 infra 的通用规则。 + +### 11. API 和权限面分离 + +平台面只允许 `SUPER_ADMIN` 创建 Organization 并指定已有 Platform Account 为初始 owner。租户面即使调用者是 `SUPER_ADMIN`,也必须具有目标 Organization 的 ACTIVE Membership 和相应组织角色。 + +组织角色最小集合保留未来扩展槽位,但本批次只实现组织、成员、域名和登录连接所需动作。Controller 只绑定 transport 与 `PlatformPrincipal`;应用服务编排事务;领域服务执行生命周期/RBAC;query repository 负责分页与组合视图。 + +匿名面仅包括 login discovery、enterprise start 和 callback,并分别配置限流。错误响应遵循现有 `ApiResponse`,对匿名方使用通用错误,不泄露组织、成员、连接或账号是否存在。 + +### 12. 扩展新协议必须是独立批次 + +SAML 和 CAS 复用 Organization、Connection、External Identity、关联、guard 和 session,只新增协议 Adapter、配置 materializer、控制面 probe、真实参考服务和协议安全矩阵。每个协议单独 OpenSpec/commit/验收,不与 Directory/SCIM 同批。 + +LDAP 只有在明确决定 credential-based end-user flow、TLS 策略、稳定 object id 和密码不落盘后才能标记为登录能力。Directory/SCIM 是 provisioning plane,独立于 authentication plane;二者即使共用上游产品,也不得共用连接类型或生命周期状态。 + +本批次运行时代码只声明 `LOGIN` connection kind、登录 capability,以及 +`MANUAL`/`INVITATION`/`JIT` Membership source;不提前公开 Directory connection、capability、 +profile source 或 Membership source 占位值。后续 provisioning 批次通过自己的 OpenSpec 和 +向前迁移增加这些词汇,不能让当前 API 或数据库值暗示已经支持 Directory/SCIM。 + +### 13. 验证分四层,证据不可互相替代 + +- L0:OpenSpec strict validation、架构依赖检查、format/diff guard。 +- L1:domain/auth/app 单元与持久化测试,覆盖关联顺序、并发、状态 guard、OIDC 校验、Secret、session 和迁移。 +- L2:从当前 exact SHA 构建镜像,运行 PostgreSQL/Redis/SkillHub,验证健康、迁移、配置开关、回滚和既有认证兼容。 +- L3:使用隔离 Docker OIDC 参考实现和真实 Windows 浏览器,验证 discovery → redirect → IdP → callback → session、错误回调、重放、跨组织碰撞和成员暂停后的访问失效。 + +MockMvc 或伪造 token 不能替代 L3。实验室必须限制 CPU/内存、随机化隔离网络/端口、只使用测试凭证、验证清理无残留,并记录运行时镜像 digest 与源码 SHA。Windows 浏览器域名映射必须作为一个完整的 quoted browser argument 传入;之前将映射拆成多个参数会表现为全部域名不可达。 + +## Risks / Trade-offs + +- [第一批只支持动态 OIDC,协议覆盖有限] → 先获得可回滚的真实生产切片;SAML、CAS 分批复用同一 conformance suite。 +- [Organization 模型先于 Directory 上线,管理员仍需手工预供给] → 明确这是认证批次;后续 provisioning 不改变已有 identity coordinate。 +- [verified email/JIT 可能造成账号接管] → 默认关闭、要求已验证域名与高 assurance、歧义和历史成员 fail closed。 +- [metadata/JWKS/token 出站请求可能形成 SSRF 或 DNS rebinding] → 每一跳仅允许 HTTPS,解析并校验全部地址,并将已验证地址固定到该次实际连接;保留原主机名用于 TLS SNI/hostname verification,禁止私网/回环/链路本地地址并限制 redirect/timeout/body。 +- [公共 OAuth 统一核心门禁与 legacy binding 结果不一致] → ACTIVE 下在 active/pending legacy 写入前 fail closed,保留 LEGACY/SHADOW 回滚;V2 写入权威切换另立批次时再补新写入同步、backfill digest 与 dual-read 证据。 +- [authority version 校验增加请求开销] → 查询使用索引并仅在企业边界执行;后续性能证据不足时不得扩大灰度。 +- [七个 Flyway migration 增加发布复杂度] → 编号固定 V54–V60,做空库和升级库测试;当前批次不再新增 SQL,后续协议优先复用已有表。 +- [Secret key 配置错误导致连接不可用] → 默认禁用企业 OIDC;启动校验 keyring,暴露不含 Secret 的健康信息,保留旧 revision 供回切。 + +## Migration Plan + +以下步骤描述完整 Release 1 的运行时启用顺序。将当前完整参考实现重组为独立合并批次时,以 [rollout-plan.md](./rollout-plan.md) 为准;每个重组后的批次必须基于自己的 exact SHA 重新验证,不能直接继承完整参考分支的验证结论。 + +1. 在合并前冻结 source SHA,运行迁移护栏、全量后端、Web typecheck/lint/test、OpenAPI freshness 和架构检查。 +2. 从 exact SHA 构建本地镜像,在空数据库验证 V1–V60,在 main 现有 V53 数据库验证 V54–V60 升级;确认公共 OAuth legacy binding 仍为持久化权威。 +3. 保持 `identity core=LEGACY`、OIDC 控制面和登录数据面均关闭,验证 local/OAuth/Device/API Token 回归。 +4. 开启 OIDC 控制面并切换统一身份核心到 ACTIVE,但保持登录数据面关闭;配置、测试并激活测试 Organization 的连接,同时观察公共登录成功率、fallback、冲突和异常日志。 +5. 将测试 Organization 加入 allowlist,再仅打开 OIDC 登录数据面。 +6. 完成真实浏览器登录、首次关联、重复登录、成员暂停、连接暂停、错误回调、重放和日志脱敏验收。 +7. 小范围扩大 Organization allowlist;任何关键指标异常先移出 allowlist或关闭动态 OIDC,再按需切回 LEGACY。数据库保留,不执行降级删除。 + +回滚顺序:Organization allowlist → OIDC 登录数据面开关 → 身份核心 LEGACY;控制面可保留用于诊断,也可最后关闭。回滚只改变运行时开关,不删除 External Identity V2、session origin 或审计数据。 + +## Open Questions + +无阻塞本批次实现的问题。SAML、CAS、LDAP 登录和 Directory/SCIM 的产品优先级在各自后续 OpenSpec 中决定,不改变本批次数据模型和 Adapter 边界。 diff --git a/openspec/changes/enterprise-identity-platform/proposal.md b/openspec/changes/enterprise-identity-platform/proposal.md new file mode 100644 index 00000000..3e9536ca --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/proposal.md @@ -0,0 +1,44 @@ +## Why + +SkillHub 现有 OAuth、local login、CLI Device Flow 和 API Token 能完成平台登录,但不同外部身份源的账号关联、资料权威、账号状态与会话建立仍缺少统一决策边界。企业身份还额外需要成员状态、租户边界和会话失效控制。直接同时上线 OIDC、SAML、CAS、LDAP、SCIM 和组织同步,会把协议正确性、身份安全与目录一致性风险叠加在一次发布中,无法充分验证和独立回滚。 + +本变更先交付可独立上线的统一身份认证基础版本:所有外部登录先收口到协议无关的统一身份核心;企业 Organization 登录只是统一核心的一个登录上下文,而不是另一套账号体系。首批同时提供最小 Organization 安全边界,并仅启用经过完整验证的动态 OIDC。后续公共 Provider、企业 Provider 和组织同步沿相同扩展契约分批交付。 + +具体的合并单元、部署顺序、启用门槛和后续认证/组织同步边界见 [rollout-plan.md](./rollout-plan.md)。 + +## What Changes + +- 新增独立于 Namespace 的 Organization、Membership、已验证域名和组织管理角色,作为企业登录的租户与撤权边界。 +- 新增版本化 Login Connection 控制面,区分草稿、测试、激活、暂停和禁用状态;认证数据面只读取不可变的已激活 revision。 +- 新增协议无关的认证 Adapter 契约和统一身份决策核心。Adapter 只验证协议并输出标准化事实,不得自行建号、绑定成员或创建会话。 +- 明确区分 Provider 协议和登录上下文:GitHub、GitLab、飞书、钉钉、OIDC 等 Provider 可服务于平台公共登录,也可在独立批次中服务于企业 Organization 登录;账号创建、绑定、资料写入和 session 建立必须继续由统一身份核心完成。 +- 首批新增动态 OIDC:安全获取 discovery/JWKS、使用一次性 state/nonce/PKCE 事务、验证 ID Token,并通过统一身份核心完成关联和会话建立。 +- 新增 External Identity V2,以 Organization、Connection、Issuer、typed Subject 形成唯一身份坐标;email 只在已验证且策略显式允许时参与关联。 +- 新增预供给 immutable subject、可选 verified-email correlation 和可选 JIT,默认均采用保守策略;冲突和历史成员状态 fail closed。 +- 企业会话记录不可逆 session 摘要、身份来源和组织/成员 authority version,使暂停、停用和版本变化能拒绝旧会话。 +- 保持现有 GitHub/GitLab OAuth、local login、CLI Device Flow、API Token、平台角色和 Namespace 行为兼容;公共 OAuth 先进入统一身份核心决策门禁,但 legacy `identity_binding` 继续作为持久化权威。公共飞书/钉钉登录属于后续公共 Provider Adapter 批次,不自动代表企业成员身份。 +- 新增企业登录发现、组织管理、连接管理和最小 Web 管理界面。 +- 动态企业 OIDC 默认关闭,必须同时通过全局开关和 Organization allowlist 才可进入数据面。 +- **不在本批次交付**:SAML、CAS、LDAP 登录、飞书/钉钉真实登录 Adapter、Identity Link、Account Merge V2、Directory/SCIM、群组同步、Namespace entitlement 自动投影。 + +## Capabilities + +### New Capabilities + +- `enterprise-organizations`: 企业登录所需的最小组织、成员、域名、角色、生命周期与租户隔离边界。 +- `federated-authentication`: 协议 Adapter、Login Connection、动态 OIDC、统一账号关联、登录发现和企业会话。 +- `enterprise-identity-governance`: 企业连接 Secret、审计、限流、日志脱敏、灰度开关、观测与回滚约束。 + +### Modified Capabilities + +当前 `openspec/specs/` 没有已归档的身份能力规范,本变更不声明 modified capability。既有认证入口只做兼容迁移,不改变其外部契约。 + +## Impact + +- 后端:`skillhub-domain` 增加 Organization 聚合;`skillhub-auth` 增加连接、OIDC、统一身份、External Identity 和 session origin;`skillhub-app` 增加管理与匿名认证 API、查询编排和审计。 +- 前端:增加组织概览、成员、域名和登录连接管理入口;复用现有登录页并通过 discovery 展示企业登录方式。 +- 数据:在现有 Skill Suites 的 V49–V53 之后新增 V54–V60;迁移只向前增加表、约束和兼容回填,不删除 legacy binding。 +- 配置:新增身份核心模式、动态 OIDC 开关、Organization allowlist、OIDC callback base、Secret envelope keyring 和相关限流配置。 +- 网络:仅 OIDC metadata、JWKS 和 token endpoint 需要受控出站访问;地址解析、协议、重定向和响应大小受策略限制。 +- 发布:必须先完成 OpenSpec strict validation、全量源代码测试、前端检查、迁移测试、exact-SHA 本地镜像、真实 OIDC 参考实现和浏览器登录/撤权验收,才可进入 PR/合并阶段。 +- 分批:完整参考实现不得直接等同于一次性生产启用;统一身份核心、控制面和 OIDC 数据面按 `rollout-plan.md` 分批重组,每批重新生成 exact-SHA 验证记录。 diff --git a/openspec/changes/enterprise-identity-platform/review-checklist.md b/openspec/changes/enterprise-identity-platform/review-checklist.md new file mode 100644 index 00000000..0fccf065 --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/review-checklist.md @@ -0,0 +1,146 @@ +# Enterprise Identity R1-A Local Review Checklist + +This checklist is for the current local reference branch `feature/enterprise-login-release`. +It does not authorize commit, push, PR creation, deployment, or production configuration changes. + +## Review package A — OpenSpec and rollout boundary + +Read first: + +- `proposal.md` +- `design.md` +- `rollout-plan.md` +- `tasks.md` +- `verification.md` +- `specs/federated-authentication/spec.md` +- `specs/enterprise-identity-governance/spec.md` +- `specs/enterprise-organizations/spec.md` + +Check: + +- R1-A means public OAuth enters the unified identity-core decision gate. +- Legacy `identity_binding` remains the runtime write and rollback authority for public OAuth. +- V60 shadow backfill is described as consistency/rollback support, not as V2 write-authority cutover. +- Feishu/DingTalk real adapters, SCIM/Directory/Group sync, Namespace entitlement, SAML, CAS and LDAP are non-goals for the first merge batch. +- Rollback order is runtime switches first; no destructive migration rollback. + +## Review package B — backend authentication compatibility + +Main files: + +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCore.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityDecision.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridge.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/EnterpriseLoginAppService.java` + +Check: + +- LEGACY and SHADOW do not change existing login outcome. +- ACTIVE fails closed before active or pending legacy binding writes when unified core returns Denied/Conflict or invalid resolution. +- OAuth success ignores saved API requests and defaults to `/` unless a safe app-relative return target exists. +- Auth catalog shows public OAuth only when a real non-placeholder client ID exists. +- Auth catalog shows enterprise discovery only when control switch, login switch, allowlist and ACTIVE identity core all permit it. +- Enterprise discovery fallback public methods do not include `ENTERPRISE_DISCOVERY` itself. + +Targeted verification: + +```bash +cd server +MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am \ + -Dtest=AuthMethodCatalogTest,EnterpriseLoginAppServiceTest,OAuth2LoginHandlersTest,OAuthLoginRedirectSupportTest,OAuthLoginFlowServiceTest,LegacyPlatformIdentityCoreBridgeTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am -DskipTests package +``` + +## Review package C — configuration-driven login and registration UI + +Main files: + +- `web/src/pages/login.tsx` +- `web/src/pages/register.tsx` +- `web/src/features/auth/auth-shell.tsx` +- `web/src/features/auth/auth-entry-switch.tsx` +- `web/src/features/auth/enterprise-login-discovery.tsx` +- `web/src/features/auth/login-button.tsx` +- `web/src/shared/lib/auth-route.ts` +- `web/src/api/types.ts` +- `web/src/app/layout.tsx` +- `web/src/assets/login-network.webp` +- `web/src/assets/login-network.webp.json` +- `web/src/i18n/locales/{zh,en,ru}.json` + +Check: + +- Login and register own a dedicated full-screen shell; the marketplace header/footer do not wrap them. +- The service catalog decides visible entries; frontend has no fixed GitHub/GitLab/Feishu/DingTalk fallback. +- Enterprise account and personal account are mutually exclusive panels when both are configured. +- Public OAuth stays visible when enterprise discovery is disabled. +- No configured provider means no fake button. +- Login/register use the same safe return target rules: only app-relative paths survive; unsafe targets go `/`. +- Enterprise discovery trims the identifier before submitting to the backend but preserves typed input when the user returns to edit. +- i18n keys used by login/register/enterprise/Organization pages exist in zh/en/ru. + +Targeted verification: + +```bash +cd web +pnpm exec vitest run \ + src/pages/login.test.tsx \ + src/pages/register.test.tsx \ + src/features/auth/enterprise-login-discovery.test.tsx \ + src/features/auth/login-button.test.tsx \ + src/shared/lib/auth-route.test.ts \ + --maxWorkers=1 +pnpm run typecheck +pnpm run lint +pnpm run build +``` + +## Review package D — Organization list and administration UX boundary + +Main files: + +- `web/src/pages/dashboard/organizations.tsx` +- `web/src/features/organization/organization-admin-shell.tsx` +- `web/src/pages/dashboard/organization-admin-pages.test.tsx` +- `web/src/features/organization/organization-admin-shell.test.ts` + +Check: + +- Member-only Organizations are listed but do not link to administration detail. +- Organization administration entry requires at least one Organization role: + `ORG_OWNER`, `IDENTITY_ADMIN`, `LOGIN_SECRET_ADMIN`, `MEMBER_ADMIN`, or `ORG_AUDITOR`. +- Backend 403 remains the final authorization boundary; frontend only improves navigation. +- One account may belong to multiple Organizations; do not simplify UI or model to a single enterprise assumption. + +Targeted verification: + +```bash +cd web +pnpm exec vitest run \ + src/features/organization/organization-admin-shell.test.ts \ + src/pages/dashboard/organization-admin-pages.test.tsx \ + --maxWorkers=1 +``` + +## Cross-cutting checks + +Run before committing or asking for PR authorization: + +```bash +openspec validate enterprise-identity-platform --strict +git diff --check +rg -n 'BEGIN (RSA|OPENSSH|PRIVATE)' . +``` + +If a real test token was used during manual validation, search for that exact value locally without +writing it into repository files, shell scripts, logs or PR text. + +Known non-blocking warnings: + +- Vite warns about runtime `runtime-config.js`, mixed static/dynamic dashboard import and large chunks. +- Java test compilation reports existing deprecated/unchecked warnings in unrelated tests. diff --git a/openspec/changes/enterprise-identity-platform/rollout-plan.md b/openspec/changes/enterprise-identity-platform/rollout-plan.md new file mode 100644 index 00000000..f18f9627 --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/rollout-plan.md @@ -0,0 +1,490 @@ +# 统一身份与企业身份平台分批合并与上线计划 + +## 文档状态 + +- 状态:人工评审稿 +- 适用范围:`enterprise-identity-platform` Release 1 及其后续认证、目录同步批次 +- 当前参考实现:`feature/enterprise-login-release` +- 当前参考验证:见 [verification.md](./verification.md) +- 本文不授权创建 PR、合并、部署、修改生产配置或导入真实企业数据 + +本文回答的不是“完整企业身份平台最终长什么样”,而是“如何把已经完成的第一版实现拆成可以逐次合并、逐次部署、逐次启用和逐次验证的发布单元”。稳定需求仍以 `specs/` 为准,架构决策仍以 [design.md](./design.md) 为准。 + +## 1. 为什么需要分批 + +当前参考分支同时包含统一身份核心、Organization 安全边界、Login Connection 控制面、动态 OIDC 数据面、管理页面、数据库迁移和身份测试实验室。它已经作为一个整体通过本地验证,但整体通过不等于适合一次性在生产启用。 + +企业身份有三类不同风险: + +1. **账号风险**:同一个外部身份是否稳定关联到原来的 Platform Account,是否可能误建号、误合并或覆盖可信资料。 +2. **租户风险**:Organization、Membership、角色、连接和会话是否严格隔离,停用后是否立即撤权。 +3. **协议风险**:OIDC、SAML、CAS 等协议的回调、签名、重放、出站请求和供应商差异是否处理正确。 + +如果三类风险同时开放,出现问题时很难判断是账号关联、组织权限还是协议 Adapter 导致,也难以只关闭故障面。因此必须把合并、部署和启用拆开。 + +## 2. 三种动作必须区分 + +| 动作 | 含义 | 是否对用户立即可见 | +|---|---|---| +| 合并 | 代码进入 `main` | 不一定 | +| 部署 | 构建产物运行在环境中,Flyway 可能执行向前迁移 | 不一定 | +| 启用 | 通过模式、数据面开关和 Organization allowlist 让请求进入新路径 | 是 | + +一批代码可以先合并并部署,但保持默认关闭。只有该批验证通过后才启用;启用失败时优先关闭运行时入口,不删除数据库记录,也不执行破坏性降级迁移。 + +## 3. 总体发布序列 + +```text +R1-A 统一身份核心与兼容迁移 + -> R1-A2 公共 Provider Adapter 扩展(飞书/钉钉等,按 Provider 分批) + -> R1-B Organization 与 Login Connection 控制面 + -> R1-C 动态 OIDC 登录数据面 + -> OIDC 小范围灰度并扩大 + -> R2-SAML(独立) + -> R3-CAS(独立) + -> R4-LDAP 登录(只有产品决策明确后) + +认证面稳定后,另起 Provisioning 发布序列: +P1-SCIM 用户生命周期 + -> P2-Group/组织关系同步 + -> P3-Namespace entitlement 投影 + -> P4-厂商目录连接器(按需分别交付) +``` + +认证面回答“登录者是谁”;Provisioning 面回答“企业中有哪些人和群组”;Entitlement 面回答“这些人获得哪些 Namespace/Skill 权限”。三者可以复用 Organization 和 Platform Account,但不得共享连接类型、生命周期状态或失败处理逻辑。认证面内部还要区分 Provider 协议与登录上下文:公共飞书/钉钉登录只建立平台 session,企业飞书/钉钉登录必须在 Organization/LoginConnection 上下文中执行。 + +## 4. Release 1 的三个合并批次 + +### 4.1 R1-A:统一身份核心与兼容迁移 + +#### 目标 + +先让现有公共 OAuth 使用统一的账号状态、身份关联和资料权威规则,不开放企业登录入口。该批次证明“统一身份核心不会破坏现有登录”。 + +#### 包含 + +- 协议无关的 Adapter descriptor、`IdentityAssertion`、typed subject 和标准失败分类。 +- 统一身份决策:已有 binding、预供给 subject、verified email、JIT、账号与 Membership guard。 +- External Identity V2 用于企业登录基础;公共 OAuth 先接入统一身份核心决策门禁,legacy binding 继续作为持久化权威。 +- 现有公共 OAuth 到统一核心的 bridge。 +- merged、system、disabled 等账号状态保护及 Profile Authority。 +- 公共 OAuth 在 ACTIVE 模式下遇到统一核心 Denied/Conflict 时先于 legacy binding 写入 fail closed,并保留低基数观测指标。 +- 为后续企业认证准备的最小 Organization/Connection schema 与内部模型,但不开放管理入口。 +- 本批次所需测试、迁移护栏和运维说明。 + +#### 数据库策略 + +V54–V60 是一条已经联合验证的 expand-only 迁移链: + +| Migration | 主要内容 | +|---|---| +| V54 | Organization、Domain、Membership 和角色基础 | +| V55 | 企业审计上下文 | +| V56 | Login Connection、revision、External Identity V2 和 session origin 基础 | +| V57 | 加密 Secret version | +| V58 | 预供给 immutable subject | +| V59 | 登录关联策略 | +| V60 | 兼容性校验与后续 public OAuth V2 迁移预留,不作为 R1-A 启用门槛 | + +虽然 R1-A 不开放 Organization 管理功能,但 V56 及后续企业认证表依赖这条 schema 链。建议在 R1-A 一次部署 V54–V60,保持全部新业务入口关闭。不要为了让 PR 看起来更小而重新编号、拆改或跨批次修改已经部署的 migration。公共 GitHub/GitLab 可以保留 V60 影子回填和一致性校验;R1-A 不切换公共 OAuth 的运行时写入权威,生产登录仍写入 legacy `identity_binding`。 + +#### 部署与启用 + +1. 合并并部署,保持 `SKILLHUB_IDENTITY_CORE_MODE=LEGACY`。 +2. 验证 local login、公共 OAuth、CLI Device Flow、API Token 和既有 Session。 +3. 切换为 `SHADOW`,新核心只做比较,旧 binding 继续决定结果;观察 mismatch、fallback 和异常。 +4. 在独立验收通过后切换为 `ACTIVE`。 +5. OIDC 控制面、OIDC 登录数据面和 Organization allowlist 继续保持关闭/为空。 + +`SKILLHUB_IDENTITY_CORE_MODE` 对 platform-scoped 公共 OAuth 是全局模式,不能按 Organization 灰度。因此从 `SHADOW` 切换到 `ACTIVE` 是本批次明确的独立发布决策,不能被后续 OIDC 灰度顺带触发。 + +#### 进入下一批的门槛 + +- 空库 V1–V60 和现有 V53→V60 升级均成功。 +- `LEGACY`、`SHADOW`、`ACTIVE` 下公共 OAuth 返回同一 Platform Account。 +- local login、CLI Device Flow 和 API Token 外部契约不变。 +- ACTIVE 下统一身份核心 Denied/Conflict 能够在 active/pending legacy binding 写入前 fail closed;日志不包含 subject、email 或 token。 +- 公共 Provider 登录不会创建 Organization Membership、企业 session origin 或 Namespace 权限。 +- 经过约定观察窗口后没有未解释的 mismatch、登录失败率或重复账号。 + +#### 回滚 + +将 `SKILLHUB_IDENTITY_CORE_MODE` 切回 `LEGACY` 并重新验证现有登录。V54–V60 数据保留;不删除新表或 V2 企业身份数据,不执行 down migration。 + +### 4.1.1 R1-A2:公共 Provider Adapter 扩展 + +#### 目标 + +在统一身份核心稳定后,按 Provider 单独接入飞书、钉钉等公共登录入口。该批次只证明“这个外部账号可以登录 SkillHub 平台账号”,不建立企业成员身份。 + +#### 包含 + +- Provider 专属 authorization、token、userinfo 或 equivalent API 客户端。 +- 稳定 subject 选择、email verified 语义、avatar/displayName 资料权威映射。 +- `/api/v1/auth/methods` catalog 展示、图标、登录按钮和 returnTo 保留。 +- 公共登录回调进入统一身份核心或当前 R1-A 兼容入口。 +- Provider 专属错误映射、日志脱敏、远程 I/O 超时与响应大小限制。 + +#### 不包含 + +- Organization Login Connection。 +- 企业成员 JIT 或预供给。 +- 通讯录、部门、群组或 SCIM 同步。 +- Namespace 权限自动授予。 + +#### 进入下一批的门槛 + +- 真实 Provider 或可重复参考服务完成登录、重复登录、禁用账号和错误回调验证。 +- 该 Provider 在 LEGACY/SHADOW/ACTIVE 或当前等价模式下返回同一 Platform Account。 +- 未配置 client id/secret 时 catalog 不展示按钮;placeholder 不展示。 +- 账号禁用、merged/system 账号、未验证 email 和资料写入边界与 GitHub/GitLab 一致。 +- 登录成功只建立平台 session,不产生 Organization Membership 或企业 session origin。 + +### 4.2 R1-B:Organization 与 Login Connection 控制面 + +#### 目标 + +让管理员能够建立企业身份边界、成员关系和 OIDC 连接配置,但普通用户仍不能从匿名入口发起企业登录。该批次证明“企业配置和权限模型正确”,不同时承担真实登录风险。 + +#### 包含 + +- Organization、Membership、Domain、角色和 authority version 生命周期。 +- 平台创建 Organization 与租户内部管理权限分离。 +- Organization、成员、域名、角色和审计 API。 +- Login Connection、不可变 revision、测试后激活和 suspend/disable 生命周期。 +- client secret envelope encryption、Secret version 和脱敏摘要。 +- Organization 与 Login Connection 管理页面。 +- 稳定分页、租户隔离、并发冲突和审计测试。 +- 控制面所需的 OIDC 配置校验与连接探测,但不开放匿名 start/callback。 + +#### 部署与启用 + +1. 保持 R1-A 已验收的 identity core 模式。 +2. 注入 Secret keyring 和固定的 OIDC public base URI 等运行配置。 +3. 设置 `SKILLHUB_ENTERPRISE_OIDC_ENABLED=true`,只装配控制面。 +4. 明确保持 `SKILLHUB_ENTERPRISE_OIDC_LOGIN_ENABLED=false`。 +5. 保持 `SKILLHUB_ENTERPRISE_ORGANIZATION_ALLOWLIST` 为空。 +6. 只创建测试 Organization,配置、测试并激活测试连接。 + +#### 进入下一批的门槛 + +- `SUPER_ADMIN` 可以创建 Organization,但不能绕过目标 Organization Membership 管理租户数据。 +- Organization 角色满足最小权限;`IDENTITY_ADMIN` 不自动获得 Secret 管理权限。 +- Secret 不出现在 API、UI、审计、异常、应用日志和容器启动日志。 +- revision 未测试不能激活;暂停、禁用必须显式确认。 +- Organization、Membership 和 Connection 状态变化正确递增 authority version,重复命令幂等。 +- 匿名 discovery/start/callback 仍不可用,现有登录入口不受影响。 +- 管理 API 的 OpenAPI 产物、Web typecheck/lint/test 和权限测试通过。 + +#### 回滚 + +设置 `SKILLHUB_ENTERPRISE_OIDC_ENABLED=false`,关闭控制面组件。已创建的 Organization、Connection、revision、Secret 和审计记录保留,不影响现有公共登录。 + +### 4.3 R1-C:动态 OIDC 登录数据面 + +#### 目标 + +在一个测试 Organization 内开放完整 OIDC authorization-code 登录,验证真实身份源、首次关联、重复登录、Session 和即时撤权,然后再逐个扩大 Organization allowlist。 + +#### 包含 + +- 匿名 login discovery、enterprise start 和 callback。 +- 固定 callback origin、opaque public handle 和安全 `returnTo`。 +- 一次性 state、nonce、PKCE、浏览器绑定和 callback replay 防护。 +- discovery/JWKS/token/UserInfo 出站访问安全。 +- issuer、签名、算法、kid、audience/azp、时效和 nonce 校验。 +- 首次登录关联、External Identity、企业 Session origin 和 authority version 重检。 +- 匿名限流、代理信任、错误收敛、日志脱敏和安全审计。 +- `oidc-fast`、Keycloak `oidc-real`、真实浏览器和回滚演练。 + +#### 部署与启用 + +1. 部署后继续保持 `SKILLHUB_ENTERPRISE_OIDC_LOGIN_ENABLED=false`。 +2. 使用测试 Organization 完成控制面连接测试和激活。 +3. 确认 identity core 已经在 R1-A 独立验收为 `ACTIVE`。 +4. 只把测试 Organization 放入 `SKILLHUB_ENTERPRISE_ORGANIZATION_ALLOWLIST`。 +5. 设置 `SKILLHUB_ENTERPRISE_OIDC_LOGIN_ENABLED=true`。 +6. 完成首次登录、重复登录、退出、成员暂停、连接暂停和错误路径验收。 +7. 一个 Organization 一个 Organization 地扩大 allowlist;禁止直接使用全量或隐式通配。 + +#### 进入扩大灰度的门槛 + +- `oidc-fast` L2 和 Keycloak `oidc-real` L3 均基于该批 exact SHA 通过。 +- 真实浏览器完成 discovery → redirect → IdP → callback → session → logout。 +- 首次登录和重复登录只保留一个 Platform Account、Membership 和 External Identity。 +- 错误 state/nonce/signature/audience/issuer、callback replay 和跨 Organization 冲突全部 fail closed。 +- Organization、Membership 或 Connection 暂停后,旧企业 Session 不等待 TTL 即被拒绝。 +- 日志、报告和持久容器日志不含 token、code、state、nonce、Cookie、client secret 或测试密码。 +- 本批的资源上限、清理脚本和回滚演练通过。 + +#### 回滚 + +按以下顺序缩小故障面: + +1. 从 Organization allowlist 移除受影响组织。 +2. 设置 `SKILLHUB_ENTERPRISE_OIDC_LOGIN_ENABLED=false`。 +3. 如问题位于统一身份核心,再将 `SKILLHUB_IDENTITY_CORE_MODE` 切回 `LEGACY`。 +4. 必要时最后关闭 `SKILLHUB_ENTERPRISE_OIDC_ENABLED` 控制面。 + +回滚不删除 Organization、Membership、Connection、External Identity、Session origin 或审计数据。 + +## 5. 测试必须跟随功能批次 + +不能先合功能、最后再补测试。每个批次至少同时携带以下验证资产: + +| 层级 | R1-A | R1-A2 公共 Provider | R1-B | R1-C | +|---|---|---|---|---| +| L0 契约/架构 | Adapter 与身份决策边界 | Provider 与登录上下文分离 | 租户/RBAC/Secret 边界 | 协议和匿名入口边界 | +| L1 单元/持久化 | 关联、兼容、迁移、账号 guard | subject、verified email、错误映射、catalog | 生命周期、并发、分页、审计 | OIDC 校验、事务、限流、Session | +| L2 exact-SHA | 现有认证与数据库升级 | 配置开关、按钮展示、returnTo、无企业副作用 | 控制面 default-off | `oidc-fast`、开关和回滚 | +| L3 真实实现/浏览器 | 不要求 | 真实厂商测试账号或明确参考服务 | 只要求连接探测 | Keycloak 与真实 Windows 浏览器 | + +Keycloak、CoreDNS、Caddy、mock provider 都是隔离的测试设施,不是生产依赖。测试 Profile 必须串行运行并在结束后清理容器、网络、volume、生成凭证和浏览器 Profile。公共 Provider 批次不能只靠 mocked catalog 按钮验收;按钮测试只证明展示和跳转,真实认证必须使用厂商测试账号、官方沙箱或写明边界的参考服务单独证明。 + +## 6. 当前参考分支如何重组 + +当前 `feature/enterprise-login-release` 保留为完整、已验证的参考实现,不对它做交互式 rebase 或历史重写。后续得到授权再按以下方式重组: + +1. 从当时最新 `main` 创建 R1-A 工作分支。 +2. 按能力和文件依赖提取实现,把后期安全修复折叠回所属代码,而不是机械 cherry-pick 一段早期提交。 +3. 保证 R1-A 独立编译、迁移、测试和运行;完成审查、合并、部署与观察后结束该批。 +4. 从已经包含 R1-A 的最新 `main` 创建 R1-B,重复完整验证。 +5. R1-B 合并并完成控制面验收后,再从最新 `main` 创建 R1-C。 +6. 每批生成自己的 exact-SHA 验证记录;当前 [verification.md](./verification.md) 只能作为整体参考证据,不能替代重组后 SHA 的验证。 + +重组过程中可能需要少量代码调整来消除批次间的编译依赖。是否属于 R1-A、R1-B 或 R1-C,以运行时职责和最小可独立验证单元判断,不以原提交时间判断。 + +### 6.1 OpenSpec 归属 + +当前 `enterprise-identity-platform` change 保留为总体架构、完整行为边界和参考验证记录。真正开始重组时,R1-A、R1-B、R1-C 分别建立自己的 OpenSpec change,并分别维护: + +- 本批 proposal、delta spec、design 和 tasks。 +- 本批相对最新 `main` 的明确范围与非目标。 +- 本批 migration、配置、兼容和回滚边界。 +- 本批 exact-SHA 验证记录。 + +不能让三个合并批次共同复用当前已经全部勾选完成的 `tasks.md`,否则后续无法判断某个 PR 到底完成了哪一批要求。子 change 只引用本总体设计,不复制整份架构说明;稳定行为在对应批次完成并验收后,再按 OpenSpec 流程归档到正式 specs。 + +### 6.2 当前参考工作区的本地审查包 + +当前 `feature/enterprise-login-release` 不是最终 PR 形态。人工审查时先按以下四组看,不把它们理解成必须一次合并的单个补丁: + +#### A. OpenSpec 与上线边界 + +目的:确认统一身份、企业登录、公共 Provider 和组织同步的职责边界,避免把 SCIM、Namespace entitlement、飞书/钉钉和 V2 写入权威切换混入 R1-A。 + +- `openspec/changes/enterprise-identity-platform/proposal.md` +- `openspec/changes/enterprise-identity-platform/design.md` +- `openspec/changes/enterprise-identity-platform/rollout-plan.md` +- `openspec/changes/enterprise-identity-platform/tasks.md` +- `openspec/changes/enterprise-identity-platform/verification.md` +- `openspec/changes/enterprise-identity-platform/specs/**/spec.md` +- `design-qa.md` + +审查重点:R1-A 只要求 public OAuth 进入统一身份核心决策门禁;legacy `identity_binding` 仍是运行时写入和回滚权威。V60 影子回填是校验/回滚辅助,不等于切换写入权威。 + +#### B. 后端认证兼容与统一核心门禁 + +目的:在不改变现有 public OAuth 写入路径的前提下,让 GitHub/GitLab 等公共 OAuth 先经过统一身份核心决策;ACTIVE 模式下 Denied/Conflict 必须在 active/pending legacy binding 写入前 fail closed。 + +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCore.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityDecision.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridge.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java` +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/*Test.java` +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java` +- `server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java` + +审查重点:LEGACY/SHADOW 不改变旧登录结果;ACTIVE 只在统一核心明确拒绝或冲突时阻断。错误日志不得包含 subject、email、token 或 client secret。 + +#### C. 配置驱动登录页与回跳安全 + +目的:登录页由服务端 auth catalog 决定显示哪些入口;个人账号、企业账号和 public OAuth 可以同时存在,但没有配置的入口不展示。登录成功默认回首页,显式安全 `returnTo` 保留来源。 + +- `web/src/pages/login.tsx` +- `web/src/pages/register.tsx` +- `web/src/features/auth/auth-shell.tsx` +- `web/src/features/auth/auth-entry-switch.tsx` +- `web/src/features/auth/enterprise-login-discovery.tsx` +- `web/src/features/auth/login-button.tsx` +- `web/src/shared/lib/auth-route.ts` +- `web/src/api/types.ts` +- `web/src/app/layout.tsx` +- `web/src/assets/login-network.webp` +- `web/src/assets/login-network.webp.json` +- 对应 `web/src/**/*.test.tsx?` +- `web/e2e/enterprise-identity-flow.spec.ts` +- `web/src/i18n/locales/{zh,en,ru}.json` + +审查重点:前端不能硬编码 GitHub、飞书、钉钉等入口为“默认可用”;按钮只来自服务端 catalog。mocked catalog 只能证明展示和跳转,不能证明真实 Provider 登录。 + +#### D. Organization 管理入口的 UX 边界 + +目的:成员可以看到自己所属 Organization,但没有组织管理角色时不能进入管理详情;后端 403 仍是最终权限边界。 + +- `web/src/pages/dashboard/organizations.tsx` +- `web/src/features/organization/organization-admin-shell.tsx` +- `web/src/pages/dashboard/organization-admin-pages.test.tsx` + +审查重点:一个账号可以属于多个 Organization;列表页不是“当前企业唯一入口”。是否要改成单组织优先体验是产品交互问题,不应削弱多组织模型。 + +#### 不进入第一批的内容 + +- 飞书、钉钉真实 Adapter 合并。 +- SAML、CAS、LDAP 登录。 +- SCIM、Directory、Group 或 Namespace entitlement 同步。 +- public OAuth 运行时写入权威切到 External Identity V2。 +- 生产环境导入真实企业组织或真实目录数据。 + +## 7. 合并与上线纪律 + +- 同一时间只推进一个功能批次,不预先堆积多个相互依赖的开放 PR。 +- 每批必须基于最新 `main`,记录 base SHA、head SHA、镜像 digest、migration 版本和配置状态。 +- PR 合并不等于允许部署;部署成功不等于允许启用。 +- Flyway migration 一经共享环境执行即视为不可变,只能追加修复 migration。 +- 未完成该批退出门槛时,不开始下一批。 +- 生产启用先使用一次性测试 Organization 和测试身份,不直接导入真实企业目录。 +- 任一关键指标异常时先关闭最窄入口,再判断是否需要应用版本回滚。 + +## 8. 后续认证协议的交付模板 + +OIDC 稳定后,SAML、CAS 和可能的 LDAP 登录分别建立独立 OpenSpec。每个协议批次只新增: + +- 协议 Adapter 和版本化配置 schema。 +- 控制面校验、连接探测和 runtime snapshot materializer。 +- 协议特有的安全校验与错误映射。 +- 一个成熟的开源参考服务或可重复的测试实现。 +- 真实协议流程、浏览器验收、故障路径和回滚说明。 + +这些批次复用统一身份核心、External Identity、Organization、Membership、Profile Authority 和 Session guard。Adapter 仍然不得决定建号、账号合并、成员授权或 Namespace 权限。 + +LDAP 可能同时被用作“用户输入企业密码直接登录”和“后台读取企业目录”。这两种能力必须使用不同连接类型和安全边界:前者属于 authentication,后者属于 provisioning。没有明确 TLS、稳定 object id、密码不落盘和连接隔离方案前,不把 LDAP 标记为已支持的登录协议。 + +## 9. 组织同步的独立发布序列 + +组织同步不进入 Release 1。后续建议至少拆为三个批次: + +### P1:SCIM 用户生命周期 + +- 建立 Directory Connection 与 source-owned 属性边界。 +- 支持 User 创建、更新、停用和幂等重放。 +- 维护外部目录对象 ID,不用 email 作为稳定主键。 +- 不自动授予 Namespace 权限。 + +### P2:Group 与组织关系同步 + +- 同步 Group、成员关系和删除/停用语义。 +- 定义全量调和、增量事件、游标、水位和冲突处理。 +- 手工 Membership 与目录 Membership 不互相覆盖。 + +### P3:Namespace entitlement 投影 + +- 单独配置 Group/Organization 到 Namespace role 的映射。 +- 使用 source-aware grant,删除目录来源时不误删手工授权。 +- 提供 dry-run、差异预览、审计和可恢复撤权。 + +厂商连接器按需建立在 P1/P2 之后。钉钉、飞书、企业微信或 LDAP Directory 的数据模型差异由各自 Adapter 吸收,不能把厂商字段直接扩散到 Platform Account、Organization 或 Namespace 核心模型。 + +## 10. 人工评审需要确认的决定 + +在开始重组代码前,需要人工明确接受或修改以下决定: + +1. 是否接受 R1-A 先部署完整 V54–V60 expand-only schema,但暂不开放 Organization/OIDC 功能。 +2. 是否接受公共 OAuth 按 `LEGACY → SHADOW → ACTIVE` 独立完成统一身份核心切换。 +3. 是否接受 R1-B 允许管理员配置并测试 OIDC Connection,但匿名企业登录保持关闭。 +4. R1-C 首个灰度 Organization、负责人、观察窗口和停止条件是什么。 +5. OIDC 之后优先做 SAML、CAS 还是 LDAP 登录;每种协议仍保持独立批次。 +6. 是否确认 SCIM/Directory、Group 同步和 Namespace entitlement 分别立项,不与认证协议混合。 + +以上六项确认前,只保留当前参考分支和文档,不创建分批 PR。 + +## 11. R1-A 本地提取作业单 + +本节是从当前参考工作区重组第一批 PR 时的操作边界。它不是新的产品范围;只把本文前面已经定义的 R1-A 落成可检查的文件与验证清单。 + +### 11.1 第一批只解决什么 + +R1-A 的可交付结果是: + +```text +现有 public OAuth/GitHub/GitLab + -> 提取已验证 facts + -> 经过统一身份核心决策门禁 + -> LEGACY/SHADOW 不改变旧结果 + -> ACTIVE 下 Denied/Conflict 在 legacy binding 写入前 fail closed + -> legacy identity_binding 仍是 public OAuth 运行时写入和回滚权威 +``` + +登录页改版、Organization 管理 UI、动态 OIDC 数据面和飞书/钉钉 Adapter 都不是 R1-A 的必要条件。若为了编译保留少量 schema/config/model,也必须保持入口默认关闭,且 PR 描述中明确“为后续批次铺 schema,不开放用户入口”。 + +### 11.2 建议进入 R1-A 的文件组 + +后端认证兼容核心: + +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCore.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityDecision.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridge.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java` +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridgeTest.java` +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java` + +如果 R1-A 同时承载“登录成功默认回首页、returnTo 安全收敛”这个兼容修复,则可以加入: + +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java` +- `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java` +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupportTest.java` +- `server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java` +- `web/src/shared/lib/auth-route.ts` +- `web/src/shared/lib/auth-route.test.ts` +- 导航栏登录 returnTo 保留所需的最小 `web/src/app/layout.tsx` 改动 + +若加入 returnTo 修复,PR 标题或正文必须单独列为“登录回跳安全兼容修复”,避免 reviewer 误以为它是企业登录 UI 的一部分。 + +### 11.3 不应进入 R1-A 的文件组 + +以下内容应留到 R1-B/R1-C 或 UI 批次,除非人工明确改变拆批策略: + +- `web/src/pages/login.tsx` 和 `web/src/pages/register.tsx` 的分屏 UI 大改。 +- `web/src/features/auth/auth-shell.tsx`、`auth-entry-switch.tsx`、`login-network.webp`。 +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java` 中声明 `ENTERPRISE_DISCOVERY` 的用户入口逻辑。 +- `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/EnterpriseLoginAppService.java` 的 discovery fallback UI 逻辑。 +- Organization 列表/管理入口 UX 调整。 +- 飞书、钉钉、SAML、CAS、LDAP、SCIM、Directory、Group、Namespace entitlement。 +- public OAuth runtime write authority 切到 External Identity V2。 + +这些文件可以在参考分支保留,供人工体验完整效果;但第一批 PR 不应夹带,否则 R1-A 会变成“统一身份核心 + 新登录页 + 企业发现入口”混合风险。 + +### 11.4 R1-A 验证命令 + +提取到新的 R1-A 分支后至少运行: + +```bash +openspec validate --strict +git diff --check + +cd server +MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am \ + -Dtest=OAuthLoginFlowServiceTest,LegacyPlatformIdentityCoreBridgeTest,OAuth2LoginHandlersTest,OAuthLoginRedirectSupportTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am -DskipTests package + +cd ../web +pnpm exec vitest run src/shared/lib/auth-route.test.ts --maxWorkers=1 +pnpm run typecheck +pnpm run lint +``` + +如果 R1-A 没有带任何 Web 改动,则 Web 命令可以退化为 OpenAPI freshness 和现有认证入口冒烟;但 PR 描述必须说明为什么没有登录页/UI 变更。 + +### 11.5 R1-A 人工验收 + +R1-A 人工验收只看现有登录是否被统一核心门禁破坏: + +1. `LEGACY`:local login、GitHub/GitLab public OAuth、CLI Device Flow、API Token 行为不变。 +2. `SHADOW`:public OAuth 登录结果不变;统一核心异常不影响旧登录结果;日志不含 subject、email、token 或 secret。 +3. `ACTIVE`:正常 public OAuth 仍登录到同一 Platform Account;统一核心 Denied/Conflict 时 fail closed,且不创建 active 或 pending legacy binding。 +4. public OAuth 登录不会创建 Organization Membership、企业 session origin 或 Namespace 权限。 +5. 如果包含 returnTo 修复:无 returnTo 登录回 `/`;安全应用内 returnTo 保留 query/hash;外部 URL、协议相对 URL、反斜杠和控制字符回 `/`。 + +只有以上通过后,才进入 R1-B 控制面。不要因为参考分支里的完整登录页已经能跑,就跳过 R1-A 的独立验收。 diff --git a/openspec/changes/enterprise-identity-platform/specs/enterprise-identity-governance/spec.md b/openspec/changes/enterprise-identity-platform/specs/enterprise-identity-governance/spec.md new file mode 100644 index 00000000..850d8fff --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/specs/enterprise-identity-governance/spec.md @@ -0,0 +1,133 @@ +## Purpose + +定义企业认证第一批上线所需的 Secret、审计、隐私、限流、可观测性、灰度和回滚约束,使管理员操作与匿名登录入口能够在生产环境安全运行。 + +## ADDED Requirements + +### Requirement: Login secrets are encrypted and separately versioned + +OIDC client secret SHALL 使用独立 envelope encryption 存储,并记录用途、key id、版本和状态;Login Connection revision 只保存 Secret version reference。API、UI、审计、异常和日志不得返回明文。 + +#### Scenario: Connection is created with a secret +- **WHEN** 有 Secret 轮换权限的管理员创建需要 client secret 的 OIDC connection +- **THEN** 系统加密保存 Secret、将其版本绑定到 revision,并只返回 configured/redacted 摘要 + +#### Scenario: Secret is replaced +- **WHEN** 获授权管理员为已有 connection 创建携带新 Secret 的 revision +- **THEN** 系统创建新的 Secret version,不覆盖历史密文,且新版本只有在对应 revision 激活后进入新登录数据面 + +#### Scenario: Request or exception is logged +- **WHEN** Secret 出现在 DTO、校验失败或上游异常上下文中 +- **THEN** `toString`、错误响应和日志都不得包含 Secret 值或加密 envelope + +### Requirement: Connection data-plane changes require explicit authority and confirmation + +创建/修改 Login Connection SHALL 要求 `IDENTITY_ADMIN`;写入 Secret SHALL 额外要求 Secret 轮换权限;激活、暂停和禁用 SHALL 要求显式确认并产生审计。 + +#### Scenario: Reader attempts to rotate a secret +- **WHEN** 只有连接查看权限的成员提交 client secret +- **THEN** 系统拒绝请求且不创建 Secret version 或 revision + +#### Scenario: Activation confirmation is absent +- **WHEN** 管理员调用激活、暂停或禁用接口但没有提交有效确认 +- **THEN** 系统在状态变更前返回 usage/validation error + +### Requirement: Authentication logs and errors are privacy preserving + +系统 SHALL 对认证请求的全部 query values、Cookie、Authorization header、OIDC code/state/nonce/token、email、subject 和 Secret 进行省略或脱敏;对外错误 SHALL 可操作但不得支持账号、成员、组织或连接枚举。 + +#### Scenario: OIDC callback is logged +- **WHEN** callback URL 包含 code、state 或其他 query values +- **THEN** 日志只保留规范化 path 和统一 `[REDACTED]` query 标记,不保留参数名或值 + +#### Scenario: Upstream authentication fails +- **WHEN** provider 返回错误或内部校验抛出异常 +- **THEN** 客户端收到标准化错误和 requestId,日志记录固定低基数类别而非 token、claims、email、subject 或响应正文 + +### Requirement: Anonymous authentication endpoints are rate limited + +login discovery、enterprise start 和 callback SHALL 分别配置有界的匿名与已认证请求额度;额度耗尽时 SHALL 在调用身份服务前拒绝请求。 + +#### Scenario: Discovery quota is exhausted +- **WHEN** 匿名客户端超过 discovery 窗口额度 +- **THEN** 系统返回 429 和现有结构化错误,不查询 Organization、Membership 或 Login Connection + +#### Scenario: Trusted proxy is not configured +- **WHEN** 请求携带伪造 Forwarded/X-Forwarded-For 且直连地址不属于 operator 配置的可信代理 +- **THEN** 限流身份使用直连地址,不信任转发头 + +### Requirement: Client IP resolution honors explicit proxy trust + +只有 operator 明确配置的可信代理链 SHALL 影响客户端 IP 解析;无效、超长或混合可信度转发链 SHALL fail safely,不能让匿名调用者选择限流 key。 + +#### Scenario: Trusted proxy forwards a client address +- **WHEN** 直连 peer 属于可信代理且转发链符合配置 +- **THEN** 系统按受控规则解析原始客户端地址 + +#### Scenario: Untrusted peer supplies forwarded headers +- **WHEN** 直连 peer 不可信但包含 Forwarded 或 X-Forwarded-For +- **THEN** 系统忽略这些 header 并使用直连 peer + +### Requirement: Enterprise identity changes are auditable + +Organization、Membership、Domain、Role Binding、Login Connection、revision、Secret、External Identity 冲突和 session 撤销 SHALL 产生结构化审计或固定类别安全事件,并携带 requestId 和租户上下文。 + +#### Scenario: Connection revision is activated +- **WHEN** 管理员激活经过测试的 revision +- **THEN** 审计包含 actor、organization、connection、revision、前后状态、时间和 requestId,不包含 typed config 中的敏感值或 Secret + +#### Scenario: Login correlation is ambiguous +- **WHEN** 首次登录出现多个账号或 Membership 候选 +- **THEN** 系统拒绝自动绑定并记录可聚合冲突类别,匿名响应不泄露候选对象 + +### Requirement: Rollout controls are independent and observable + +身份核心模式、动态企业 OIDC 控制面开关、匿名登录数据面开关和 Organization allowlist SHALL 独立配置;默认 SHALL 为 legacy-compatible 且企业 OIDC 控制面与登录数据面关闭。系统 SHALL 暴露不含身份数据的启用状态、连接健康和固定类别指标。 + +#### Scenario: Candidate is deployed with defaults +- **WHEN** 新版本使用默认配置启动 +- **THEN** 既有认证继续工作,动态 OIDC 不可发现且不执行 + +#### Scenario: Control plane is enabled without an explicit data-plane switch +- **WHEN** operator 开启动态 OIDC 控制面但没有显式开启匿名登录数据面 +- **THEN** 连接可以配置和测试,但 discovery、start 和 callback 仍保持关闭 + +#### Scenario: One organization is allowlisted +- **WHEN** operator 先开启控制面完成连接配置与测试,再开启登录数据面并只加入一个测试 Organization +- **THEN** 只有该 Organization 的 ACTIVE connection 可进入 discovery/start/callback,其他组织行为不变 + +### Requirement: Rollback is non-destructive + +operator SHALL 能依次移除 Organization allowlist、关闭 OIDC 登录数据面、将身份核心切回 LEGACY,而不删除新表、V2 binding、session origin 或审计记录;控制面开关可以独立保留用于诊断。 + +#### Scenario: Dynamic OIDC shows elevated failures +- **WHEN** callback failure、identity conflict 或连接不可用指标超过发布阈值 +- **THEN** operator 可先关闭对应 Organization 或全局 OIDC 数据面,现有公共认证继续可用 + +#### Scenario: Unified core must be rolled back +- **WHEN** 公共 OAuth 的统一核心门禁导致无法接受的登录失败、冲突或异常 +- **THEN** operator 切回 LEGACY 模式,应用不要求执行 Flyway down migration,legacy `identity_binding` 仍可继续服务现有登录 + +### Requirement: Release evidence is bound to the exact candidate SHA + +可合并结论 SHALL 基于同一冻结 SHA 的源代码检查、本地镜像、数据库迁移、真实 OIDC 参考实现和浏览器验收;旧分支、旧镜像或 Mock 测试不得替代 exact-SHA 证据。 + +#### Scenario: Unit tests pass but runtime proof is missing +- **WHEN** L0/L1 检查通过但 candidate image 或真实 OIDC 浏览器流程未完成 +- **THEN** 交付状态保持未就绪,不声称可合并或可发布 + +#### Scenario: Lab run completes +- **WHEN** exact-SHA OIDC lab 和浏览器矩阵完成 +- **THEN** 报告记录 source SHA、image digest、迁移版本、命令、用例结果、资源使用和清理结果,且凭证扫描通过 + +### Requirement: Identity validation lab is isolated and resource bounded + +测试实验室 SHALL 与生产部署文件和数据隔离,使用开源 OIDC 参考实现、测试凭证、受限 CPU/内存和可清理网络/volume;不得将实验室服务加入生产 compose 或镜像。 + +#### Scenario: Lab starts under WSL pressure +- **WHEN** 本地机器资源紧张时启动身份实验室 +- **THEN** 编排只启动当前批次必需的 PostgreSQL、Redis、SkillHub 和 OIDC provider,并应用明确资源限制,不启动 SCIM、LDAP、SAML 或 CAS 服务 + +#### Scenario: Lab stops after validation +- **WHEN** 测试结束或失败 +- **THEN** 清理脚本停止并移除本次创建的容器、网络、临时 volume、测试凭证和浏览器工件,不触碰其他项目资源 diff --git a/openspec/changes/enterprise-identity-platform/specs/enterprise-organizations/spec.md b/openspec/changes/enterprise-identity-platform/specs/enterprise-organizations/spec.md new file mode 100644 index 00000000..48f2b61d --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/specs/enterprise-organizations/spec.md @@ -0,0 +1,136 @@ +## Purpose + +定义企业登录所依赖的最小租户、成员、域名与管理权限边界,使企业身份能够在不依赖 Namespace、目录同步或自动授权的情况下安全建立和撤销。 + +## ADDED Requirements + +### Requirement: Organization is an independent security boundary + +系统 SHALL 将 Organization 建模为独立于 Platform Account、Namespace 和身份提供方的安全边界,并为每个 Organization 分配不可变标识和唯一 slug;创建 Organization 不得隐式创建、接管或授权任何 Namespace。 + +#### Scenario: Platform administrator creates an organization +- **WHEN** `SUPER_ADMIN` 提交唯一 slug、显示名和一个已有可登录 Platform Account 作为初始 owner +- **THEN** 系统在一个事务中创建 ACTIVE Organization、owner 的 ACTIVE Membership 和 `ORG_OWNER` role binding,且不创建 Namespace + +#### Scenario: Duplicate slug is rejected +- **WHEN** 管理员提交已占用的 Organization slug +- **THEN** 系统返回冲突错误且不留下 Organization、Membership 或 role binding 部分数据 + +### Requirement: Platform and tenant administration are separate + +系统 SHALL 将平台创建/处置 Organization 的权限面与组织内部管理权限面分离;平台角色不得替代目标 Organization 的 Membership 和组织角色。 + +#### Scenario: Platform role cannot bypass tenant authorization +- **WHEN** 仅具有 `SUPER_ADMIN` 平台角色但不是目标 Organization 成员的账号访问组织管理 API +- **THEN** 系统拒绝访问且不返回目标组织的成员、域名、连接或角色信息 + +#### Scenario: Member lists own organizations +- **WHEN** 已认证账号分页查询自己可管理或可查看的 Organization +- **THEN** 系统只返回该账号具有当前 ACTIVE Membership 的组织,并使用稳定排序和不超过 100 的页大小 + +#### Scenario: Member-only organization is shown without management entry +- **WHEN** 已认证账号拥有目标 Organization 的 ACTIVE Membership 但没有任何组织管理或审计角色 +- **THEN** Web UI 可以展示该 Organization 的成员关系,但不得提供进入组织管理详情页的可点击入口,服务端管理 API 仍以 403 作为最终保护 + +### Requirement: Organization data is tenant isolated + +所有 Organization Membership、Domain、Role Binding、Login Connection、External Identity、Session Origin 和组织审计 SHALL 绑定唯一 Organization;读取和写入 SHALL 同时使用 Organization id 限定。 + +#### Scenario: Cross-organization object id is supplied +- **WHEN** Organization A 的管理员把 Organization B 的 membership、domain 或 connection id 放入 A 的管理路径 +- **THEN** 系统按不存在或无权限处理,不变更任何对象且不泄露 B 的敏感信息 + +### Requirement: Organization lifecycle invalidates stale authority + +Organization SHALL 支持 `ACTIVE → SUSPENDED → ACTIVE` 和 `SUSPENDED → DECOMMISSIONED`;`DECOMMISSIONED` 为终态。每次有效状态变更 SHALL 递增 authority version,相同目标状态的重复命令 SHALL 幂等。 + +#### Scenario: Organization is suspended +- **WHEN** 获授权管理员暂停 ACTIVE Organization +- **THEN** 系统将其置为 SUSPENDED、递增 authority version,并使基于旧版本的企业会话不能继续访问企业边界 + +#### Scenario: Direct decommission is rejected +- **WHEN** 管理员尝试把 ACTIVE Organization 直接置为 DECOMMISSIONED +- **THEN** 系统拒绝该转换且状态和 authority version 保持不变 + +#### Scenario: Repeated suspension is idempotent +- **WHEN** 管理员重复暂停已经 SUSPENDED 的 Organization +- **THEN** 系统返回当前状态且不再次递增 authority version + +### Requirement: Platform account and organization membership are separate + +系统 SHALL 区分可登录的 Platform Account 与 Organization Membership;一个账号可以属于零个、一个或多个 Organization,预供给 Membership 可以暂不绑定 Platform Account。 + +#### Scenario: Member is pre-provisioned before login +- **WHEN** 组织管理员创建带企业资料但没有 Platform Account 的成员 +- **THEN** 系统创建 PROVISIONED Membership,且不生成账号、密码、Token 或登录会话 + +#### Scenario: The same provider subject is pre-provisioned concurrently +- **WHEN** 两个请求同时尝试在同一 Organization 和 Login Connection 下预留同一 typed subject +- **THEN** 数据库只保留一个预留关系,另一个请求返回稳定冲突且不得留下孤立 Membership + +#### Scenario: Account joins two organizations +- **WHEN** 同一 Platform Account 在两个 Organization 中各自具有有效 Membership +- **THEN** 两个 Membership、角色和 authority version 独立管理,不互相复制或覆盖 + +### Requirement: Membership lifecycle is guarded and reversible only where safe + +Membership SHALL 显式区分 PROVISIONED、ACTIVE、SUSPENDED 和 DEPROVISIONED;有效状态变化 SHALL 递增 authority version,DEPROVISIONED 不得由登录流程自动恢复。 + +#### Scenario: Provisioned member is activated by trusted identity +- **WHEN** 统一身份核心通过同一 Organization 和 Login Connection 的 immutable subject 确认 PROVISIONED Membership +- **THEN** 系统原子绑定 Platform Account、将 Membership 置为 ACTIVE 并递增 authority version + +#### Scenario: Suspended member attempts enterprise login +- **WHEN** SUSPENDED Membership 对应的上游身份完成有效协议认证 +- **THEN** 系统拒绝建立企业会话,不自动恢复成员状态 + +#### Scenario: Deprovisioned member attempts re-entry +- **WHEN** DEPROVISIONED Membership 的 email 或 subject 再次出现在登录 assertion 中 +- **THEN** 系统拒绝关联和 JIT 替代创建,直到管理员通过独立流程处理 + +### Requirement: Organization domains have verified ownership lifecycle + +Organization Domain SHALL 经 challenge 验证后才能用于登录发现、verified-email correlation 或 JIT;domain 在所有 Organization 间唯一,并支持禁用。 + +#### Scenario: Unverified domain is submitted to discovery +- **WHEN** 匿名用户提交属于 PENDING 或 DISABLED domain 的 email +- **THEN** 系统不返回该 Organization 的企业登录选项 + +#### Scenario: Domain is already owned +- **WHEN** 另一个 Organization 尝试添加已被有效占用的规范化 domain +- **THEN** 系统拒绝请求且不披露现有 owner 的内部信息 + +#### Scenario: Verified domain is disabled +- **WHEN** 获授权管理员禁用 VERIFIED domain +- **THEN** 新 discovery、email correlation 和 JIT 不再使用该 domain,既有 External Identity binding 不被自动迁移或删除 + +### Requirement: Organization roles use least privilege + +系统 SHALL 使用 `ORG_OWNER`、`IDENTITY_ADMIN`、`LOGIN_SECRET_ADMIN`、`MEMBER_ADMIN` 和 +`ORG_AUDITOR` 分别授权组织所有权、身份连接管理、登录 Secret 管理、成员管理和只读审计。 +本批次不得公开 Directory 或 Entitlement 专用角色;管理 Secret 的动作 SHALL 需要独立的 +`LOGIN_SECRET_ADMIN`,不得由 `IDENTITY_ADMIN` 隐式获得。 + +#### Scenario: Organization owner lacks identity administration role +- **WHEN** 仅有 `ORG_OWNER`、没有 `IDENTITY_ADMIN` 的成员尝试创建或激活 Login Connection +- **THEN** 系统拒绝请求,不把 owner 身份视为所有管理权限的隐式超集 + +#### Scenario: Identity administrator without secret authority rotates a secret +- **WHEN** 只有 `IDENTITY_ADMIN`、没有 `LOGIN_SECRET_ADMIN` 的成员提交新的 client secret +- **THEN** 系统拒绝 Secret 写入和 revision 创建 + +#### Scenario: Member administrator changes membership state +- **WHEN** 具有 `MEMBER_ADMIN` 的成员新增、暂停、恢复或撤销 Organization Membership +- **THEN** 系统允许成员管理,但不授予登录连接、Secret、角色委派或审计读取权限 + +### Requirement: Organization administration is audited and paginated + +所有 Organization、Membership、Domain、Role Binding 和 Login Connection 管理写操作 SHALL 产生结构化、脱敏审计;列表 SHALL 使用稳定服务端分页且每页不超过 100。 + +#### Scenario: Member status changes +- **WHEN** 管理员暂停、恢复或撤销 Membership +- **THEN** 审计记录包含 actor、organization、action、target、时间、requestId 和允许的状态变化,不包含上游 token、Secret 或原始会话标识 + +#### Scenario: Page size is excessive +- **WHEN** 客户端请求超过最大页大小 +- **THEN** 系统拒绝或限制到 100,并保持稳定排序以避免重复或遗漏 diff --git a/openspec/changes/enterprise-identity-platform/specs/federated-authentication/spec.md b/openspec/changes/enterprise-identity-platform/specs/federated-authentication/spec.md new file mode 100644 index 00000000..73dc0f67 --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/specs/federated-authentication/spec.md @@ -0,0 +1,265 @@ +## Purpose + +定义协议无关的企业认证、账号关联和会话契约,并规定首批动态 OIDC 的安全行为,使后续协议能够复用同一身份核心而不复制账号与权限决策。 + +## ADDED Requirements + +### Requirement: Provider protocol and login context are separate + +系统 SHALL 将 Provider 协议适配与登录上下文分离。同一 Provider 可以用于平台公共登录,也可以用于企业 Organization 登录;Provider Adapter 只输出已验证事实,账号绑定、资料权威、成员关系和 session 副作用由统一身份核心按上下文决定。 + +#### Scenario: Public provider login creates only a platform session +- **WHEN** 用户从个人账号登录区域选择 GitHub、GitLab、飞书、钉钉或其他公共 Provider +- **THEN** 系统只按 platform-scoped external identity 完成平台账号登录,不创建 Organization Membership、企业 session origin 或 Namespace 权限 + +#### Scenario: Enterprise provider login requires organization context +- **WHEN** 用户通过企业账号发现流程选择某个 Organization 的飞书、钉钉或 OIDC 登录连接 +- **THEN** 系统在已确定的 Organization/LoginConnection 上下文中完成协议校验,并继续执行企业成员、连接、authority version 和企业 session guard + +#### Scenario: Provider implementation is reused without duplicating identity decisions +- **WHEN** 新增飞书或钉钉 Provider +- **THEN** 协议代码可以复用 authorization、token、userinfo 和 subject 解析,但不得引入绕过统一身份核心的建号、绑定、成员授权或 session 创建路径 + +### Requirement: Adapters output normalized verified assertions + +认证 Adapter SHALL 只在完成协议级校验后输出标准化 Identity Assertion;Adapter 不得创建账号、绑定 Membership、修改企业资料、授予 Namespace 权限或建立 session。 + +#### Scenario: OIDC callback is valid +- **WHEN** OIDC Adapter 完成 issuer、signature、algorithm、key、audience/azp、expiry、issued-at、state、nonce、PKCE 和授权码交换校验 +- **THEN** Adapter 输出 connection、issuer、typed subject、verified claims 和 assurance,由统一身份核心继续决策 + +#### Scenario: Protocol verification fails +- **WHEN** 任一必需协议校验失败或连接 revision 已失效 +- **THEN** 系统拒绝登录,不创建账号、Membership、External Identity 或 session + +### Requirement: Adapter registration is versioned and extensible + +每个 Adapter SHALL 注册唯一 key、contract version、config schema version、interaction model 和 capabilities;运行时 SHALL 拒绝不支持的版本,不得猜测降级。 + +#### Scenario: Compatible adapter revision is activated +- **WHEN** Login Connection revision 引用受支持的 Adapter contract、schema 和 capabilities +- **THEN** 运行时可通过统一 registry 构造 snapshot,身份核心无需增加 provider-name 分支 + +#### Scenario: Unsupported adapter version is encountered +- **WHEN** active revision 引用当前运行时不支持的 contract 或 schema version +- **THEN** 系统拒绝激活或认证,并返回不泄露配置内容的可诊断错误 + +### Requirement: Login Connection runtime is immutable + +Login Connection SHALL 分离可编辑草稿、不可变 revision 和 active runtime snapshot;管理员必须先成功测试 revision 再激活,激活切换 SHALL 原子发生。 + +#### Scenario: New revision becomes active +- **WHEN** 管理员成功测试并激活一个兼容 revision +- **THEN** 新登录使用该 revision,已开始的登录继续使用启动时绑定的 snapshot + +#### Scenario: Untested revision is activated +- **WHEN** 管理员尝试激活未测试成功或测试结果已不匹配的 revision +- **THEN** 系统拒绝激活,当前 active revision 不变 + +#### Scenario: Connection is suspended +- **WHEN** 管理员显式确认并暂停 ACTIVE connection +- **THEN** discovery 不再展示该连接,新的 start/callback 被拒绝,旧企业会话在企业边界失效 + +### Requirement: Login discovery resists enumeration + +匿名 discovery SHALL 仅依据规范化 Organization slug 或 ACTIVE VERIFIED domain 返回公开品牌和登录选项;不得查询或泄露账号、Membership、内部 connection id、issuer 或 Secret。 + +#### Scenario: Two local parts use the same verified domain +- **WHEN** 匿名用户分别提交 `member@example.com` 和 `unknown@example.com`,且 `example.com` 属于同一 Organization +- **THEN** 两个响应的公开登录数据相同,系统不根据 local part 判断成员是否存在 + +#### Scenario: Unknown identifier is submitted +- **WHEN** 匿名用户提交未知组织或 domain +- **THEN** 系统返回通用结果和仍可用的公共登录方式,不表明用户或组织是否存在 + +### Requirement: Anonymous enterprise endpoints use opaque handles and fixed origins + +企业 start/callback SHALL 只接受平台生成的 opaque public handle;callback origin SHALL 来自 operator 配置,不得由 Host、Forwarded header、issuer 参数或客户端 URL 决定。 + +#### Scenario: Internal identifier or unsafe return target is supplied +- **WHEN** 请求使用 connection database id、issuer URL、非法 handle、绝对 URL 或协议相对 URL +- **THEN** 系统在调用 Adapter 前拒绝请求且不泄露连接是否存在 + +#### Scenario: Callback host headers are forged +- **WHEN** callback 请求携带攻击者控制的 Host 或 Forwarded header +- **THEN** 系统仍使用已配置 callback base 和已绑定事务,不把请求头作为可信 origin + +### Requirement: OIDC authorization transaction is one-time and browser-bound + +动态 OIDC SHALL 使用有过期时间的一次性事务绑定 public handle、connection revision、state、nonce、PKCE verifier、return target 和随机浏览器值;callback 必须完整匹配后才能消费。 + +#### Scenario: Valid callback completes once +- **WHEN** callback 的 state、浏览器 Cookie、public handle 和协议响应都匹配未过期事务 +- **THEN** 系统原子消费事务、完成身份决策,并在成功或失败响应中删除绑定 Cookie + +#### Scenario: Callback is replayed +- **WHEN** 已消费 callback 被再次提交,即使参数和 Cookie 相同 +- **THEN** 系统拒绝请求且不创建第二个 session 或重复关联 + +#### Scenario: Browser binding is missing or duplicated +- **WHEN** callback 没有绑定 Cookie或存在多个同名 Cookie +- **THEN** 系统拒绝 callback,不仅依赖 state 判断请求有效 + +### Requirement: OIDC provider data is fetched with outbound protections + +OIDC discovery、JWKS 和 token endpoint 访问 SHALL 限制协议、目标地址、重定向、连接/读取超时和响应大小;每一跳 SHALL 验证全部解析地址,并将已验证地址固定到该次实际连接,同时保留原主机名执行 TLS SNI 和 hostname verification。 + +#### Scenario: Metadata resolves to a private address +- **WHEN** issuer 或 metadata/JWKS/token endpoint 解析到回环、私网、链路本地、保留或不允许的地址 +- **THEN** 系统在发送凭证或读取响应前拒绝访问 + +#### Scenario: Endpoint redirects to a different target +- **WHEN** 上游返回重定向 +- **THEN** 系统不自动跟随未经重新校验的目标,不把 authorization code 或 client secret 转发到新地址 + +#### Scenario: Response exceeds the configured bound +- **WHEN** metadata、JWKS 或 token response 超过允许大小或超时 +- **THEN** 系统中止读取并返回标准化暂不可用错误,不记录响应正文 + +### Requirement: External identity uses an issuer-scoped typed subject + +External Identity SHALL 至少由 Organization、Login Connection、issuer、subject type 和 subject value 唯一确定;显示名或 email 不得替代该坐标。 + +#### Scenario: Same subject is returned by two issuers +- **WHEN** 两个不同 issuer 返回相同 subject 文本 +- **THEN** 系统将它们视为不同 External Identity,不自动合并 Platform Account + +#### Scenario: Same subject is used in two organizations +- **WHEN** 两个 Organization 的连接返回相同 issuer/subject 文本组合 +- **THEN** 两个租户的 identity coordinate 独立,任何关联不得跨 Organization 泄漏 + +### Requirement: First-login correlation is deterministic and conservative + +统一身份核心 SHALL 按“已有有效 binding → 同连接预供给 immutable subject → 策略允许的 verified email → 策略允许的 JIT → 拒绝”顺序处理首次登录,并在歧义、历史成员或已绑定账号场景 fail closed。 + +#### Scenario: Existing binding wins +- **WHEN** assertion 命中已有有效 External Identity binding +- **THEN** 系统使用绑定的 Platform Account,不继续执行 email 或 JIT 匹配 + +#### Scenario: Pre-provisioned subject is matched +- **WHEN** assertion 精确命中同一 Organization 和 Connection 下的有效预供给 immutable subject +- **THEN** 系统在一个事务中关联或创建 Platform Account、激活 Membership 并建立 External Identity + +#### Scenario: Concurrent first logins race +- **WHEN** 同一 subject 的两个首次登录并发观察到尚无 binding +- **THEN** 数据库唯一约束和有界重读使二者收敛到同一 Platform Account、Membership 和 External Identity,不留下孤立账号 + +#### Scenario: Existing account is matched only by email +- **WHEN** verified email 对应的 ACTIVE Membership 已绑定 Platform Account,但新 External Identity 尚未绑定 +- **THEN** 系统拒绝自动关联并要求未来独立的重新认证 Identity Link 流程 + +#### Scenario: Historical member exists +- **WHEN** email 或预供给 subject 命中 SUSPENDED、DEPROVISIONED 或 revoked 历史记录 +- **THEN** 系统拒绝关联且不得通过 JIT 创建替代成员 + +### Requirement: Verified email correlation and JIT are explicit opt-ins + +系统 SHALL 仅在 Adapter 提供可信 verified assurance、Organization 拥有 ACTIVE VERIFIED domain、revision 显式开启 correlation 时使用 email 参与匹配;JIT 还必须单独显式开启并且不存在当前或历史候选。 + +#### Scenario: Correlation is disabled +- **WHEN** assertion 含 verified email 但 revision 未开启 verified-email correlation +- **THEN** 系统不按 email 查询或关联 Membership,也不创建 JIT Membership + +#### Scenario: Email is unverified +- **WHEN** assertion 含 email 但没有 `email_verified=true` 或协议等价保证 +- **THEN** 系统不使用该 email 关联账号或写入可信企业资料 + +#### Scenario: JIT is explicitly allowed +- **WHEN** verified email 属于同一 Organization 的 ACTIVE VERIFIED domain,correlation 与 JIT 都开启,且没有任何当前或历史 Membership 候选 +- **THEN** 系统可以原子创建 JIT Membership、Platform Account 和 External Identity + +### Requirement: Account and authority guards run before session creation + +系统 SHALL 在创建 session 前检查 Platform Account、Organization、Membership、Login Connection 和 External Identity 的当前状态;merged、system、suspended、deprovisioned 或 disabled 对象 SHALL 拒绝登录。 + +#### Scenario: Account is merged or reserved +- **WHEN** 关联结果指向 merged account 或 system account +- **THEN** 系统拒绝登录并记录脱敏安全审计 + +#### Scenario: Membership changes during login +- **WHEN** Membership 在协议验证完成后、session 提交前被暂停或 authority version 变化 +- **THEN** 系统拒绝建立 session,不使用启动时旧状态放行 + +### Requirement: Enterprise sessions retain origin and authority version + +企业 session SHALL 记录 Organization、Membership、Connection、External Identity、assurance、认证时间以及 Organization/Membership authority version,并只持久化不可逆 session key 摘要。 + +#### Scenario: Membership is suspended after login +- **WHEN** 已登录成员被暂停并递增 authority version +- **THEN** 下一次企业资源请求拒绝旧 session,不等待 session 自然过期 + +#### Scenario: Device approval becomes stale +- **WHEN** 企业成员批准 CLI Device Flow 后、兑换前被暂停或 authority version 变化 +- **THEN** 系统拒绝兑换且不创建 API Token + +#### Scenario: API token request carries a browser cookie +- **WHEN** API Token 请求同时携带无关企业浏览器 Cookie +- **THEN** 系统仍以 Token 对应 Platform Account 和当前 Membership 判定企业访问,不继承 Cookie authority + +### Requirement: Existing authentication remains compatible + +local login、公共 OAuth、CLI Device Flow 和 API Token SHALL 保留既有路径和主体语义;统一身份迁移不得自动赋予企业 Membership 或 Namespace 权限。 + +#### Scenario: Public OAuth keeps legacy persistence during the first rollout +- **WHEN** GitHub/GitLab 公共 OAuth 在 R1-A 中登录 +- **THEN** 统一身份核心先做 platform-scoped 决策门禁,legacy `identity_binding` 仍作为持久化权威 + +#### Scenario: Active unified core denies a public OAuth correlation +- **WHEN** identity core 为 ACTIVE 且公共 OAuth 的统一身份决策返回 Denied 或 Conflict +- **THEN** 系统 fail closed,且不创建 active 或 pending legacy binding + +#### Scenario: Public OAuth V2 write authority is evaluated later +- **WHEN** 后续决定把公共 OAuth 运行时写入权威切到 External Identity V2 +- **THEN** 该切换必须独立完成新写入同步、历史回填验证、回滚和真实 Provider 登录验收;不得作为 R1-A 上线前置条件 + +### Requirement: Dynamic enterprise OIDC is disabled by default + +动态企业 OIDC SHALL 仅在身份核心对目标 Organization 为 `ACTIVE`、OIDC 登录数据面开关启用且目标 Organization 位于 allowlist 时公开和执行;控制面开关只决定连接管理组件是否装配,不得隐式开放匿名登录。默认配置 SHALL 不改变现有登录。 + +#### Scenario: Global switch is disabled +- **WHEN** operator 未开启动态企业 OIDC +- **THEN** discovery 不展示企业连接,start/callback 不进入 Adapter,公共登录仍可用 + +#### Scenario: Organization is not allowlisted +- **WHEN** 全局开关已启用但目标 Organization 不在 allowlist +- **THEN** 该组织的连接保持不可发现且认证数据面拒绝使用 + +### Requirement: Login page is driven by advertised authentication capabilities + +登录页 SHALL 使用独立的左右分屏页面框架,不嵌入 Marketplace 导航和大型页脚。前端 SHALL 依据现有匿名 `/api/v1/auth/methods` catalog 展示入口,不硬编码公共 Provider 或无条件展示企业发现。移动端 SHALL 隐藏装饰区并保持表单无横向溢出。 + +服务端 SHALL 仅在 OIDC 控制面和登录数据面均启用、Organization allowlist 非空且 identity core 为 `ACTIVE` 时声明 `ENTERPRISE_DISCOVERY`。该声明 SHALL 不暴露 Organization 清单、内部 connection id、issuer 或 Secret;具体可用连接仍由现有 discovery 和数据面校验。 + +#### Scenario: Mixed enterprise and account login +- **WHEN** catalog 同时声明企业发现和当前表单支持的账号密码方式 +- **THEN** 企业发现默认选中;分段按钮切换时仅显示一个表单,已输入内容保留,左侧品牌位置不变 + +#### Scenario: Registration uses the same authentication shell +- **WHEN** 用户从登录进入注册或直接访问 `/register` +- **THEN** 使用相同分屏外壳,混合方式下保留相同位置的分段入口且个人账号选中;注册内容置于其下,保留现有字段、校验和接口;不回到旧卡片页,小高度窗口仅表单区滚动。没有配置 OAuth 时不得在说明中宣传 OAuth 登录 +- **AND** 分段标签为“企业账号 / 个人账号”,表示身份类型而非登录动作;注册页唯一主标题为“创建个人账号”,不同时显示“登录 SkillHub”或重复的创建标题 + +#### Scenario: Enterprise discovery succeeds +- **WHEN** discovery 返回可用 Organization +- **THEN** 查询表单替换为返回的组织与登录选项,用户可以更换企业;继续通过既有 actionUrl 跳转企业身份源,不收集企业密码 + +#### Scenario: Enterprise login is disabled or rolled back +- **WHEN** 任一 OIDC 开关关闭、allowlist 为空或 identity core 为 LEGACY/SHADOW +- **THEN** catalog 不声明企业入口,页面不显示企业发现空壳,现有公共和本地登录行为保留 + +#### Scenario: Provider is not configured +- **WHEN** catalog 不包含某个 OAUTH_REDIRECT Provider +- **THEN** 登录页不显示该 Provider 的按钮 + +#### Scenario: Configured external providers do not depend on enterprise discovery +- **WHEN** 企业发现入口关闭,服务端 catalog 声明已配置的公共 OAuth 来源 +- **THEN** 页面按 catalog 的 displayName 和 actionUrl 展示并跳转,不硬编码 GitHub、钉钉、飞书等来源列表;入口展示不代表对应协议适配已经完成 + +#### Scenario: Successful authentication returns to the source page or homepage +- **WHEN** 用户完成密码登录、注册或 OAuth 登录 +- **THEN** 有合法的应用内 returnTo 时返回该页面,保留查询参数和锚点;没有或不安全时返回首页 `/`,不默认进入控制台 +- **AND** 导航栏登录入口记录当前页面;显式 CLI 授权目标继续保留;外部 URL、协议相对 URL、反斜杠和控制字符不得成为返回目标 + +#### Scenario: Catalog cannot be loaded +- **WHEN** 登录方式请求失败 +- **THEN** 页面显示可重试错误,不猜测或展示未经 catalog 声明的登录方式 diff --git a/openspec/changes/enterprise-identity-platform/tasks.md b/openspec/changes/enterprise-identity-platform/tasks.md new file mode 100644 index 00000000..daf859cd --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/tasks.md @@ -0,0 +1,94 @@ +## 1. Release boundary and architecture + +- [x] 1.1 Freeze Release 1 as unified identity core, minimal Organization boundary and dynamic OIDC only. +- [x] 1.2 Explicitly exclude SAML, CAS, LDAP login, Identity Link, Account Merge, Directory/SCIM and Namespace entitlement automation. +- [x] 1.3 Add an automated architecture check that keeps protocol types out of organization/directory/entitlement domain packages and prevents adapters from owning identity decisions. +- [x] 1.4 Define the staged rollout and non-destructive rollback order. + +## 2. Organization security boundary + +- [x] 2.1 Add Organization, verified domain, Membership and role-binding models with tenant-scoped uniqueness and optimistic locking. +- [x] 2.2 Implement Organization and Membership lifecycle guards with authority-version increments only on effective state changes. +- [x] 2.3 Separate platform Organization creation from tenant administration; verify platform roles do not bypass Organization membership. +- [x] 2.4 Implement stable, bounded Organization/member/domain/role list and management APIs. +- [x] 2.5 Record allowlisted, redacted audit events for Organization administration. +- [x] 2.6 Add minimal Organization and member/domain administration pages without Namespace mapping or directory controls; member-only organizations are listed without a management-detail entry. + +## 3. Unified identity and compatibility + +- [x] 3.1 Define versioned Adapter descriptors, interaction models, normalized assertions and standard failure taxonomy. +- [x] 3.2 Implement the single identity decision module for binding, pre-provisioned subject, verified-email correlation, JIT and account/membership guards. +- [x] 3.3 Persist External Identity V2 and pre-provisioned immutable subjects with concurrency-safe uniqueness. +- [x] 3.4 Implement field-authority-aware profile resolution and block unverified email from trusted fields. +- [x] 3.5 Persist enterprise session origin using a one-way session-key digest and Organization/Membership authority versions. +- [x] 3.6 Recheck enterprise authority before Device Flow redemption and enterprise-resource access. +- [x] 3.7 Route legacy public OAuth facts through the unified core decision gate while preserving LEGACY/SHADOW compatibility and legacy binding persistence. +- [x] 3.8 Keep legacy public OAuth bindings authoritative in R1-A; fail closed on ACTIVE unified-core Denied/Conflict before active or pending legacy writes. + +## 4. Login Connection and dynamic OIDC + +- [x] 4.1 Implement immutable Login Connection revisions, lifecycle rules, test-before-activate and runtime snapshot materialization. +- [x] 4.2 Encrypt client secrets separately from typed configuration and expose only redacted secret summaries. +- [x] 4.3 Implement secure OIDC discovery/JWKS retrieval with outbound target, redirect, timeout and body-size controls. +- [x] 4.4 Implement one-time OIDC authorization transactions bound to state, nonce, PKCE, connection revision, browser and configured callback origin. +- [x] 4.5 Validate token endpoint response and ID Token issuer, algorithm, signature, key, audience/azp, expiry, issued-at and nonce. +- [x] 4.6 Implement enumeration-resistant login discovery plus anonymous start/callback endpoints using opaque public handles. +- [x] 4.7 Make dynamic OIDC default-off and require both the global switch and Organization allowlist. +- [x] 4.8 Add bounded anonymous rate limits and redact all query values and authentication secrets from logs/errors. +- [x] 4.9 Implement Organization Login Connection management UI and API for create, revision, test, activate, suspend and disable. + +## 5. Database and contract convergence + +- [x] 5.1 Place enterprise authentication migrations at V54–V60 after main's Skill Suites V49–V53 migrations. +- [x] 5.2 Add migration guardrails for immutable released migrations, legacy backfill safety and required uniqueness constraints. +- [x] 5.3 Run empty-database migration from V1 through V60 using the exact candidate image. +- [x] 5.4 Run upgrade migration from a main/V53 database through V60 and verify public OAuth legacy bindings remain the runtime write authority; V60 V2 shadow backfill is only a consistency and rollback aid. +- [x] 5.5 Regenerate checked-in OpenAPI types from the candidate source and prove no uncommitted generated diff remains. + +## 6. Source verification + +- [x] 6.1 Pass strict OpenSpec validation for this three-capability release scope. +- [x] 6.2 Pass enterprise architecture check and its negative self-test fixtures. +- [x] 6.3 Pass targeted migration, connection, controller, association, session, logging and rate-limit tests. +- [x] 6.4 Pass full backend test suite from a clean candidate worktree. +- [x] 6.5 Pass Web typecheck, lint and unit tests. +- [x] 6.6 Pass generated OpenAPI freshness and repository diff/secret scans. + +## 7. Exact-SHA runtime acceptance + +- [x] 7.1 Freeze the candidate SHA and build backend/frontend images only from that SHA; record image digests. +- [x] 7.2 Start a resource-bounded isolated OIDC reference lab with PostgreSQL and Redis and prove health/readiness. +- [x] 7.3 Prove existing local/OAuth configuration, CLI Device Flow and API Token compatibility with enterprise OIDC disabled. +- [x] 7.4 Prove discovery, redirect, IdP authentication, callback, first association, repeat login and logout in a real Windows browser. +- [x] 7.5 Prove invalid state/nonce/signature/audience/issuer, callback replay, cross-Organization subject collision and unsafe return targets fail closed. +- [x] 7.6 Prove member/Organization/connection suspension rejects stale enterprise access without waiting for session expiry. +- [x] 7.7 Prove logs and generated reports contain no token, authorization code, state, nonce, client secret, cookie or test password. +- [x] 7.8 Exercise allowlist removal, enterprise OIDC disable and identity-core LEGACY rollback in order. +- [x] 7.9 Stop the lab and prove containers, temporary networks, volumes, credentials and browser artifacts are cleaned. +- [x] 7.10 Produce one exact-SHA readiness report; only then request permission to push or create a PR. + +## 8. Configuration-driven dedicated login page (local review) + +- [x] 8.1 Replace embedded login card with a dedicated responsive split-screen shell. +- [x] 8.2 Advertise enterprise discovery using existing OIDC switches, allowlist and ACTIVE identity core. +- [x] 8.3 Render only configured methods and mutually exclusive enterprise/account panels; preserve existing direct-password/bootstrap integration. +- [x] 8.4 Test configuration combinations, catalog loading/error/empty states, validation, retained input and discovery request errors. +- [x] 8.5 Pass targeted backend tests, frontend checks and OpenSpec/architecture validation. +- [x] 8.6 Complete final Windows Chrome desktop/mobile interaction and visual review. +- [ ] 8.7 Obtain user acceptance and repeat exact-SHA release checks after committing the approved UI; no PR/push authorization is implied. +- [x] 8.8 Rotate the authorized review lab credentials, certificates and disposable test data without touching other projects. +- [x] 8.9 Replace accordion interaction with segmented entry switching and shared registration shell; retain existing registration validation/API. +- [x] 8.10 Recheck laptop/short-screen/mobile layouts, fixed brand position, discovery redirect and full frontend regression for the revised interaction. +- [x] 8.11 Compact login controls after user feedback and prove the right main does not overflow at ordinary/scaled desktop sizes; retain short-screen scrolling. +- [x] 8.12 Preserve the login source and default successful authentication to homepage; test catalog-driven public providers independently of enterprise discovery. Real vendor authentication remains a separate credential-dependent acceptance check. + +## 9. Public Provider Adapter follow-ups + +- [x] 9.1 Document provider protocol versus login context as a first-class architecture boundary: public provider login must not create Organization Membership, enterprise session origin, or Namespace permissions. +- [x] 9.2 Route GitHub/GitLab public OAuth through the unified identity core decision gate while retaining legacy `identity_binding` persistence. + - [x] 9.2.1 Make ACTIVE public OAuth fail closed on unified-core Denied/Conflict before creating active or pending legacy bindings; LEGACY and SHADOW keep existing behavior. + - [ ] 9.2.2 Optional follow-up: evaluate whether platform-scoped public OAuth should switch runtime write authority to V2 after R1-A is stable; do not block unified authentication rollout on this cutover. +- [ ] 9.3 Rework the Feishu PR as a public Provider Adapter first, with stable subject, verified email semantics, catalog rendering, real login validation, and no enterprise side effects. +- [ ] 9.4 Rework the DingTalk PR as a public Provider Adapter first; define a stable primary subject and explicit alias/migration policy before any merge. +- [ ] 9.5 Add a repeatable public-provider acceptance path using real vendor test credentials or an explicit reference service for each provider; mocked catalog buttons only prove presentation and navigation. +- [ ] 9.6 Add enterprise Feishu/DingTalk Login Connection adapters only after public-provider behavior is stable, reusing protocol code but adding Organization/LoginConnection context and enterprise session guards. diff --git a/openspec/changes/enterprise-identity-platform/verification.md b/openspec/changes/enterprise-identity-platform/verification.md new file mode 100644 index 00000000..d9190c51 --- /dev/null +++ b/openspec/changes/enterprise-identity-platform/verification.md @@ -0,0 +1,230 @@ +# Enterprise Identity Platform Release 1 Verification + +## 2026-09-17 post-manual-acceptance regression plan + +- Scope: current dirty local preview atop `7d8383860a9d9bfbf55d64bf62ee7935108ce3a9`; no new deployment, data reset, commit, PR or push. User confirmed avatar recovery and reported other reviewed login behavior acceptable. Exact-SHA certification remains pending. +- Follow-up from manual enterprise login: `alice@acme.identity-lab.test` successfully completed the Keycloak callback and received an active Organization membership, then hit expected server-side 403 on the Organization administration detail because it has no Organization roles. The Web Organization list now keeps member-only Organizations visible but not clickable into administration; backend 403 remains the final authorization boundary. +- P0 compatibility: run full frontend unit suite with one worker and targeted backend local-password/OAuth/Device Flow/API Token/enterprise Session tests, with bounded JVM resources. Passing requires zero failures; retain actual counts after execution. +- Existing browser-contract cases map to `web/e2e/enterprise-identity-flow.spec.ts`: P0 connection draft/test/explicit activation (mock admin/catalog/API, verify password is not echoed); P1 invalid connection (mock failed test, verify bounded safe error and no secret/internal error); P0 discovery redirect (mock enterprise-only catalog and successful redirect, verify expected explicit target and authenticated heading). These cases change fixture state only, not lab database data. +- Browser executor: existing matching cached Chromium; target `http://127.0.0.1:13000`, one worker. Native Windows Chrome remains the manual-review browser. Capture Playwright screenshots/failure traces in ignored test-results. No real credentials enter browser-contract reports. +- Real public-provider authorization is blocked by configuration: the live catalog contains only PASSWORD and ENTERPRISE_DISCOVERY. No GitHub/DingTalk/Feishu authentication claim will be made from mocked configuration tests. Do not silently configure or merge pending vendor adapters. + +### Completed results + +- `cd web && pnpm exec vitest run --maxWorkers=1`: 207 files / 818 tests passed, 119.63s. The jsdom navigation warning does not represent a real OAuth callback test. +- `cd server && MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am test -Dtest=LocalAuthServiceTest,LocalAuthControllerTest,DeviceAuthServiceTest,DeviceAuthControllerTest,DeviceAuthWebControllerTest,ApiTokenServiceTest,ApiTokenAuthenticationFilterTest,ApiTokenScopeServiceTest,OAuth2LoginHandlersTest,OAuth2AuthorizationRequestResolverTest,OAuthLoginRedirectSupportTest,OAuthLoginFlowServiceTest,AuthMethodCatalogTest,EnterpriseLoginAppServiceTest,EnterpriseBrowserSessionServiceTest,EnterpriseSessionAccessServiceTest,ExpiredPublicSessionFilterTest -Dsurefire.failIfNoSpecifiedTests=false`: 17 classes / 119 tests passed, zero failures/errors/skips, 48.333s. Counts checked from the selected Surefire XML files, not all historical reports. This is a targeted compatibility suite, not a new full backend run. +- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:13000 PLAYWRIGHT_WORKERS=1 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/home/ylhu16/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome pnpm exec playwright test e2e/enterprise-identity-flow.spec.ts --project=chromium --workers=1 --reporter=line`: 3 passed, 12.5s. Initial default launch failed because the headless-shell cache was absent; reused the matching full Chromium executable without download or new services. First executable-enabled run exposed a stale fixture lacking LOGIN_SECRET_ADMIN; two creation cases timed out and the remaining run was cancelled. Added that role to the mock admin only, then reran all three successfully. Existing UI unit test still asserts IDENTITY_ADMIN alone cannot create a confidential connection. No product authorization bypass was added. +- `cd server && MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am -DskipTests package`: passed, including `skillhub-auth` and `skillhub-app` compilation/package. +- `cd web && pnpm exec vitest run src/pages/login.test.tsx src/pages/register.test.tsx src/features/auth/enterprise-login-discovery.test.tsx src/features/auth/login-button.test.tsx src/shared/lib/auth-route.test.ts src/pages/dashboard/organization-admin-pages.test.tsx --maxWorkers=1`: 6 files / 45 tests passed. +- `cd web && pnpm run typecheck && pnpm run lint`: passed. +- `cd web && pnpm run build`: passed. Existing runtime-config, mixed static/dynamic dashboard import and large chunk warnings remain. +- `cd web && pnpm exec vitest run src/pages/register.test.tsx src/pages/login.test.tsx src/features/auth/enterprise-login-discovery.test.tsx --maxWorkers=1`: 3 files / 19 tests passed after adding registration safe-return verification and enterprise-discovery identifier trimming. +- `cd web && pnpm run typecheck && pnpm run lint`: passed after the registration and enterprise-discovery test hardening. +- `cd web && pnpm exec vitest run src/features/organization/organization-admin-shell.test.ts src/pages/dashboard/organization-admin-pages.test.tsx --maxWorkers=1 && pnpm run typecheck`: 2 files / 20 tests passed plus TypeScript check after adding direct Organization role-helper coverage. +- Login/register/enterprise/Organization i18n key scan against `zh`, `en` and `ru`: zero missing keys after switching the disabled-account login banner to the existing `error.auth.local.accountDisabled` key. +- `cd web && pnpm exec vitest run src/pages/login.test.tsx --maxWorkers=1`: 1 file / 11 tests passed after the i18n key correction. +- `cd web && pnpm run typecheck && pnpm run lint`: passed after the i18n key correction and Organization role-helper coverage. +- `cd server && MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am -Dtest=EnterpriseLoginAppServiceTest,AuthMethodCatalogTest -Dsurefire.failIfNoSpecifiedTests=false test`: 16 tests passed after filtering `ENTERPRISE_DISCOVERY` out of enterprise-discovery fallback public methods. +- `cd server && ./mvnw -pl skillhub-auth -am -Dtest=OAuth2LoginHandlersTest,OAuthLoginRedirectSupportTest,OAuthLoginFlowServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`: 20 tests passed after the final OAuth redirect comment/import cleanup. +- `cd server && MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am -Dtest=AuthMethodCatalogTest,EnterpriseLoginAppServiceTest,OAuth2LoginHandlersTest,OAuthLoginRedirectSupportTest,OAuthLoginFlowServiceTest,LegacyPlatformIdentityCoreBridgeTest -Dsurefire.failIfNoSpecifiedTests=false test`: app/auth combined regression passed, 40 tests total. +- `cd server && MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am -DskipTests package`: passed after the final enterprise discovery fallback change. +- `cd web && pnpm exec vitest run src/pages/login.test.tsx src/pages/register.test.tsx src/features/auth/enterprise-login-discovery.test.tsx src/features/auth/login-button.test.tsx src/shared/lib/auth-route.test.ts src/features/organization/organization-admin-shell.test.ts src/pages/dashboard/organization-admin-pages.test.tsx --maxWorkers=1 && pnpm run typecheck && pnpm run lint && pnpm run build`: 7 files / 54 tests passed, TypeScript check, lint and production build passed. Existing Vite runtime-config, mixed static/dynamic dashboard import and large chunk warnings remain. +- 2026-09-17 14:54 CST rerun: the same app/auth combined regression passed again, 40 tests total, using `MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2'`. +- 2026-09-17 14:55 CST rerun: the same seven-file frontend targeted suite passed again, 54 tests total, followed by passing `pnpm run typecheck`, `pnpm run lint` and `pnpm run build`. Existing Vite runtime-config, mixed static/dynamic dashboard import and large chunk warnings remain. +- 2026-09-17 14:55 CST rerun: `cd server && MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am -DskipTests package` passed. +- 2026-09-17 14:55 CST secret hygiene check: exact local scan for the previously used disposable test token and private-key markers returned no repository hits, excluding generated build and dependency directories. The token value is intentionally not recorded in this file. +- Generated OpenAPI types from the running review backend into ignored `reports/openapi-current.d.ts`; `cmp -s` against checked-in `web/src/api/generated/schema.d.ts` returned 0. The standard OpenAPI check was not run because it starts/stops another Compose stack and overwrites the checked-in file. +- Architecture check, diff whitespace check and strict OpenSpec validation passed. Screenshots for the successful three UI cases are in ignored `web/test-results/`; backend results are in module Surefire reports. No real vendor login, current-candidate real Keycloak callback, data reset or new exact-SHA certification was performed in this batch. Staging was not launched to avoid introducing another stack under WSL resource constraints. + +### Manual acceptance steps for this batch + +1. Password UI: open `http://127.0.0.1:13000/login` using the existing local test account. Without returnTo, success goes to `/`. Logout, visit a public Skill detail, click navbar login and verify it returns to that exact detail. Refresh three times and verify the avatar remains visible and its menu opens. +2. Session: refresh after successful login and remain logged in. Logout through avatar menu, refresh again and verify the navbar shows login. Visiting `/dashboard/organizations` while logged out must require authentication. +3. Source safety: use login with `returnTo=%2F%3Fq%3Dplayer%23results` and verify the final address preserves `/?q=player#results`; use `returnTo=https%3A%2F%2Fexample.com` and verify it returns home, not outside SkillHub. +4. Registration UI: from logged-out login click register, verify one create-personal-account heading, return-login operation, stable brand position and short-height scrolling. Actual submission requires an explicitly disposable account; no enterprise membership should be created by personal registration. +5. Existing enterprise reference login (separate from public vendors): use the mapped `https://skillhub.identity-lab.test:18445/login` origin; enter organization `identity-lab-real-acme`, discover and continue to the configured Keycloak option. Use only the existing disposable IdP credentials. Verify redirect to Keycloak (no enterprise password collected by SkillHub), successful callback to homepage/source, avatar and logout/re-login. The HTTP direct origin is not a substitute for the configured HTTPS callback. If credentials or enterprise entry are unavailable, mark blocked rather than passed. +6. Admin read-only review: with an authorized Organization admin account open `/dashboard/organizations`, select the lab Organization and inspect login connections. Secrets must not be displayed. Create button requires both IDENTITY_ADMIN and LOGIN_SECRET_ADMIN; absence with only the former is expected. Do not suspend/rotate/delete the existing connection as part of this read-only review. +7. Real public providers: after a supported adapter and valid application registration are configured, check only those providers appear, enterprise discovery can be disabled without hiding public OAuth, and each authorization/callback returns to the source/home. Current live catalog has no public providers, so this step is blocked. No fake button is a substitute for successful upstream authentication. + +## 2026-09-17 header avatar and browser test cleanup + +- User reported avatar disappearing after refresh and recovering after window resize. Found review helpers leaving a 1366px CDP device-metrics override active after testing, while the maximized Windows window has 1280px available width. A simulated screenshot alone does not establish physical-window containment. +- Cleared the override in the active review browser and added cleanup to the ignored public-provider and login-UI helpers. No application CSS/authentication change was made on this finding. +- Native-window navigation and three cache-bypassing reloads at 1280px retained the 32px avatar button within the viewport (right edge 1224px). The report's containment checks passed; a fresh screenshot was captured. This is a local browser-state correction; the user's exact intermittent disappearance still requires confirmation after refresh, not a claimed application regression fix. + +## 2026-09-17 source-page redirects and configuration-driven external entries + +- Password login/registration and backend OAuth fallback now target `/`, not `/dashboard`. Safe explicit source targets remain unchanged. Navbar login records pathname/query/hash. Frontend and backend reject unsafe return targets; explicit CLI authorization targets are preserved. +- Frontend targeted suite: 5 files / 38 tests passed. After the generic vendor-icon adjustment, the 4 button tests, lint and production build including TypeScript checks passed again. Backend redirect/resolver/flow/catalog/enterprise service suite: 36 tests passed, package build succeeded; expanded five-provider catalog test rerun: 5 tests passed. Existing build warnings remain. +- Windows Chrome tested an intercepted catalog without enterprise discovery: GitHub, GitLab, OIDC, DingTalk and Feishu buttons rendered and each actual button navigated to its configured authorization action with `/search?q=player#results` preserved. Browser exceptions: 0. This verifies presentation/navigation only, NOT real vendor authentication or callback; fixture credentials were not deployed. +- Provider list remains server-owned (`AuthMethodCatalog`, OAuth2 registrations); the page contains no fixed button fallback. Current validity filtering hides absent/blank/placeholder client IDs. GitHub/GitLab configuration exists in application.yml; vendor registration visibility does not implement DingTalk/Feishu protocol handling. Those adapters and real credentials are separate integration prerequisites. +- Current review lab actually advertises password and enterprise discovery, without public GitHub credentials. No environment switch or enterprise feature was removed based on the ambiguous statement that its entry had been cancelled. No PR/push or production mutation. + +## 2026-09-17 account-type labels and registration heading + +- User approved neutral enterprise/personal account labels. Shared switch now displays “企业账号 / 个人账号”; registration has one “创建个人账号” h1 and no “登录 SkillHub” or duplicate creation heading. Chinese/English/Russian translations updated; auth APIs, validation and permission behavior unchanged. +- Targeted 4-file/15-test suite, lint, production build including TypeScript checks, diff whitespace check and OpenSpec strict validation passed. Tests assert a single registration heading and absence of the login heading. +- Completed Windows Chrome rerun explicitly checked both neutral labels, the registration heading and absence of login copy. Existing ordinary/scaled no-overflow, fixed brand, short-screen scrolling, mobile containment and real Keycloak redirect assertions passed; exceptions/console errors 0. Registration screenshot reopened and confirmed. First attempt encountered a missing DOM control during the resize/navigation sequence and failed; the completed rerun is recorded, not the failed attempt. Full frontend/backend/release matrix and registration submission not repeated for this copy-only adjustment. + +## 2026-09-17 compact login and provider integration advice + +- User screenshot showed an internal right-main scrollbar and oversized controls. Previous document-level overflow assertions did not prove internal-main containment. Added explicit main overflow assertions; do not hide scrollbars to bypass failures. +- Compact UI: max form width 440px, password inputs/main action 40px, right heading 24px, left headline 36–48px; tighter header/footer/group spacing. Mixed mode retains accessible headings but removes visually duplicated mode titles. Registration/discovery controls follow the smaller shell. +- Final targeted frontend tests: 4 files / 15 tests passed. Lint and production build (including TypeScript project checks) passed. Existing Vite warnings remain. The full 804-test result below is historical, not a new full-suite run for this CSS-focused change. +- Windows Chrome CDP completed: ordinary 1366×768 personal form main overflow **false**; 1280×678 at deviceScaleFactor 1.5 main overflow **false**, measured input height **40px**. This is a CSS viewport/pixel-scale check, not proof of the user's actual browser zoom setting. Document overflow false; fixed brand, integrated registration, short-screen containment, mobile horizontal containment, real discovery and Keycloak redirect passed. Exceptions and console errors **0**. First attempt failed a DOM evaluation during resize; the completed rerun is the recorded result. +- Captures: ignored review-lab reports `login-ui-{desktop,password,scaled,register,mobile,discovery}.png`; JSON contains desktopFormOverflow and scaledFormOverflow in addition to existing assertions. No full authentication/callback or real vendor login was repeated. +- Fresh independent visual review compared the user's rejected screenshot with all six new captures and returned `Ship`, with no material clipping/readability findings. Functional/short-screen evidence is from the executed Chrome checks, not screenshot-only claims. UI detector returned `[]`. +- Provider PR read-only advice: Feishu #696 head `d3f1d5e65af8139a6021eca52a5d7a114156f3c3`, DingTalk #467 head `0b21fe2f34c6901abf5bc017e37f1d432e66fe33`. Their platform OAuth registrations are not Organization-scoped LoginConnections. See design.md's provider integration section for staged reuse and identity-coordinate constraints. No PR mutation, merge, push or real provider verification. + +## 2026-09-16 dedicated login local review + +### Revised interaction and authorized lab reset + +- User authorized resetting only `skillhub-identity-lab-review`. Its containers, five volumes and generated state were removed; test passwords, OIDC secrets, encryption key and self-signed certificates were regenerated. No old credentials were backed up; only the credential-free Chrome helper and Nginx config were retained temporarily. Other Docker projects were untouched. +- Recreated the existing lab services and moved the existing preview Web container into the same Compose project. No additional long-running service type or production dependency was introduced. Disposable Keycloak organizations/connections were seeded again using the existing script. +- Local-only ignored `preview.compose.yml` mounts the current built backend JAR and frontend dist and labels containers `uncommitted-login-preview`. The committed harness's clean-tree/exact-SHA guards are unchanged. This remains a development preview, not release certification. +- Revised UI: fixed-height split shell; segmented enterprise/personal switching; `/register` uses the same shell and existing validation/API; discovery replaces input with real returned organization/options and supports changing enterprise. Only configured OAuth buttons appear. +- Targeted frontend regression after the visual fix batch: `pnpm exec vitest run src/pages/login.test.tsx src/pages/register.test.tsx src/features/auth/enterprise-login-discovery.test.tsx src/features/auth/login-button.test.tsx --maxWorkers=2`: **4 files / 15 tests passed**. +- Full frontend regression after the visual fix batch: `pnpm exec vitest run --maxWorkers=2`: **207 files / 804 tests passed**. `pnpm run typecheck`, `pnpm run lint`, `pnpm run build`, `git diff --check`, OpenSpec strict validation and enterprise architecture check/self-test passed. Build includes TypeScript project checking. Existing Vite runtime-config/dynamic-import/large-chunk warnings remain. +- Independent visual review identified missing registration segmented entry and misleading unconfigured OAuth subtitle; both were fixed in one UI batch. The reviewer confirmed both resolved and returned `ship` at that two-fix scope. See [design-qa.md](../../../design-qa.md). +- Windows Chrome CDP revised interaction: **passed** at 1366×768 and 390×844. Desktop document has no vertical/horizontal overflow; brand headline bounds are identical across enterprise/personal/register. At 1366×550 registration scrolls only its main area. `/register` route and its email field are present in the shared shell. Real lab discovery and Keycloak redirect passed; no authentication credentials entered, no callback/registration submission repeated. Exceptions and console errors: **0**. +- Captures/report: ignored state `reports/login-ui-{desktop,password,register,mobile,discovery}.png` and `reports/login-ui-browser.json`. Initial inspection raced backend readiness/lazy loading; the helper now waits for rendered controls. A transient Windows/WSL process-launch timeout succeeded on retry. These failures were not recorded as successful checks. +- Final post-fix Chrome rerun passed all recorded flags, including registration's retained segmented switch/local-only subtitle and short-screen containment, with 0 exceptions/console errors. A frame-based resize wait stalled in the background tab and was terminated; a bounded native delay replaced it. The final report reflects the completed rerun, not that interrupted attempt. +- Lab startup requires a full first Keycloak realm import under the existing 1 CPU limit. Current lab remains six protocol/dependency services plus the existing Web preview, now Compose-managed and limited to 128 MiB. No production registry/data accessed and no PR/push. + +This subsection describes an **uncommitted worktree preview**, not the historical exact-SHA candidate below. The previous assertion of byte-identical `server/` and `web/` trees applies only to the older candidate. Current login modifications need a new exact-SHA release record after user acceptance. + +- Worktree: `skillhub-enterprise-login-release`, branch `feature/enterprise-login-release`. +- Preview: `http://127.0.0.1:13000/login`; full enterprise redirect uses the mapped Windows Chrome window at `https://skillhub.identity-lab.test:18445/login`. +- Local backend JAR and frontend static build were copied into the existing isolated review containers. This is a development preview: image revision labels do not certify the replaced artifacts. +- No new services, dependencies, migrations, real users, PR or push. Existing test-only Keycloak and data are reused. +- Server command: `MAVEN_OPTS='-Xmx768m -XX:ActiveProcessorCount=2' ./mvnw -pl skillhub-app -am package -Dtest=AuthMethodCatalogTest,AuthControllerTest,EnterpriseLoginAppServiceTest -Dsurefire.failIfNoSpecifiedTests=false`: **22 passed**, package succeeded. +- Web: `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm run build`: passed. Existing runtime-config/dynamic-import/large-chunk build warnings remain. +- Web full suite before final test additions: **206 files / 798 tests passed**. Final targeted suite including discovery and configured OAuth rendering: **4 files / 13 tests passed**. +- OpenSpec strict validation, enterprise architecture check/self-test and UI mechanical detector passed; asset provenance scan found no missing metadata. +- Real Windows Chrome CDP: desktop 1487×1058 and mobile 390×844; enterprise default, mutually exclusive switching, no horizontal overflow, no marketplace navigation, real Organization lookup and redirect to Keycloak all passed. No credentials were entered and full authentication/callback was not repeated in this UI review. +- Browser exception and console-error counts: zero. Reports/screenshots are in ignored lab state `reports/login-ui-*`. +- Visual review and remaining scope: see [design-qa.md](../../../design-qa.md). +- Existing broader Playwright E2E file was updated for the capability catalog but not rerun; full backend/staging/exact-SHA release matrices were not rerun for this scoped UI change. +- Operational incident: a process-list diagnostic printed the lab-generated trust-store password into tool output. It was not a real API token and was not copied into source or reports. Treat that disposable lab password as exposed; reset/recreate this exact lab after manual acceptance, not production credentials. + +The prior backend JAR is preserved in ignored lab state as `app-pre-login-ui.jar`. Containers remain running for manual review. Cleanup/reset requires a separate user decision so review data is not removed while acceptance is in progress. + +## Candidate + +- Runtime candidate SHA: `854a4f0b067e5cc8a9fb78c3528f86fc68bb5526` +- Base `main` SHA: `36de54157bff59c18c5eff255d2c158df45a3e2a` +- Scope: unified identity core, minimal Organization security boundary and dynamic OIDC +- Excluded: SAML, CAS, LDAP login, Directory/SCIM, Identity Link, Account Merge and Namespace entitlement automation + +This document is a verification record layered on the tested runtime candidate. Its own documentation +commit does not alter `server/` or `web/`; both trees remain byte-identical to the fully tested source +trees recorded below. + +This evidence applies to the complete Release 1 reference candidate. If the implementation is +reassembled into the staged merge units defined in [rollout-plan.md](./rollout-plan.md), every unit +must produce a new exact-SHA verification record; this document cannot be used as a substitute. + +## Built artifacts + +| Artifact | Image ID | Revision label | +|---|---|---| +| `skillhub-server:staging` | `sha256:bef814a5352ce590f0a48236e9b8c88f60d67d5d2952699c18b57faaa2660c6a` | candidate SHA | +| `skillhub-web:enterprise-login-candidate` | `sha256:9cf47c0eb6d00bef9a028114e4707caec0b0d3ec3c58ad291a04cd2d391a20f3` | candidate SHA | + +The frontend image returned HTTP 200 from `/nginx-health` and `/` with the production-equivalent +`SKILLHUB_API_UPSTREAM` setting. The backend image reported healthy in both OIDC lab profiles. + +## Source and migration verification + +| Verification | Result | +|---|---| +| Full Java 21 backend suite | 1,029 passed, 0 failed, 1 skipped | +| Frontend unit suite | 206 files and 794 tests passed | +| Frontend typecheck, lint and production build | passed | +| `ExternalIdentityV2MigrationTest` on Java 21 | 9 passed, including populated V53 to V60 preservation and canonical digest | +| Fresh candidate database | Flyway V60, success | +| Runtime OpenAPI regenerated against candidate image | byte-identical to checked-in `schema.d.ts` | +| Strict OpenSpec and architecture checks | passed | + +The complete backend and frontend suites ran at `acce4b9c87310f617133dbc445c15201a6fe39df`. +The corresponding Git tree IDs are unchanged at the runtime candidate: + +- `server/`: `1ec4e4b5ff1273cbc06cc7f134c9cc6ffcc3dac4` +- `web/`: `02b9771b21698ac3a5ff977606758db55916cbef` + +Later commits only modify identity-lab controls, documentation and seed/verifier behavior. The +candidate-specific V53 to V60 migration test was also rerun after those commits. + +This migration evidence proves the expand-only schema chain and legacy-data preservation for the +complete Release 1 reference candidate. It does **not** make public GitHub/GitLab OAuth V2 +persistence a gate for the staged R1-A rollout: R1-A keeps legacy `identity_binding` authoritative +for public OAuth, routes facts through the unified identity-core decision gate, and fails closed in +ACTIVE mode before creating active or pending legacy bindings when the unified decision returns +Denied or Conflict. + +## Runtime acceptance + +### OIDC fast profile + +- Exact candidate SHA and backend image revision matched. +- Fresh PostgreSQL database migrated to V60. +- OIDC discovery, metadata, JWKS, authorization-code callback, JIT association and Session origin passed. +- An ID Token with the wrong audience failed closed without creating an association. +- Generated report: L2, passed, `containsCredentials: false`. +- Observed steady-state memory was approximately 788 MiB across five bounded containers. + +### OIDC real profile + +- Digest-pinned Keycloak, Caddy, CoreDNS, PostgreSQL and Valkey ran with the candidate SkillHub image. +- Containerized real Chromium authorization-code journey passed. +- First-login binding, persisted Session origin, disabled upstream user rejection and + cross-Organization subject collision rejection passed. +- Generated report: L3, passed, `containsCredentials: false`. +- Observed steady-state memory was approximately 1.44 GiB across six bounded containers. +- The short-lived Playwright container was removed automatically. + +### Windows browser + +Windows Chrome `152.0.7977.83` was launched with one complete host-resolver argument for the two +lab-only domains. The following checks passed through the real Keycloak page: + +- discovery and redirect; +- IdP credential authentication; +- callback and enterprise Session establishment; +- first association; +- logout and subsequent `/api/v1/auth/me` rejection; +- repeat login reusing exactly one External Identity and one user. + +The Windows browser Profile was isolated from the user's normal Profile. All 11 processes using +that exact Profile were stopped, and the Profile directory was moved to the Windows recycle bin. + +## Security and rollback + +- Invalid state, nonce, signature, audience, issuer, callback replay and unsafe return targets are + covered by passing OIDC unit/contract tests. +- Cross-Organization collision and disabled upstream account behavior passed against Keycloak. +- Member, Organization and Login Connection authority changes reject stale access before Session TTL. +- Every generated lab secret is compared against every JSON report and persistent container log. +- The lab now avoids `JAVA_TOOL_OPTIONS`, which exposed the generated trust-store password in JVM + startup output; the regression is enforced by the lab contract test. +- Exact-candidate rollback order passed: remove Organization allowlist, disable enterprise OIDC + login, switch identity core to `LEGACY`, then restore. Each disabled state returned a structured + fail-closed response while the server remained healthy and database data remained at V60. + +## Cleanup and merge assessment + +The exact fast, real, rollback and Windows-browser lab projects were reset. Their containers, +networks, volumes, generated credentials and transient browser artifacts are absent. No production +service, shared database or real identity was used. + +The runtime candidate contains 52 purpose-specific commits because the Release 1 architecture was +developed in small reviewable stages; this record adds one documentation-only commit. A path and +commit-title audit found no Directory/SCIM, CAS, SAML, Skill Suite or unrelated product +implementation mixed into the branch. The PR will still be large because the release intentionally +introduces the unified core, Organization boundary, OIDC adapter, management API/UI, migrations and +their security tests together. + +Release 1 is technically ready for review and merge. It must still follow normal repository CI, +DCO/CLA and human review gates. This verification does not authorize a push, PR, merge, tag, +deployment or package release. diff --git a/server/skillhub-app/src/main/resources/db/migration/V60__organization_foundation.sql b/server/skillhub-app/src/main/resources/db/migration/V60__organization_foundation.sql new file mode 100644 index 00000000..a73e9841 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V60__organization_foundation.sql @@ -0,0 +1,151 @@ +-- Organization foundation shared by unified identity, enterprise SSO and later team features. +-- Expand-only: existing platform and Namespace tables remain untouched so the capability can be +-- rolled out behind feature flags. + +CREATE TABLE organization ( + id VARCHAR(64) PRIMARY KEY, + slug VARCHAR(64) NOT NULL, + display_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + authority_version BIGINT NOT NULL DEFAULT 0, + created_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_organization_slug UNIQUE (slug), + CONSTRAINT ck_organization_slug_normalized CHECK ( + length(slug) BETWEEN 2 AND 64 + AND slug = lower(btrim(slug)) + AND slug !~ '[^a-z0-9-]' + AND slug NOT LIKE '-%' + AND slug NOT LIKE '%-' + AND slug NOT LIKE '%--%' + ), + CONSTRAINT ck_organization_status CHECK ( + status IN ('ACTIVE', 'SUSPENDED', 'DECOMMISSIONED') + ), + CONSTRAINT ck_organization_authority_version CHECK (authority_version >= 0), + CONSTRAINT ck_organization_version CHECK (version >= 0) +); + +CREATE INDEX idx_organization_status_id + ON organization (status, id); + +CREATE TABLE organization_domain ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) NOT NULL REFERENCES organization(id), + domain VARCHAR(253) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + verification_method VARCHAR(64), + verification_token_hash VARCHAR(255), + verified_at TIMESTAMPTZ, + last_checked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_organization_domain_claim UNIQUE (organization_id, domain), + CONSTRAINT ck_organization_domain_normalized CHECK ( + length(domain) BETWEEN 1 AND 253 + AND domain = lower(btrim(domain)) + ), + CONSTRAINT ck_organization_domain_status CHECK ( + status IN ('PENDING', 'VERIFIED', 'DISABLED') + ), + CONSTRAINT ck_organization_domain_verified_at CHECK ( + status <> 'VERIFIED' OR verified_at IS NOT NULL + ), + CONSTRAINT ck_organization_domain_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uk_organization_domain_verified_owner + ON organization_domain (domain) + WHERE status = 'VERIFIED'; + +CREATE INDEX idx_organization_domain_tenant_status + ON organization_domain (organization_id, status, domain, id); + +CREATE TABLE organization_membership ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) NOT NULL REFERENCES organization(id), + user_id VARCHAR(128) REFERENCES user_account(id), + status VARCHAR(32) NOT NULL, + source_type VARCHAR(32) NOT NULL, + source_id VARCHAR(256), + display_name VARCHAR(128), + primary_email VARCHAR(256), + department VARCHAR(256), + employee_number VARCHAR(128), + authority_version BIGINT NOT NULL DEFAULT 0, + activated_at TIMESTAMPTZ, + suspended_at TIMESTAMPTZ, + deprovisioned_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT ck_organization_membership_status CHECK ( + status IN ('INVITED', 'PROVISIONED', 'ACTIVE', 'SUSPENDED', 'DEPROVISIONED') + ), + CONSTRAINT ck_organization_membership_source_type CHECK ( + length(btrim(source_type)) > 0 + ), + CONSTRAINT ck_organization_membership_authority_version CHECK (authority_version >= 0), + CONSTRAINT ck_organization_membership_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uk_organization_membership_current_user + ON organization_membership (organization_id, user_id) + WHERE user_id IS NOT NULL AND status <> 'DEPROVISIONED'; + +CREATE UNIQUE INDEX uk_organization_membership_current_source + ON organization_membership (organization_id, source_type, source_id) + WHERE source_id IS NOT NULL AND status <> 'DEPROVISIONED'; + +CREATE INDEX idx_organization_membership_tenant_status + ON organization_membership (organization_id, status, id); + +CREATE INDEX idx_organization_membership_tenant_user + ON organization_membership (organization_id, user_id, status, id); + +CREATE INDEX idx_organization_membership_tenant_source + ON organization_membership (organization_id, source_type, source_id, id); + +CREATE TABLE organization_role_binding ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) NOT NULL REFERENCES organization(id), + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + role VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_by VARCHAR(128) NOT NULL REFERENCES user_account(id), + revoked_by VARCHAR(128) REFERENCES user_account(id), + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT ck_organization_role_binding_role CHECK ( + role IN ( + 'ORG_OWNER', + 'IDENTITY_ADMIN', + 'LOGIN_SECRET_ADMIN', + 'MEMBER_ADMIN', + 'ORG_AUDITOR' + ) + ), + CONSTRAINT ck_organization_role_binding_status CHECK ( + status IN ('ACTIVE', 'REVOKED') + ), + CONSTRAINT ck_organization_role_binding_revocation CHECK ( + (status = 'ACTIVE' AND revoked_at IS NULL AND revoked_by IS NULL) + OR (status = 'REVOKED' AND revoked_at IS NOT NULL) + ), + CONSTRAINT ck_organization_role_binding_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uk_organization_role_binding_active + ON organization_role_binding (organization_id, user_id, role) + WHERE status = 'ACTIVE'; + +CREATE INDEX idx_organization_role_binding_tenant_user + ON organization_role_binding (organization_id, user_id, status, role, id); + +CREATE INDEX idx_organization_role_binding_tenant_role + ON organization_role_binding (organization_id, role, status, user_id, id); diff --git a/server/skillhub-app/src/main/resources/db/migration/V61__audit_log_organization_context.sql b/server/skillhub-app/src/main/resources/db/migration/V61__audit_log_organization_context.sql new file mode 100644 index 00000000..e5bb36ca --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V61__audit_log_organization_context.sql @@ -0,0 +1,17 @@ +-- Add organization context to the shared audit log so identity operations stay attributable +-- with opaque IDs and organization scoping. +ALTER TABLE audit_log + ADD COLUMN organization_id VARCHAR(64) REFERENCES organization(id), + ADD COLUMN target_ref VARCHAR(128), + ADD COLUMN result VARCHAR(32); + +ALTER TABLE audit_log + ADD CONSTRAINT chk_audit_log_result + CHECK (result IS NULL OR result IN ('SUCCESS', 'DENIED', 'FAILED')); + +CREATE INDEX idx_audit_log_organization_created + ON audit_log(organization_id, created_at DESC, id DESC); + +CREATE INDEX idx_audit_log_target_ref + ON audit_log(target_type, target_ref) + WHERE target_ref IS NOT NULL; diff --git a/server/skillhub-app/src/main/resources/db/migration/V62__external_identity_v2_foundation.sql b/server/skillhub-app/src/main/resources/db/migration/V62__external_identity_v2_foundation.sql new file mode 100644 index 00000000..5c50657d --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V62__external_identity_v2_foundation.sql @@ -0,0 +1,279 @@ +-- External Identity V2 foundation. This migration only expands the schema; it does not migrate, +-- update, or remove the legacy identity_binding read path. + +CREATE TABLE login_connection ( + id VARCHAR(64) PRIMARY KEY, + public_handle VARCHAR(128) NOT NULL, + scope_type VARCHAR(32) NOT NULL, + organization_id VARCHAR(64) REFERENCES organization(id), + scope_key VARCHAR(64) GENERATED ALWAYS AS ( + COALESCE(organization_id, '@platform') + ) STORED, + system_key VARCHAR(128), + display_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + adapter_key VARCHAR(128) NOT NULL, + active_revision_id VARCHAR(64), + last_tested_revision_id VARCHAR(64), + created_by VARCHAR(128) REFERENCES user_account(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_login_connection_public_handle UNIQUE (public_handle), + CONSTRAINT uk_login_connection_id_scope UNIQUE (id, scope_key), + CONSTRAINT uk_login_connection_organization_id UNIQUE (organization_id, id), + CONSTRAINT ck_login_connection_scope CHECK ( + (scope_type = 'PLATFORM' AND organization_id IS NULL) + OR (scope_type = 'ORGANIZATION' AND organization_id IS NOT NULL) + ), + CONSTRAINT ck_login_connection_status CHECK ( + status IN ('DRAFT', 'ACTIVE', 'SUSPENDED', 'DISABLED') + ), + CONSTRAINT ck_login_connection_adapter_key CHECK (length(btrim(adapter_key)) > 0), + CONSTRAINT ck_login_connection_public_handle CHECK ( + public_handle ~ '^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$' + ), + CONSTRAINT ck_login_connection_system_key CHECK ( + system_key IS NULL OR length(btrim(system_key)) > 0 + ), + CONSTRAINT ck_login_connection_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uk_login_connection_scoped_system_key + ON login_connection (scope_key, system_key) + WHERE system_key IS NOT NULL; + +CREATE INDEX idx_login_connection_tenant_status + ON login_connection (organization_id, status, id) + WHERE organization_id IS NOT NULL; + +CREATE INDEX idx_login_connection_adapter_status + ON login_connection (adapter_key, status, id); + +CREATE TABLE login_connection_revision ( + id VARCHAR(64) PRIMARY KEY, + connection_id VARCHAR(64) NOT NULL REFERENCES login_connection(id), + revision BIGINT NOT NULL, + adapter_contract_version VARCHAR(32) NOT NULL, + config_schema_version INTEGER NOT NULL, + capabilities JSONB NOT NULL, + typed_config JSONB NOT NULL, + secret_binding_version BIGINT, + created_by VARCHAR(128) REFERENCES user_account(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_login_connection_revision_number UNIQUE (connection_id, revision), + CONSTRAINT uk_login_connection_revision_identity UNIQUE (connection_id, id), + CONSTRAINT ck_login_connection_revision_positive CHECK (revision > 0), + CONSTRAINT ck_login_connection_revision_contract CHECK ( + adapter_contract_version ~ '^[1-9][0-9]*\.[0-9]+$' + ), + CONSTRAINT ck_login_connection_revision_schema CHECK (config_schema_version > 0), + CONSTRAINT ck_login_connection_revision_capabilities CHECK ( + jsonb_typeof(capabilities) = 'array' + ), + CONSTRAINT ck_login_connection_revision_config CHECK ( + jsonb_typeof(typed_config) = 'object' + ), + CONSTRAINT ck_login_connection_revision_secret_version CHECK ( + secret_binding_version IS NULL OR secret_binding_version >= 0 + ) +); + +ALTER TABLE login_connection + ADD CONSTRAINT fk_login_connection_active_revision + FOREIGN KEY (id, active_revision_id) + REFERENCES login_connection_revision(connection_id, id), + ADD CONSTRAINT fk_login_connection_last_tested_revision + FOREIGN KEY (id, last_tested_revision_id) + REFERENCES login_connection_revision(connection_id, id); + +CREATE INDEX idx_login_connection_revision_created + ON login_connection_revision (connection_id, revision DESC, id); + +CREATE TABLE external_identity ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) REFERENCES organization(id), + scope_key VARCHAR(64) GENERATED ALWAYS AS ( + COALESCE(organization_id, '@platform') + ) STORED, + connection_id VARCHAR(64) NOT NULL, + issuer VARCHAR(512) NOT NULL, + subject_type VARCHAR(64) NOT NULL, + subject_value VARCHAR(512) NOT NULL, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + last_authenticated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT fk_external_identity_connection_scope + FOREIGN KEY (connection_id, scope_key) + REFERENCES login_connection(id, scope_key), + CONSTRAINT uk_external_identity_coordinate UNIQUE ( + connection_id, issuer, subject_type, subject_value + ), + CONSTRAINT uk_external_identity_tenant_id UNIQUE (organization_id, id), + CONSTRAINT uk_external_identity_tenant_id_user UNIQUE (organization_id, id, user_id), + CONSTRAINT ck_external_identity_issuer CHECK (length(btrim(issuer)) > 0), + CONSTRAINT ck_external_identity_subject_type CHECK (length(btrim(subject_type)) > 0), + CONSTRAINT ck_external_identity_subject_value CHECK (length(btrim(subject_value)) > 0), + CONSTRAINT ck_external_identity_status CHECK ( + status IN ('ACTIVE', 'SUSPENDED', 'REVOKED') + ), + CONSTRAINT ck_external_identity_version CHECK (version >= 0) +); + +CREATE INDEX idx_external_identity_tenant_user + ON external_identity (organization_id, user_id, status, id) + WHERE organization_id IS NOT NULL; + +CREATE INDEX idx_external_identity_connection_status + ON external_identity (connection_id, status, id); + +CREATE INDEX idx_external_identity_user_status + ON external_identity (user_id, status, id); + +CREATE UNIQUE INDEX uk_organization_membership_tenant_id_user + ON organization_membership (organization_id, id, user_id); + +CREATE TABLE identity_session_origin ( + id VARCHAR(64) PRIMARY KEY, + session_key_hash CHAR(64) NOT NULL, + user_id VARCHAR(128) NOT NULL REFERENCES user_account(id), + organization_id VARCHAR(64) NOT NULL REFERENCES organization(id), + membership_id VARCHAR(64) NOT NULL, + login_connection_id VARCHAR(64) NOT NULL, + external_identity_id VARCHAR(64) NOT NULL, + authenticated_at TIMESTAMPTZ NOT NULL, + assurance VARCHAR(64) NOT NULL, + organization_authority_version BIGINT NOT NULL, + membership_authority_version BIGINT NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_identity_session_origin_hash UNIQUE (session_key_hash), + CONSTRAINT fk_identity_session_membership + FOREIGN KEY (organization_id, membership_id, user_id) + REFERENCES organization_membership(organization_id, id, user_id), + CONSTRAINT fk_identity_session_connection + FOREIGN KEY (organization_id, login_connection_id) + REFERENCES login_connection(organization_id, id), + CONSTRAINT fk_identity_session_external_identity + FOREIGN KEY (organization_id, external_identity_id, user_id) + REFERENCES external_identity(organization_id, id, user_id), + CONSTRAINT ck_identity_session_hash CHECK ( + session_key_hash ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT ck_identity_session_assurance CHECK (length(btrim(assurance)) > 0), + CONSTRAINT ck_identity_session_authority_versions CHECK ( + organization_authority_version >= 0 AND membership_authority_version >= 0 + ), + CONSTRAINT ck_identity_session_version CHECK (version >= 0) +); + +CREATE INDEX idx_identity_session_tenant_user + ON identity_session_origin (organization_id, user_id, revoked_at, id); + +CREATE INDEX idx_identity_session_membership_active + ON identity_session_origin (organization_id, membership_id, authenticated_at DESC, id) + WHERE revoked_at IS NULL; + +CREATE TABLE identity_operation ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) REFERENCES organization(id), + scope_key VARCHAR(64) GENERATED ALWAYS AS ( + COALESCE(organization_id, '@platform') + ) STORED, + connection_id VARCHAR(64), + operation_type VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + request_id VARCHAR(128), + external_request_id VARCHAR(256), + source_type VARCHAR(128), + source_reference VARCHAR(256), + error_code VARCHAR(128), + error_summary VARCHAR(512), + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT fk_identity_operation_connection_scope + FOREIGN KEY (connection_id, scope_key) + REFERENCES login_connection(id, scope_key), + CONSTRAINT uk_identity_operation_id_scope UNIQUE (id, scope_key), + CONSTRAINT ck_identity_operation_type CHECK (length(btrim(operation_type)) > 0), + CONSTRAINT ck_identity_operation_source CHECK ( + (source_type IS NULL AND source_reference IS NULL) + OR (source_type IS NOT NULL AND length(btrim(source_type)) > 0 + AND source_reference IS NOT NULL AND length(btrim(source_reference)) > 0) + ), + CONSTRAINT ck_identity_operation_status CHECK ( + status IN ('PENDING', 'SUCCEEDED', 'FAILED', 'CONFLICT') + ), + CONSTRAINT ck_identity_operation_completion CHECK ( + (status = 'PENDING' AND completed_at IS NULL) + OR (status <> 'PENDING' AND completed_at IS NOT NULL) + ), + CONSTRAINT ck_identity_operation_version CHECK (version >= 0) +); + +CREATE INDEX idx_identity_operation_tenant_status + ON identity_operation (organization_id, status, started_at DESC, id) + WHERE organization_id IS NOT NULL; + +CREATE INDEX idx_identity_operation_request + ON identity_operation (request_id, started_at DESC, id) + WHERE request_id IS NOT NULL; + +CREATE INDEX idx_identity_operation_connection_external + ON identity_operation (connection_id, external_request_id, id) + WHERE connection_id IS NOT NULL AND external_request_id IS NOT NULL; + +CREATE TABLE identity_conflict ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) REFERENCES organization(id), + scope_key VARCHAR(64) GENERATED ALWAYS AS ( + COALESCE(organization_id, '@platform') + ) STORED, + operation_id VARCHAR(64), + connection_id VARCHAR(64), + conflict_type VARCHAR(128) NOT NULL, + subject_reference VARCHAR(256) NOT NULL, + candidate_count INTEGER NOT NULL DEFAULT 0, + safe_summary VARCHAR(512), + status VARCHAR(32) NOT NULL DEFAULT 'OPEN', + resolution_code VARCHAR(128), + resolved_by VARCHAR(128) REFERENCES user_account(id), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT fk_identity_conflict_operation_scope + FOREIGN KEY (operation_id, scope_key) + REFERENCES identity_operation(id, scope_key), + CONSTRAINT fk_identity_conflict_connection_scope + FOREIGN KEY (connection_id, scope_key) + REFERENCES login_connection(id, scope_key), + CONSTRAINT ck_identity_conflict_type CHECK (length(btrim(conflict_type)) > 0), + CONSTRAINT ck_identity_conflict_subject CHECK (length(btrim(subject_reference)) > 0), + CONSTRAINT ck_identity_conflict_candidates CHECK (candidate_count >= 0), + CONSTRAINT ck_identity_conflict_status CHECK ( + status IN ('OPEN', 'RESOLVED', 'DISMISSED') + ), + CONSTRAINT ck_identity_conflict_resolution CHECK ( + (status = 'OPEN' AND resolution_code IS NULL AND resolved_by IS NULL AND resolved_at IS NULL) + OR (status <> 'OPEN' AND resolution_code IS NOT NULL + AND resolved_by IS NOT NULL AND resolved_at IS NOT NULL) + ), + CONSTRAINT ck_identity_conflict_version CHECK (version >= 0) +); + +CREATE INDEX idx_identity_conflict_tenant_status + ON identity_conflict (organization_id, status, created_at DESC, id) + WHERE organization_id IS NOT NULL; + +CREATE INDEX idx_identity_conflict_connection_status + ON identity_conflict (connection_id, status, created_at DESC, id) + WHERE connection_id IS NOT NULL; diff --git a/server/skillhub-app/src/main/resources/db/migration/V63__preprovisioned_login_subject.sql b/server/skillhub-app/src/main/resources/db/migration/V63__preprovisioned_login_subject.sql new file mode 100644 index 00000000..6089acdd --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V63__preprovisioned_login_subject.sql @@ -0,0 +1,53 @@ +-- Tenant- and Login Connection-scoped immutable subject claims used to correlate a verified +-- authentication assertion with a pre-provisioned Organization Membership. The immutable login +-- subject is intentionally stored separately from Membership source metadata. + +-- The protocol-neutral SubjectRef contract permits 1024 characters. Align the existing V2 +-- binding column before the first production write path starts using it. +ALTER TABLE external_identity + ALTER COLUMN subject_value TYPE VARCHAR(1024); + +-- PostgreSQL requires the referenced column set itself to be unique for the composite tenant +-- foreign key below. Membership id remains the global primary key as well. +ALTER TABLE organization_membership + ADD CONSTRAINT uk_organization_membership_tenant_id + UNIQUE (organization_id, id); + +CREATE TABLE preprovisioned_login_subject ( + id VARCHAR(64) PRIMARY KEY, + organization_id VARCHAR(64) NOT NULL REFERENCES organization(id), + membership_id VARCHAR(64) NOT NULL, + login_connection_id VARCHAR(64) NOT NULL, + issuer VARCHAR(512) NOT NULL, + subject_type VARCHAR(64) NOT NULL, + subject_value VARCHAR(1024) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at TIMESTAMPTZ, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT fk_preprovisioned_subject_membership + FOREIGN KEY (organization_id, membership_id) + REFERENCES organization_membership(organization_id, id), + CONSTRAINT fk_preprovisioned_subject_connection + FOREIGN KEY (organization_id, login_connection_id) + REFERENCES login_connection(organization_id, id), + CONSTRAINT uk_preprovisioned_subject_coordinate UNIQUE ( + login_connection_id, issuer, subject_type, subject_value + ), + CONSTRAINT ck_preprovisioned_subject_issuer CHECK (length(btrim(issuer)) > 0), + CONSTRAINT ck_preprovisioned_subject_type CHECK (length(btrim(subject_type)) > 0), + CONSTRAINT ck_preprovisioned_subject_value CHECK (length(btrim(subject_value)) > 0), + CONSTRAINT ck_preprovisioned_subject_status CHECK (status IN ('ACTIVE', 'REVOKED')), + CONSTRAINT ck_preprovisioned_subject_revocation CHECK ( + (status = 'ACTIVE' AND revoked_at IS NULL) + OR (status = 'REVOKED' AND revoked_at IS NOT NULL) + ), + CONSTRAINT ck_preprovisioned_subject_version CHECK (version >= 0) +); + +CREATE INDEX idx_preprovisioned_subject_tenant_membership + ON preprovisioned_login_subject (organization_id, membership_id, status, id); + +CREATE INDEX idx_preprovisioned_subject_connection_status + ON preprovisioned_login_subject (login_connection_id, status, id); diff --git a/server/skillhub-app/src/main/resources/db/migration/V64__login_correlation_policy.sql b/server/skillhub-app/src/main/resources/db/migration/V64__login_correlation_policy.sql new file mode 100644 index 00000000..9db7de9b --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V64__login_correlation_policy.sql @@ -0,0 +1,9 @@ +-- Platform-owned identity correlation policy. These controls are immutable revision data so +-- authentication adapters cannot decide account or membership creation. + +ALTER TABLE login_connection_revision + ADD COLUMN verified_email_correlation_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN jit_provisioning_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD CONSTRAINT ck_login_connection_revision_jit_policy CHECK ( + NOT jit_provisioning_enabled OR verified_email_correlation_enabled + ); diff --git a/server/skillhub-app/src/main/resources/db/migration/V65__backfill_legacy_external_identities.sql b/server/skillhub-app/src/main/resources/db/migration/V65__backfill_legacy_external_identities.sql new file mode 100644 index 00000000..fbfd6c2e --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V65__backfill_legacy_external_identities.sql @@ -0,0 +1,101 @@ +-- Backfill legacy public OAuth identities into platform-scoped V2 connections. The legacy table +-- remains authoritative in LEGACY/SHADOW modes and is intentionally retained for rollback. + +INSERT INTO login_connection ( + id, + public_handle, + scope_type, + organization_id, + system_key, + display_name, + status, + adapter_key, + created_at, + updated_at +) +SELECT + 'legacy-oauth-' || md5('skillhub:legacy-oauth:' || provider_code), + 'legacy-' || replace(gen_random_uuid()::text, '-', ''), + 'PLATFORM', + NULL, + 'legacy.oauth.' || provider_code, + 'Legacy OAuth (' || provider_code || ')', + 'ACTIVE', + 'legacy-oauth', + MIN(created_at), + MAX(updated_at) +FROM identity_binding +GROUP BY provider_code; + +INSERT INTO external_identity ( + id, + organization_id, + connection_id, + issuer, + subject_type, + subject_value, + user_id, + status, + last_authenticated_at, + created_at, + updated_at +) +SELECT + 'legacy-binding-' || binding.id, + NULL, + 'legacy-oauth-' || md5('skillhub:legacy-oauth:' || binding.provider_code), + 'urn:skillhub:legacy-oauth', + 'legacy-oauth-subject', + binding.subject, + binding.user_id, + 'ACTIVE', + NULL, + binding.created_at, + binding.updated_at +FROM identity_binding binding; + +-- Fail the migration transaction if either the cardinality or canonical binding digest differs. +-- MD5 is a PostgreSQL built-in used here as a migration consistency checksum, not as a credential +-- or cryptographic trust primitive. +DO $$ +DECLARE + legacy_count BIGINT; + v2_count BIGINT; + legacy_digest TEXT; + v2_digest TEXT; +BEGIN + SELECT + COUNT(*), + md5(COALESCE(string_agg( + jsonb_build_array(provider_code, subject, user_id)::text, + E'\n' ORDER BY provider_code, subject, user_id + ), '')) + INTO legacy_count, legacy_digest + FROM identity_binding; + + SELECT + COUNT(*), + md5(COALESCE(string_agg( + jsonb_build_array( + substring(connection.system_key FROM length('legacy.oauth.') + 1), + identity.subject_value, + identity.user_id + )::text, + E'\n' ORDER BY connection.system_key, identity.subject_value, identity.user_id + ), '')) + INTO v2_count, v2_digest + FROM external_identity identity + JOIN login_connection connection ON connection.id = identity.connection_id + WHERE connection.scope_type = 'PLATFORM' + AND connection.adapter_key = 'legacy-oauth' + AND connection.system_key LIKE 'legacy.oauth.%' + AND identity.organization_id IS NULL + AND identity.issuer = 'urn:skillhub:legacy-oauth' + AND identity.subject_type = 'legacy-oauth-subject'; + + IF legacy_count <> v2_count OR legacy_digest <> v2_digest THEN + RAISE EXCEPTION + 'legacy identity V2 backfill verification failed: legacy_count=%, v2_count=%, legacy_digest=%, v2_digest=%', + legacy_count, v2_count, legacy_digest, v2_digest; + END IF; +END $$; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthIdentityCoordinate.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthIdentityCoordinate.java new file mode 100644 index 00000000..2f73da58 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthIdentityCoordinate.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.auth.federation.adapter; + +import com.iflytek.skillhub.auth.federation.core.SubjectType; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Stable V2 coordinate components for one legacy platform OAuth provider. */ +public final class LegacyOAuthIdentityCoordinate { + + public static final URI ISSUER = URI.create("urn:skillhub:legacy-oauth"); + public static final SubjectType SUBJECT_TYPE = new SubjectType("legacy-oauth-subject"); + + private static final Pattern PROVIDER_PATTERN = Pattern.compile("[a-z][a-z0-9._-]{0,63}"); + private static final String CONNECTION_ID_PREFIX = "legacy-oauth-"; + private static final String CONNECTION_ID_NAMESPACE = "skillhub:legacy-oauth:"; + + private LegacyOAuthIdentityCoordinate() { + } + + public static String connectionId(String provider) { + String normalized = requireProvider(provider); + return CONNECTION_ID_PREFIX + md5(CONNECTION_ID_NAMESPACE + normalized); + } + + public static String requireProvider(String provider) { + Objects.requireNonNull(provider, "provider"); + String normalized = provider.trim(); + if (!PROVIDER_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("provider must be a normalized stable key"); + } + return normalized; + } + + private static String md5(String value) { + try { + // MD5 is used only for a deterministic opaque database identifier, not for security. + MessageDigest digest = MessageDigest.getInstance("MD5"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("JVM does not provide required MD5 identifier digest", impossible); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapter.java new file mode 100644 index 00000000..4a1e7d2f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapter.java @@ -0,0 +1,81 @@ +package com.iflytek.skillhub.auth.federation.adapter; + +import com.iflytek.skillhub.auth.federation.core.Assurance; +import com.iflytek.skillhub.auth.federation.core.AuthenticationAdapterException; +import com.iflytek.skillhub.auth.federation.core.AuthenticationAdapterFailureReason; +import com.iflytek.skillhub.auth.federation.core.IdentityAssertion; +import com.iflytek.skillhub.auth.federation.core.SubjectRef; +import com.iflytek.skillhub.auth.federation.core.VerifiedAuthenticationFactsAdapter; +import com.iflytek.skillhub.auth.federation.core.VerifiedEmail; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Component; + +/** Adapts the existing normalized OAuth facts to the protocol-neutral assertion Interface. */ +@Component +public final class LegacyOAuthVerifiedFactsAdapter + implements VerifiedAuthenticationFactsAdapter { + + private static final Assurance VERIFIED_EMAIL = new Assurance("email_verified"); + + private final Clock clock; + + public LegacyOAuthVerifiedFactsAdapter(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public IdentityAssertion toAssertion(OAuthClaims claims) { + try { + Objects.requireNonNull(claims, "claims"); + String provider = LegacyOAuthIdentityCoordinate.requireProvider(claims.provider()); + Optional email = verifiedEmail(claims); + return new IdentityAssertion( + Optional.empty(), + LegacyOAuthIdentityCoordinate.connectionId(provider), + LegacyOAuthIdentityCoordinate.ISSUER, + new SubjectRef(LegacyOAuthIdentityCoordinate.SUBJECT_TYPE, claims.subject()), + email, + optionalText(claims.providerLogin()), + optionalText(claims.providerLogin()), + optionalMapText(claims.extra(), "avatar_url"), + email.isPresent() ? Set.of(VERIFIED_EMAIL) : Set.of(), + Instant.now(clock), + Map.of() + ); + } catch (IllegalArgumentException | NullPointerException invalidFacts) { + throw new AuthenticationAdapterException( + AuthenticationAdapterFailureReason.INVALID_ASSERTION + ); + } + } + + private static Optional verifiedEmail(OAuthClaims claims) { + if (!claims.emailVerified()) { + return Optional.empty(); + } + return optionalText(claims.email()).map(VerifiedEmail::new); + } + + private static Optional optionalMapText(Map values, String key) { + if (values == null) { + return Optional.empty(); + } + Object value = values.get(key); + return value instanceof String text ? optionalText(text) : Optional.empty(); + } + + private static Optional optionalText(String value) { + if (value == null) { + return Optional.empty(); + } + String normalized = value.trim(); + return normalized.isEmpty() ? Optional.empty() : Optional.of(normalized); + } + +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/RemoteIdentityIoExecutor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/RemoteIdentityIoExecutor.java new file mode 100644 index 00000000..ef544671 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/RemoteIdentityIoExecutor.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.federation.adapter; + +import java.util.function.Supplier; + +/** Executes calls to remote identity systems without holding a database transaction open. */ +@FunctionalInterface +public interface RemoteIdentityIoExecutor { + + T execute(Supplier remoteIo); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/TransactionSuspendingRemoteIdentityIoExecutor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/TransactionSuspendingRemoteIdentityIoExecutor.java new file mode 100644 index 00000000..cca54c6f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/TransactionSuspendingRemoteIdentityIoExecutor.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.auth.federation.adapter; + +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** Spring transaction boundary for metadata, token and userinfo network calls. */ +@Component +public final class TransactionSuspendingRemoteIdentityIoExecutor implements RemoteIdentityIoExecutor { + + private final TransactionTemplate transactions; + + public TransactionSuspendingRemoteIdentityIoExecutor( + PlatformTransactionManager transactionManager + ) { + Objects.requireNonNull(transactionManager, "transactionManager"); + this.transactions = new TransactionTemplate(transactionManager); + this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); + } + + @Override + public T execute(Supplier remoteIo) { + Objects.requireNonNull(remoteIo, "remoteIo"); + return transactions.execute(ignored -> remoteIo.get()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/package-info.java new file mode 100644 index 00000000..5e772344 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/adapter/package-info.java @@ -0,0 +1,2 @@ +/** Protocol adapters and runtime boundaries for external identity systems. */ +package com.iflytek.skillhub.auth.federation.adapter; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociation.java new file mode 100644 index 00000000..f0fb8a8f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociation.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationStage; +import java.util.Objects; + +/** Transactionally consistent result consumed by later guard/session orchestration. */ +public record EnterpriseIdentityAssociation( + String externalIdentityId, + String userId, + String membershipId, + IdentityCorrelationStage stage, + boolean accountCreated +) { + public EnterpriseIdentityAssociation { + Objects.requireNonNull(externalIdentityId, "externalIdentityId must not be null"); + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(membershipId, "membershipId must not be null"); + Objects.requireNonNull(stage, "stage must not be null"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationException.java new file mode 100644 index 00000000..26ad1dfa --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationException.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.auth.federation.association; + +import java.util.Objects; + +/** Safe boundary failure that never includes a subject, email, token or internal identifier. */ +public class EnterpriseIdentityAssociationException extends RuntimeException { + + private final EnterpriseIdentityAssociationFailure failure; + + public EnterpriseIdentityAssociationException(EnterpriseIdentityAssociationFailure failure) { + this(failure, null); + } + + public EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure failure, + Throwable cause + ) { + super("Enterprise identity could not be associated", cause); + this.failure = Objects.requireNonNull(failure, "failure must not be null"); + } + + public EnterpriseIdentityAssociationFailure getFailure() { + return failure; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationFailure.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationFailure.java new file mode 100644 index 00000000..136504c5 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationFailure.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.federation.association; + +/** Stable, privacy-preserving failure classification for first-login association. */ +public enum EnterpriseIdentityAssociationFailure { + ORGANIZATION_REQUIRED, + NO_SAFE_MATCH, + CORRELATION_CONFLICT, + BINDING_UNAVAILABLE, + MEMBERSHIP_UNAVAILABLE, + ACCOUNT_UNAVAILABLE, + ACCOUNT_REAUTHENTICATION_REQUIRED, + CONCURRENT_ASSOCIATION_FAILED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationService.java new file mode 100644 index 00000000..0e7717b8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/EnterpriseIdentityAssociationService.java @@ -0,0 +1,583 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityLoginModule; +import com.iflytek.skillhub.auth.federation.core.EnterpriseIdentitySecurityEventRecorder; +import com.iflytek.skillhub.auth.federation.core.EnterpriseIdentitySecurityEventType; +import com.iflytek.skillhub.auth.federation.core.IdentityAssertion; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreActivation; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationCandidates; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationPolicy; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationPolicySettings; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationStage; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationTarget; +import com.iflytek.skillhub.auth.federation.core.IdentityLoginResolution; +import com.iflytek.skillhub.auth.federation.core.VerifiedEmail; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.organization.MembershipSourceType; +import com.iflytek.skillhub.domain.organization.OrganizationDomain; +import com.iflytek.skillhub.domain.organization.OrganizationDomainRepository; +import com.iflytek.skillhub.domain.organization.OrganizationMembership; +import com.iflytek.skillhub.domain.organization.OrganizationMembershipRepository; +import com.iflytek.skillhub.domain.organization.OrganizationMembershipStatus; +import com.iflytek.skillhub.domain.organization.OrganizationRepository; +import com.iflytek.skillhub.domain.organization.OrganizationStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionOperations; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Correlates one already-verified enterprise assertion and commits account, membership and binding + * as one retryable transaction. Protocol/network I/O must complete before entering this service. + */ +public class EnterpriseIdentityAssociationService { + + private static final int MAX_ASSOCIATION_ATTEMPTS = 3; + + private final IdentityCoreActivation activation; + private final ExternalIdentityRepository externalIdentities; + private final PreProvisionedLoginSubjectRepository preProvisionedSubjects; + private final OrganizationDomainRepository domains; + private final OrganizationMembershipRepository memberships; + private final OrganizationRepository organizations; + private final UserAccountRepository accounts; + private final GlobalNamespaceMembershipService globalMemberships; + private final ApplicationEventPublisher events; + private final TransactionOperations transactions; + private final Clock clock; + private final EnterpriseIdentitySecurityEventRecorder securityEvents; + + public EnterpriseIdentityAssociationService( + IdentityCoreActivation activation, + ExternalIdentityRepository externalIdentities, + PreProvisionedLoginSubjectRepository preProvisionedSubjects, + OrganizationDomainRepository domains, + OrganizationMembershipRepository memberships, + OrganizationRepository organizations, + UserAccountRepository accounts, + GlobalNamespaceMembershipService globalMemberships, + ApplicationEventPublisher events, + PlatformTransactionManager transactionManager, + Clock clock, + EnterpriseIdentitySecurityEventRecorder securityEvents + ) { + this.activation = Objects.requireNonNull(activation, "activation must not be null"); + this.externalIdentities = Objects.requireNonNull( + externalIdentities, + "externalIdentities must not be null" + ); + this.preProvisionedSubjects = Objects.requireNonNull( + preProvisionedSubjects, + "preProvisionedSubjects must not be null" + ); + this.domains = Objects.requireNonNull(domains, "domains must not be null"); + this.memberships = Objects.requireNonNull(memberships, "memberships must not be null"); + this.organizations = Objects.requireNonNull( + organizations, + "organizations must not be null" + ); + this.accounts = Objects.requireNonNull(accounts, "accounts must not be null"); + this.globalMemberships = Objects.requireNonNull( + globalMemberships, + "globalMemberships must not be null" + ); + this.events = Objects.requireNonNull(events, "events must not be null"); + this.transactions = requiresNewTransactions(transactionManager); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.securityEvents = Objects.requireNonNull( + securityEvents, + "securityEvents must not be null" + ); + } + + public EnterpriseIdentityAssociation associate(IdentityAssertion assertion) { + return associate(assertion, IdentityCorrelationPolicySettings.disabled()); + } + + public EnterpriseIdentityAssociation associate( + IdentityAssertion assertion, + IdentityCorrelationPolicySettings policySettings + ) { + Objects.requireNonNull(assertion, "assertion must not be null"); + Objects.requireNonNull(policySettings, "policySettings must not be null"); + if (assertion.organizationId().isEmpty()) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.ORGANIZATION_REQUIRED + ); + } + + try { + RuntimeException lastConflict = null; + for (int attempt = 1; attempt <= MAX_ASSOCIATION_ATTEMPTS; attempt++) { + try { + EnterpriseIdentityAssociation result = transactions.execute( + ignored -> associateInTransaction(assertion, policySettings) + ); + return Objects.requireNonNull(result, "association result must not be null"); + } catch (DataIntegrityViolationException | OptimisticLockingFailureException conflict) { + lastConflict = conflict; + } + } + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.CONCURRENT_ASSOCIATION_FAILED, + lastConflict + ); + } catch (EnterpriseIdentityAssociationException failure) { + if (failure.getFailure() == EnterpriseIdentityAssociationFailure.CORRELATION_CONFLICT) { + securityEvents.record( + EnterpriseIdentitySecurityEventType.IDENTITY_CORRELATION_CONFLICT, + assertion.organizationId().orElseThrow(), + assertion.connectionId(), + "subject:" + coordinateDigest(ExternalIdentityCoordinate.from(assertion)) + ); + } + throw failure; + } + } + + private EnterpriseIdentityAssociation associateInTransaction( + IdentityAssertion assertion, + IdentityCorrelationPolicySettings policySettings + ) { + ExternalIdentityCoordinate coordinate = ExternalIdentityCoordinate.from(assertion); + ExternalIdentityLoginModule loginModule = new ExternalIdentityLoginModule( + activation, + candidates(), + effectivePolicy(assertion, policySettings) + ); + IdentityLoginResolution resolution = loginModule.resolve(assertion); + if (resolution instanceof IdentityLoginResolution.Matched matched) { + return switch (matched.stage()) { + case EXACT_BINDING -> useExactBinding(coordinate); + case PRE_PROVISIONED_SUBJECT -> claimPreProvisionedMembership( + assertion, + coordinate, + matched.target() + ); + case VERIFIED_EMAIL -> claimVerifiedEmailMembership( + assertion, + coordinate, + matched.target() + ); + case JIT_PROVISIONING -> throw new IllegalStateException( + "JIT is a provisioning result, not a matched candidate stage" + ); + }; + } + if (resolution instanceof IdentityLoginResolution.ProvisionNew) { + return provisionJitMembership(assertion, coordinate); + } + if (resolution instanceof IdentityLoginResolution.Conflict) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.CORRELATION_CONFLICT + ); + } + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.NO_SAFE_MATCH + ); + } + + private IdentityCorrelationPolicy effectivePolicy( + IdentityAssertion assertion, + IdentityCorrelationPolicySettings settings + ) { + boolean verifiedDomain = settings.allowsVerifiedEmailCorrelation(assertion) + && hasVerifiedOrganizationDomain(assertion); + return new IdentityCorrelationPolicy() { + @Override + public boolean allowsVerifiedEmailCorrelation(IdentityAssertion ignored) { + return verifiedDomain; + } + + @Override + public boolean allowsJitProvisioning(IdentityAssertion ignored) { + return verifiedDomain && settings.allowsJitProvisioning(assertion); + } + }; + } + + private boolean hasVerifiedOrganizationDomain(IdentityAssertion assertion) { + String organizationId = assertion.organizationId().orElseThrow(); + boolean organizationActive = organizations.findByIdForUpdate(organizationId) + .filter(organization -> organization.getStatus() == OrganizationStatus.ACTIVE) + .isPresent(); + if (!organizationActive) { + return false; + } + return assertion.email() + .map(VerifiedEmail::value) + .flatMap(EnterpriseIdentityAssociationService::verifiedOrganizationDomain) + .flatMap(domains::findVerifiedByDomainForUpdate) + .filter(domain -> domain.getOrganizationId().equals(organizationId)) + .isPresent(); + } + + private EnterpriseIdentityAssociation useExactBinding( + ExternalIdentityCoordinate coordinate + ) { + String organizationId = coordinate.organizationId().orElseThrow(); + ExternalIdentity identity = externalIdentities.findByCoordinate(coordinate) + .orElseThrow(() -> new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.BINDING_UNAVAILABLE + )); + identity.requireActive(); + UserAccount account = requireUsableAccount(identity.getUserId()); + OrganizationMembership membership = memberships + .findCurrentByOrganizationIdAndUserId(organizationId, account.getId()) + .filter(candidate -> candidate.getStatus() == OrganizationMembershipStatus.ACTIVE) + .orElseThrow(() -> new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.MEMBERSHIP_UNAVAILABLE + )); + + preProvisionedSubjects.findByCoordinate(coordinate) + .filter(PreProvisionedLoginSubject::isActive) + .ifPresent(claim -> { + if (!claim.getMembershipId().equals(membership.getId())) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.CORRELATION_CONFLICT + ); + } + }); + + identity.recordAuthentication(clock.instant()); + identity = externalIdentities.saveAndFlush(identity); + return new EnterpriseIdentityAssociation( + identity.getId(), + account.getId(), + membership.getId(), + IdentityCorrelationStage.EXACT_BINDING, + false + ); + } + + private EnterpriseIdentityAssociation claimPreProvisionedMembership( + IdentityAssertion assertion, + ExternalIdentityCoordinate coordinate, + IdentityCorrelationTarget target + ) { + String organizationId = coordinate.organizationId().orElseThrow(); + PreProvisionedLoginSubject claim = preProvisionedSubjects.findByCoordinate(coordinate) + .filter(PreProvisionedLoginSubject::isActive) + .orElseThrow(() -> new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.MEMBERSHIP_UNAVAILABLE + )); + if (!target.membershipId().orElseThrow().equals(claim.getMembershipId())) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.CORRELATION_CONFLICT + ); + } + OrganizationMembership membership = memberships + .findByOrganizationIdAndId(organizationId, claim.getMembershipId()) + .orElseThrow(() -> new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.MEMBERSHIP_UNAVAILABLE + )); + + Instant now = clock.instant(); + boolean accountCreated; + UserAccount account; + if (membership.getStatus() == OrganizationMembershipStatus.PROVISIONED) { + account = createAccount(assertion, membership); + membership.activate(account.getId(), now); + memberships.save(membership); + accountCreated = true; + } else if (membership.getStatus() == OrganizationMembershipStatus.ACTIVE + && membership.getUserId() != null) { + account = requireUsableAccount(membership.getUserId()); + accountCreated = false; + } else { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.MEMBERSHIP_UNAVAILABLE + ); + } + + ExternalIdentity identity = ExternalIdentity.bind(coordinate, account.getId(), now); + identity = externalIdentities.saveAndFlush(identity); + if (accountCreated) { + globalMemberships.ensureMember(account.getId()); + events.publishEvent(new UserActivatedEvent( + account.getId(), + assertion.loginName().orElse(account.getDisplayName()), + account.getEmail() + )); + } + return new EnterpriseIdentityAssociation( + identity.getId(), + account.getId(), + membership.getId(), + IdentityCorrelationStage.PRE_PROVISIONED_SUBJECT, + accountCreated + ); + } + + private EnterpriseIdentityAssociation claimVerifiedEmailMembership( + IdentityAssertion assertion, + ExternalIdentityCoordinate coordinate, + IdentityCorrelationTarget target + ) { + String organizationId = coordinate.organizationId().orElseThrow(); + String membershipId = target.membershipId().orElseThrow( + () -> new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.CORRELATION_CONFLICT + ) + ); + OrganizationMembership membership = memberships + .findByOrganizationIdAndId(organizationId, membershipId) + .orElseThrow(() -> new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.MEMBERSHIP_UNAVAILABLE + )); + String verifiedEmail = assertion.email().orElseThrow().value(); + if (membership.getPrimaryEmail() == null + || !membership.getPrimaryEmail().equalsIgnoreCase(verifiedEmail)) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.CORRELATION_CONFLICT + ); + } + if (membership.getStatus() == OrganizationMembershipStatus.ACTIVE + && membership.getUserId() != null) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.ACCOUNT_REAUTHENTICATION_REQUIRED + ); + } + if (membership.getStatus() != OrganizationMembershipStatus.PROVISIONED + || membership.getUserId() != null + || target.userId().isPresent()) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.MEMBERSHIP_UNAVAILABLE + ); + } + return createAccountAssociation( + assertion, + coordinate, + membership, + IdentityCorrelationStage.VERIFIED_EMAIL + ); + } + + private EnterpriseIdentityAssociation provisionJitMembership( + IdentityAssertion assertion, + ExternalIdentityCoordinate coordinate + ) { + String organizationId = coordinate.organizationId().orElseThrow(); + String verifiedEmail = assertion.email().orElseThrow().value(); + String displayName = firstPresent( + assertion.displayName(), + assertion.loginName() + ).orElse(verifiedEmail.substring(0, verifiedEmail.lastIndexOf('@'))); + requireBounded(displayName, 128, "displayName"); + OrganizationMembership membership = OrganizationMembership.provisioned( + organizationId, + MembershipSourceType.JIT, + jitSourceId(coordinate), + displayName, + verifiedEmail, + clock.instant() + ); + membership = memberships.save(membership); + return createAccountAssociation( + assertion, + coordinate, + membership, + IdentityCorrelationStage.JIT_PROVISIONING + ); + } + + private EnterpriseIdentityAssociation createAccountAssociation( + IdentityAssertion assertion, + ExternalIdentityCoordinate coordinate, + OrganizationMembership membership, + IdentityCorrelationStage stage + ) { + Instant now = clock.instant(); + UserAccount account = createAccount(assertion, membership); + membership.activate(account.getId(), now); + memberships.save(membership); + ExternalIdentity identity = externalIdentities.saveAndFlush( + ExternalIdentity.bind(coordinate, account.getId(), now) + ); + globalMemberships.ensureMember(account.getId()); + events.publishEvent(new UserActivatedEvent( + account.getId(), + assertion.loginName().orElse(account.getDisplayName()), + account.getEmail() + )); + return new EnterpriseIdentityAssociation( + identity.getId(), + account.getId(), + membership.getId(), + stage, + true + ); + } + + private UserAccount createAccount( + IdentityAssertion assertion, + OrganizationMembership membership + ) { + String displayName = firstPresent( + optional(membership.getDisplayName()), + assertion.displayName(), + assertion.loginName() + ).orElse("Enterprise user"); + String email = assertion.email().map(VerifiedEmail::value).orElse(null); + String avatarUrl = assertion.avatarUrl().orElse(null); + requireBounded(displayName, 128, "displayName"); + requireBoundedNullable(avatarUrl, 512, "avatarUrl"); + UserAccount account = new UserAccount( + "usr_" + UUID.randomUUID(), + displayName, + email, + avatarUrl + ); + return accounts.save(account); + } + + private UserAccount requireUsableAccount(String userId) { + UserAccount account = accounts.findById(userId) + .orElseThrow(() -> new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.ACCOUNT_UNAVAILABLE + )); + if (!account.isActive() + || account.isSystemAccount() + || account.getMergedToUserId() != null) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.ACCOUNT_UNAVAILABLE + ); + } + return account; + } + + private IdentityCorrelationCandidates candidates() { + return new IdentityCorrelationCandidates() { + @Override + public List findExactBinding( + ExternalIdentityCoordinate coordinate + ) { + return externalIdentities.findByCoordinate(coordinate) + .map(identity -> List.of(IdentityCorrelationTarget.account( + identity.getUserId() + ))) + .orElseGet(List::of); + } + + @Override + public List findPreProvisionedSubject( + ExternalIdentityCoordinate coordinate + ) { + return preProvisionedSubjects.findByCoordinate(coordinate) + .map(subject -> List.of(IdentityCorrelationTarget.membership( + subject.getMembershipId() + ))) + .orElseGet(List::of); + } + + @Override + public List findByVerifiedEmail( + String organizationId, + VerifiedEmail email + ) { + return memberships.findIdentityCorrelationCandidatesByPrimaryEmail( + organizationId, + email.value() + ).stream() + .map(membership -> membership.getUserId() == null + ? IdentityCorrelationTarget.membership(membership.getId()) + : IdentityCorrelationTarget.linked( + membership.getUserId(), + membership.getId() + )) + .toList(); + } + }; + } + + private static Optional verifiedOrganizationDomain(String email) { + int separator = email.lastIndexOf('@'); + if (separator < 1 || separator == email.length() - 1) { + return Optional.empty(); + } + try { + return Optional.of(OrganizationDomain.normalize(email.substring(separator + 1))); + } catch (DomainBadRequestException invalidDomain) { + return Optional.empty(); + } + } + + private static String jitSourceId(ExternalIdentityCoordinate coordinate) { + return "jit:" + coordinateDigest(coordinate); + } + + private static String coordinateDigest(ExternalIdentityCoordinate coordinate) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + updateDigest(digest, coordinate.connectionId()); + updateDigest(digest, coordinate.issuer().toString()); + updateDigest(digest, coordinate.subject().type().value()); + updateDigest(digest, coordinate.subject().value()); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(digest.digest()); + } catch (GeneralSecurityException unavailable) { + throw new IllegalStateException("SHA-256 is unavailable", unavailable); + } + } + + private static void updateDigest(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + digest.update(encoded); + } + + @SafeVarargs + private static Optional firstPresent(Optional... candidates) { + for (Optional candidate : candidates) { + if (candidate.isPresent()) { + return candidate; + } + } + return Optional.empty(); + } + + private static Optional optional(String value) { + return Optional.ofNullable(value).filter(text -> !text.isBlank()); + } + + private static void requireBounded(String value, int maximum, String field) { + if (value.isBlank() || value.length() > maximum) { + throw new IllegalArgumentException(field + " is outside its persistence bounds"); + } + } + + private static void requireBoundedNullable(String value, int maximum, String field) { + if (value != null && value.length() > maximum) { + throw new IllegalArgumentException(field + " is outside its persistence bounds"); + } + } + + private static TransactionOperations requiresNewTransactions( + PlatformTransactionManager transactionManager + ) { + TransactionTemplate transaction = new TransactionTemplate( + Objects.requireNonNull(transactionManager, "transactionManager must not be null") + ); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return transaction; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentity.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentity.java new file mode 100644 index 00000000..0ad9d171 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentity.java @@ -0,0 +1,199 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.core.SubjectRef; +import com.iflytek.skillhub.auth.federation.core.SubjectType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.net.URI; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** Persistent binding from a verified external identity coordinate to one Platform Account. */ +@Entity +@Table(name = "external_identity") +public class ExternalIdentity { + + @Id + @Column(length = 64) + private String id; + + @Column(name = "organization_id", length = 64, updatable = false) + private String organizationId; + + @Column(name = "connection_id", nullable = false, length = 64, updatable = false) + private String connectionId; + + @Column(nullable = false, length = 512, updatable = false) + private String issuer; + + @Column(name = "subject_type", nullable = false, length = 64, updatable = false) + private String subjectType; + + @Column(name = "subject_value", nullable = false, length = 1024, updatable = false) + private String subjectValue; + + @Column(name = "user_id", nullable = false, length = 128, updatable = false) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private ExternalIdentityStatus status; + + @Column(name = "last_authenticated_at") + private Instant lastAuthenticatedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long version; + + protected ExternalIdentity() { + } + + private ExternalIdentity( + ExternalIdentityCoordinate coordinate, + String userId, + Instant authenticatedAt + ) { + this.id = UUID.randomUUID().toString(); + this.organizationId = coordinate.organizationId().orElse(null); + this.connectionId = bounded(coordinate.connectionId(), 64, "connectionId"); + this.issuer = bounded(coordinate.issuer().toString(), 512, "issuer"); + this.subjectType = bounded(coordinate.subject().type().value(), 64, "subjectType"); + this.subjectValue = boundedOpaque( + coordinate.subject().value(), + 1024, + "subjectValue" + ); + this.userId = bounded(userId, 128, "userId"); + this.status = ExternalIdentityStatus.ACTIVE; + this.lastAuthenticatedAt = Objects.requireNonNull( + authenticatedAt, + "authenticatedAt must not be null" + ); + this.createdAt = authenticatedAt; + this.updatedAt = authenticatedAt; + } + + public static ExternalIdentity bind( + ExternalIdentityCoordinate coordinate, + String userId, + Instant authenticatedAt + ) { + return new ExternalIdentity( + Objects.requireNonNull(coordinate, "coordinate must not be null"), + userId, + authenticatedAt + ); + } + + public ExternalIdentityCoordinate coordinate() { + return new ExternalIdentityCoordinate( + Optional.ofNullable(organizationId), + connectionId, + URI.create(issuer), + new SubjectRef( + new SubjectType(subjectType), + subjectValue + ) + ); + } + + public void recordAuthentication(Instant authenticatedAt) { + requireActive(); + Instant observedAt = Objects.requireNonNull( + authenticatedAt, + "authenticatedAt must not be null" + ); + if (observedAt.isAfter(lastAuthenticatedAt)) { + lastAuthenticatedAt = observedAt; + updatedAt = observedAt; + } + } + + public void requireActive() { + if (status != ExternalIdentityStatus.ACTIVE) { + throw new EnterpriseIdentityAssociationException( + EnterpriseIdentityAssociationFailure.BINDING_UNAVAILABLE + ); + } + } + + private static String bounded(String value, int maximum, String field) { + Objects.requireNonNull(value, field + " must not be null"); + String normalized = value.trim(); + if (normalized.isEmpty() || normalized.length() > maximum) { + throw new IllegalArgumentException(field + " is outside its persistence bounds"); + } + return normalized; + } + + private static String boundedOpaque(String value, int maximum, String field) { + Objects.requireNonNull(value, field + " must not be null"); + if (value.isBlank() || value.length() > maximum) { + throw new IllegalArgumentException(field + " is outside its persistence bounds"); + } + return value; + } + + public String getId() { + return id; + } + + public String getOrganizationId() { + return organizationId; + } + + public String getConnectionId() { + return connectionId; + } + + public String getIssuer() { + return issuer; + } + + public String getSubjectType() { + return subjectType; + } + + public String getSubjectValue() { + return subjectValue; + } + + public String getUserId() { + return userId; + } + + public boolean isActive() { + return status == ExternalIdentityStatus.ACTIVE; + } + + public Instant getLastAuthenticatedAt() { + return lastAuthenticatedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getVersion() { + return version; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentityRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentityRepository.java new file mode 100644 index 00000000..66c62a86 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentityRepository.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import java.util.Optional; + +/** Persistence port for exact external identity bindings. */ +public interface ExternalIdentityRepository { + + Optional findByCoordinate(ExternalIdentityCoordinate coordinate); + + Optional findByOrganizationIdAndId(String organizationId, String id); + + ExternalIdentity saveAndFlush(ExternalIdentity identity); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentitySpringDataRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentitySpringDataRepository.java new file mode 100644 index 00000000..91d954c7 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentitySpringDataRepository.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.federation.association; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Internal Spring Data delegate; callers use the protocol-neutral repository port. */ +interface ExternalIdentitySpringDataRepository + extends JpaRepository { + + Optional findByOrganizationIdAndId(String organizationId, String id); + + Optional findByConnectionIdAndIssuerAndSubjectTypeAndSubjectValue( + String connectionId, + String issuer, + String subjectType, + String subjectValue + ); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentityStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentityStatus.java new file mode 100644 index 00000000..25755b6f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/ExternalIdentityStatus.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.federation.association; + +/** Lifecycle of one protocol identity binding. */ +enum ExternalIdentityStatus { + ACTIVE, + SUSPENDED, + REVOKED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/JpaExternalIdentityRepositoryAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/JpaExternalIdentityRepositoryAdapter.java new file mode 100644 index 00000000..1c053095 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/JpaExternalIdentityRepositoryAdapter.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import java.util.Objects; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** JPA adapter that verifies the tenant scope after resolving the globally unique connection. */ +@Repository +public class JpaExternalIdentityRepositoryAdapter implements ExternalIdentityRepository { + + private final ExternalIdentitySpringDataRepository delegate; + + public JpaExternalIdentityRepositoryAdapter(ExternalIdentitySpringDataRepository delegate) { + this.delegate = delegate; + } + + @Override + public Optional findByCoordinate(ExternalIdentityCoordinate coordinate) { + Objects.requireNonNull(coordinate, "coordinate must not be null"); + return delegate.findByConnectionIdAndIssuerAndSubjectTypeAndSubjectValue( + coordinate.connectionId(), + coordinate.issuer().toString(), + coordinate.subject().type().value(), + coordinate.subject().value() + ) + .filter(identity -> Objects.equals( + identity.getOrganizationId(), + coordinate.organizationId().orElse(null) + )); + } + + @Override + public Optional findByOrganizationIdAndId( + String organizationId, + String id + ) { + return delegate.findByOrganizationIdAndId( + requireText(organizationId, "organizationId"), + requireText(id, "id") + ); + } + + @Override + public ExternalIdentity saveAndFlush(ExternalIdentity identity) { + return delegate.saveAndFlush(Objects.requireNonNull(identity, "identity must not be null")); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/JpaPreProvisionedLoginSubjectRepositoryAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/JpaPreProvisionedLoginSubjectRepositoryAdapter.java new file mode 100644 index 00000000..63867fe3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/JpaPreProvisionedLoginSubjectRepositoryAdapter.java @@ -0,0 +1,55 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** JPA adapter that refuses platform-scoped coordinates before querying enterprise claims. */ +@Repository +public class JpaPreProvisionedLoginSubjectRepositoryAdapter + implements PreProvisionedLoginSubjectRepository { + + private final PreProvisionedLoginSubjectSpringDataRepository delegate; + + public JpaPreProvisionedLoginSubjectRepositoryAdapter( + PreProvisionedLoginSubjectSpringDataRepository delegate + ) { + this.delegate = delegate; + } + + @Override + public List findActiveByMembership( + String organizationId, + String membershipId + ) { + return delegate.findByOrganizationIdAndMembershipIdAndStatus( + Objects.requireNonNull(organizationId, "organizationId"), + Objects.requireNonNull(membershipId, "membershipId"), + PreProvisionedLoginSubjectStatus.ACTIVE + ); + } + + @Override + public Optional findByCoordinate( + ExternalIdentityCoordinate coordinate + ) { + Objects.requireNonNull(coordinate, "coordinate must not be null"); + return coordinate.organizationId().flatMap(organizationId -> + delegate + .findByOrganizationIdAndLoginConnectionIdAndIssuerAndSubjectTypeAndSubjectValue( + organizationId, + coordinate.connectionId(), + coordinate.issuer().toString(), + coordinate.subject().type().value(), + coordinate.subject().value() + ) + ); + } + + @Override + public PreProvisionedLoginSubject saveAndFlush(PreProvisionedLoginSubject subject) { + return delegate.saveAndFlush(Objects.requireNonNull(subject, "subject must not be null")); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubject.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubject.java new file mode 100644 index 00000000..3dbb099c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubject.java @@ -0,0 +1,200 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.core.SubjectRef; +import com.iflytek.skillhub.auth.federation.core.SubjectType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.net.URI; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** Trusted immutable subject coordinate prepared before an employee's first enterprise login. */ +@Entity +@Table(name = "preprovisioned_login_subject") +public class PreProvisionedLoginSubject { + + @Id + @Column(length = 64) + private String id; + + @Column(name = "organization_id", nullable = false, length = 64, updatable = false) + private String organizationId; + + @Column(name = "membership_id", nullable = false, length = 64, updatable = false) + private String membershipId; + + @Column(name = "login_connection_id", nullable = false, length = 64, updatable = false) + private String loginConnectionId; + + @Column(nullable = false, length = 512, updatable = false) + private String issuer; + + @Column(name = "subject_type", nullable = false, length = 64, updatable = false) + private String subjectType; + + @Column(name = "subject_value", nullable = false, length = 1024, updatable = false) + private String subjectValue; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private PreProvisionedLoginSubjectStatus status; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Version + @Column(nullable = false) + private long version; + + protected PreProvisionedLoginSubject() { + } + + private PreProvisionedLoginSubject( + String organizationId, + String membershipId, + ExternalIdentityCoordinate coordinate, + Instant createdAt + ) { + this.id = UUID.randomUUID().toString(); + this.organizationId = bounded(organizationId, 64, "organizationId"); + this.membershipId = bounded(membershipId, 64, "membershipId"); + this.loginConnectionId = bounded(coordinate.connectionId(), 64, "connectionId"); + this.issuer = bounded(coordinate.issuer().toString(), 512, "issuer"); + this.subjectType = bounded(coordinate.subject().type().value(), 64, "subjectType"); + this.subjectValue = boundedOpaque( + coordinate.subject().value(), + 1024, + "subjectValue" + ); + this.status = PreProvisionedLoginSubjectStatus.ACTIVE; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null"); + this.updatedAt = createdAt; + } + + public static PreProvisionedLoginSubject claim( + String organizationId, + String membershipId, + ExternalIdentityCoordinate coordinate, + Instant createdAt + ) { + Objects.requireNonNull(coordinate, "coordinate must not be null"); + String coordinateOrganization = coordinate.organizationId().orElseThrow( + () -> new IllegalArgumentException( + "pre-provisioned login subject requires an organization" + ) + ); + if (!coordinateOrganization.equals(organizationId)) { + throw new IllegalArgumentException("coordinate organization does not match claim"); + } + return new PreProvisionedLoginSubject( + organizationId, + membershipId, + coordinate, + createdAt + ); + } + + public ExternalIdentityCoordinate coordinate() { + return new ExternalIdentityCoordinate( + Optional.of(organizationId), + loginConnectionId, + URI.create(issuer), + new SubjectRef( + new SubjectType(subjectType), + subjectValue + ) + ); + } + + public boolean isActive() { + return status == PreProvisionedLoginSubjectStatus.ACTIVE; + } + + public void revoke(Instant occurredAt) { + if (status == PreProvisionedLoginSubjectStatus.REVOKED) { + return; + } + Instant transitionTime = Objects.requireNonNull(occurredAt, "occurredAt must not be null"); + if (transitionTime.isBefore(updatedAt)) { + throw new IllegalArgumentException("revocation time must not precede the last update"); + } + status = PreProvisionedLoginSubjectStatus.REVOKED; + revokedAt = transitionTime; + updatedAt = transitionTime; + } + + private static String bounded(String value, int maximum, String field) { + Objects.requireNonNull(value, field + " must not be null"); + String normalized = value.trim(); + if (normalized.isEmpty() || normalized.length() > maximum) { + throw new IllegalArgumentException(field + " is outside its persistence bounds"); + } + return normalized; + } + + private static String boundedOpaque(String value, int maximum, String field) { + Objects.requireNonNull(value, field + " must not be null"); + if (value.isBlank() || value.length() > maximum) { + throw new IllegalArgumentException(field + " is outside its persistence bounds"); + } + return value; + } + + public String getId() { + return id; + } + + public String getOrganizationId() { + return organizationId; + } + + public String getMembershipId() { + return membershipId; + } + + public String getLoginConnectionId() { + return loginConnectionId; + } + + public String getIssuer() { + return issuer; + } + + public String getSubjectType() { + return subjectType; + } + + public String getSubjectValue() { + return subjectValue; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getRevokedAt() { + return revokedAt; + } + + public long getVersion() { + return version; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectRepository.java new file mode 100644 index 00000000..e8cecba3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectRepository.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.auth.federation.association; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import java.util.List; +import java.util.Optional; + +/** Persistence port for trusted first-login subject claims. */ +public interface PreProvisionedLoginSubjectRepository { + + Optional findByCoordinate( + ExternalIdentityCoordinate coordinate + ); + + List findActiveByMembership( + String organizationId, + String membershipId + ); + + PreProvisionedLoginSubject saveAndFlush(PreProvisionedLoginSubject subject); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectSpringDataRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectSpringDataRepository.java new file mode 100644 index 00000000..89ababdc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectSpringDataRepository.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.auth.federation.association; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Internal Spring Data delegate for exact, tenant-scoped subject claims. */ +interface PreProvisionedLoginSubjectSpringDataRepository + extends JpaRepository { + + Optional + findByOrganizationIdAndLoginConnectionIdAndIssuerAndSubjectTypeAndSubjectValue( + String organizationId, + String loginConnectionId, + String issuer, + String subjectType, + String subjectValue + ); + + List findByOrganizationIdAndMembershipIdAndStatus( + String organizationId, + String membershipId, + PreProvisionedLoginSubjectStatus status + ); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectStatus.java new file mode 100644 index 00000000..b0363224 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/PreProvisionedLoginSubjectStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.auth.federation.association; + +/** Lifecycle of an immutable pre-login correlation claim. */ +enum PreProvisionedLoginSubjectStatus { + ACTIVE, + REVOKED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/package-info.java new file mode 100644 index 00000000..bc234f36 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/association/package-info.java @@ -0,0 +1,2 @@ +/** Transactional external identity binding and pre-provisioned membership association. */ +package com.iflytek.skillhub.auth.federation.association; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/ConfiguredIdentityCoreActivation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/ConfiguredIdentityCoreActivation.java new file mode 100644 index 00000000..58ace459 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/ConfiguredIdentityCoreActivation.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.auth.federation.config; + +import com.iflytek.skillhub.auth.federation.core.IdentityCoreActivation; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import java.util.Objects; +import java.util.Optional; + +/** Applies the global core mode while keeping non-allowlisted organizations on the legacy path. */ +final class ConfiguredIdentityCoreActivation implements IdentityCoreActivation { + + private final IdentityCoreProperties core; + private final EnterpriseIdentityRolloutProperties rollout; + + ConfiguredIdentityCoreActivation( + IdentityCoreProperties core, + EnterpriseIdentityRolloutProperties rollout + ) { + this.core = Objects.requireNonNull(core, "core properties must not be null"); + this.rollout = Objects.requireNonNull(rollout, "rollout properties must not be null"); + } + + @Override + public IdentityCoreMode modeFor(Optional organizationId) { + Objects.requireNonNull(organizationId, "organizationId must not be null"); + if (organizationId.isPresent() + && !rollout.getOrganizationAllowlist().contains(organizationId.orElseThrow())) { + return IdentityCoreMode.LEGACY; + } + return core.getMode(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseIdentityCoreConfiguration.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseIdentityCoreConfiguration.java new file mode 100644 index 00000000..a78ed123 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseIdentityCoreConfiguration.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.federation.config; + +import com.iflytek.skillhub.auth.federation.core.IdentityCoreActivation; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentityRepository; +import com.iflytek.skillhub.auth.federation.migration.IdentityBindingReadMetrics; +import com.iflytek.skillhub.auth.federation.migration.LegacyIdentityBindingDualReader; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Binds conservative rollout controls without enabling the identity decision module itself. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties({ + IdentityCoreProperties.class, + EnterpriseIdentityRolloutProperties.class +}) +public class EnterpriseIdentityCoreConfiguration { + + @Bean + IdentityCoreActivation identityCoreActivation( + IdentityCoreProperties core, + EnterpriseIdentityRolloutProperties rollout + ) { + return new ConfiguredIdentityCoreActivation(core, rollout); + } + + @Bean + @ConditionalOnMissingBean(IdentityBindingReadMetrics.class) + IdentityBindingReadMetrics identityBindingReadMetrics() { + return IdentityBindingReadMetrics.noop(); + } + + @Bean + LegacyIdentityBindingDualReader legacyIdentityBindingDualReader( + ExternalIdentityRepository externalIdentities, + IdentityBindingRepository legacyBindings, + IdentityBindingReadMetrics metrics + ) { + return new LegacyIdentityBindingDualReader(externalIdentities, legacyBindings, metrics); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseIdentityRolloutProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseIdentityRolloutProperties.java new file mode 100644 index 00000000..b4b14754 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseIdentityRolloutProperties.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.auth.federation.config; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Organization-scoped rollout controls shared by enterprise identity capabilities. */ +@ConfigurationProperties(prefix = "skillhub.enterprise") +public class EnterpriseIdentityRolloutProperties { + + private Set organizationAllowlist = Set.of(); + + public Set getOrganizationAllowlist() { + return organizationAllowlist; + } + + public void setOrganizationAllowlist(Set organizationAllowlist) { + Objects.requireNonNull(organizationAllowlist, "organization allowlist must not be null"); + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String organizationId : organizationAllowlist) { + Objects.requireNonNull(organizationId, "organization allowlist must not contain null values"); + String value = organizationId.trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException("organization allowlist must not contain blank values"); + } + normalized.add(value); + } + this.organizationAllowlist = Set.copyOf(normalized); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/IdentityCoreProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/IdentityCoreProperties.java new file mode 100644 index 00000000..15c9d581 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/IdentityCoreProperties.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.auth.federation.config; + +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import jakarta.annotation.PostConstruct; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Configuration for {@code skillhub.identity.core.mode}; defaults to the legacy path. */ +@ConfigurationProperties(prefix = "skillhub.identity.core") +public class IdentityCoreProperties { + + private static final Logger log = LoggerFactory.getLogger(IdentityCoreProperties.class); + + private IdentityCoreMode mode = IdentityCoreMode.LEGACY; + + @PostConstruct + void logEffectiveMode() { + log.info("Identity core mode: {}", mode); + } + + public IdentityCoreMode getMode() { + return mode; + } + + public void setMode(IdentityCoreMode mode) { + this.mode = Objects.requireNonNull(mode, "identity core mode must not be null"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/package-info.java new file mode 100644 index 00000000..27eb5897 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/package-info.java @@ -0,0 +1,2 @@ +/** Conservative runtime configuration for enterprise identity rollout. */ +package com.iflytek.skillhub.auth.federation.config; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ApprovedIdentityLogin.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ApprovedIdentityLogin.java new file mode 100644 index 00000000..cb318056 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ApprovedIdentityLogin.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Proof that every state required for principal creation passed the final login guard. */ +public final class ApprovedIdentityLogin { + + private final IdentityLoginGuardContext context; + + ApprovedIdentityLogin(IdentityLoginGuardContext context) { + this.context = context; + } + + public IdentityLoginGuardContext context() { + return context; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/Assurance.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/Assurance.java new file mode 100644 index 00000000..62ec8420 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/Assurance.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Normalized evidence about how strongly an identity fact was established. */ +public record Assurance(String value) { + + private static final Pattern VALUE_PATTERN = Pattern.compile("[a-z][a-z0-9._:-]{0,127}"); + + public Assurance { + Objects.requireNonNull(value, "assurance must not be null"); + value = value.trim(); + if (!VALUE_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("assurance must be a normalized stable key"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AttributeKey.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AttributeKey.java new file mode 100644 index 00000000..8ba78564 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AttributeKey.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Namespaced platform key for an adapter-normalized identity attribute. */ +public record AttributeKey(String value) { + + private static final Pattern VALUE_PATTERN = Pattern.compile("[a-z][a-z0-9_-]*(?:\\.[a-z][a-z0-9_-]*)+"); + + public AttributeKey { + Objects.requireNonNull(value, "attribute key must not be null"); + value = value.trim(); + if (!VALUE_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("attribute key must be a normalized namespaced key"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AttributeValue.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AttributeValue.java new file mode 100644 index 00000000..cf1b7532 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AttributeValue.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.List; +import java.util.Objects; + +/** Closed set of values that can cross the authentication adapter boundary. */ +public sealed interface AttributeValue + permits AttributeValue.Text, AttributeValue.TextList, AttributeValue.Flag, AttributeValue.NumberValue { + + record Text(String value) implements AttributeValue { + public Text { + Objects.requireNonNull(value, "attribute text must not be null"); + } + } + + record TextList(List values) implements AttributeValue { + public TextList { + Objects.requireNonNull(values, "attribute text list must not be null"); + if (values.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("attribute text list must not contain null values"); + } + values = List.copyOf(values); + } + } + + record Flag(boolean value) implements AttributeValue { + } + + record NumberValue(long value) implements AttributeValue { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterException.java new file mode 100644 index 00000000..cd4df9d9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterException.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Authentication Adapter failure whose public shape cannot embed provider payloads or Secrets. */ +public final class AuthenticationAdapterException extends RuntimeException { + + private final AuthenticationAdapterFailureReason reason; + + public AuthenticationAdapterException(AuthenticationAdapterFailureReason reason) { + super(Objects.requireNonNull(reason, "reason").publicMessage()); + this.reason = reason; + } + + public AuthenticationAdapterFailureReason reason() { + return reason; + } + + public boolean retryable() { + return reason.retryable(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterFailureReason.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterFailureReason.java new file mode 100644 index 00000000..76b15fe3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterFailureReason.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Stable, privacy-safe categories returned by authentication adapters. */ +public enum AuthenticationAdapterFailureReason { + INVALID_ASSERTION("The identity provider response is invalid.", false), + AUTHENTICATION_DENIED("Authentication was denied by the identity provider.", false), + UPSTREAM_UNAVAILABLE("The identity provider is temporarily unavailable.", true), + CONNECTION_MISCONFIGURED("The identity connection is not configured correctly.", false); + + private final String publicMessage; + private final boolean retryable; + + AuthenticationAdapterFailureReason(String publicMessage, boolean retryable) { + this.publicMessage = publicMessage; + this.retryable = retryable; + } + + public String publicMessage() { + return publicMessage; + } + + public boolean retryable() { + return retryable; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/EnterpriseIdentitySecurityEventRecorder.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/EnterpriseIdentitySecurityEventRecorder.java new file mode 100644 index 00000000..fe06e6cf --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/EnterpriseIdentitySecurityEventRecorder.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Records redacted, tenant-correlated enterprise identity security events. */ +@FunctionalInterface +public interface EnterpriseIdentitySecurityEventRecorder { + + void record( + EnterpriseIdentitySecurityEventType type, + String organizationId, + String connectionId, + String targetReference + ); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/EnterpriseIdentitySecurityEventType.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/EnterpriseIdentitySecurityEventType.java new file mode 100644 index 00000000..06f567c8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/EnterpriseIdentitySecurityEventType.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Fixed low-cardinality security events emitted by the enterprise identity boundary. */ +public enum EnterpriseIdentitySecurityEventType { + IDENTITY_CORRELATION_CONFLICT, + MISSING_ACCOUNT_LOGIN_REJECTED, + INACTIVE_ACCOUNT_LOGIN_REJECTED, + MERGED_ACCOUNT_LOGIN_REJECTED, + SYSTEM_ACCOUNT_LOGIN_REJECTED, + ENTERPRISE_SESSION_REVOKED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityCoordinate.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityCoordinate.java new file mode 100644 index 00000000..43af6ca9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityCoordinate.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.net.URI; +import java.util.Objects; +import java.util.Optional; + +/** Stable coordinate used for exact binding and pre-provisioned subject lookup. */ +public record ExternalIdentityCoordinate( + Optional organizationId, + String connectionId, + URI issuer, + SubjectRef subject +) { + + public ExternalIdentityCoordinate { + Objects.requireNonNull(organizationId, "organizationId must not be null"); + organizationId = organizationId.map(value -> requireText(value, "organizationId")); + connectionId = requireText(connectionId, "connectionId"); + Objects.requireNonNull(issuer, "issuer must not be null"); + if (!issuer.isAbsolute()) { + throw new IllegalArgumentException("issuer must be an absolute URI"); + } + Objects.requireNonNull(subject, "subject must not be null"); + } + + public static ExternalIdentityCoordinate from(IdentityAssertion assertion) { + Objects.requireNonNull(assertion, "assertion must not be null"); + return new ExternalIdentityCoordinate( + assertion.organizationId(), + assertion.connectionId(), + assertion.issuer(), + assertion.subject() + ); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityGuardState.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityGuardState.java new file mode 100644 index 00000000..21d0a510 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityGuardState.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** External-identity states relevant to the final login guard. */ +public enum ExternalIdentityGuardState { + ACTIVE, + SUSPENDED, + REVOKED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityLoginModule.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityLoginModule.java new file mode 100644 index 00000000..3e29fca4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityLoginModule.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.List; +import java.util.Objects; + +/** + * Applies the deterministic, fail-closed identity correlation order. + * + *

This module returns a decision only. Transactional binding, provisioning, guards and session + * creation consume that decision in later orchestration stages.

+ */ +public final class ExternalIdentityLoginModule { + + private final IdentityCoreActivation activation; + private final IdentityCorrelationCandidates candidates; + private final IdentityCorrelationPolicy policy; + + public ExternalIdentityLoginModule( + IdentityCoreActivation activation, + IdentityCorrelationCandidates candidates, + IdentityCorrelationPolicy policy + ) { + this.activation = Objects.requireNonNull(activation, "activation must not be null"); + this.candidates = Objects.requireNonNull(candidates, "candidates must not be null"); + this.policy = Objects.requireNonNull(policy, "policy must not be null"); + } + + public IdentityLoginResolution resolve(IdentityAssertion assertion) { + Objects.requireNonNull(assertion, "assertion must not be null"); + IdentityCoreMode mode = Objects.requireNonNull( + activation.modeFor(assertion.organizationId()), + "identity core mode must not be null" + ); + if (mode == IdentityCoreMode.LEGACY) { + throw new IdentityCoreDisabledException(); + } + + ExternalIdentityCoordinate coordinate = ExternalIdentityCoordinate.from(assertion); + IdentityLoginResolution resolved = resolveStage( + IdentityCorrelationStage.EXACT_BINDING, + candidates.findExactBinding(coordinate) + ); + if (resolved != null) { + return resolved; + } + + if (coordinate.organizationId().isPresent()) { + resolved = resolveStage( + IdentityCorrelationStage.PRE_PROVISIONED_SUBJECT, + candidates.findPreProvisionedSubject(coordinate) + ); + if (resolved != null) { + return resolved; + } + } + + if (coordinate.organizationId().isPresent() + && assertion.email().isPresent() + && policy.allowsVerifiedEmailCorrelation(assertion)) { + resolved = resolveStage( + IdentityCorrelationStage.VERIFIED_EMAIL, + candidates.findByVerifiedEmail( + coordinate.organizationId().orElseThrow(), + assertion.email().orElseThrow() + ) + ); + if (resolved != null) { + return resolved; + } + } + + if (policy.allowsJitProvisioning(assertion)) { + return new IdentityLoginResolution.ProvisionNew(); + } + return new IdentityLoginResolution.Denied(IdentityLoginDenialReason.NO_SAFE_MATCH); + } + + private IdentityLoginResolution resolveStage( + IdentityCorrelationStage stage, + List suppliedCandidates + ) { + Objects.requireNonNull(suppliedCandidates, "correlation candidates must not be null"); + if (suppliedCandidates.stream().anyMatch(Objects::isNull)) { + throw new IllegalStateException("correlation candidates must not contain null values"); + } + List stageCandidates = List.copyOf(suppliedCandidates); + if (stageCandidates.size() == 1) { + return new IdentityLoginResolution.Matched(stage, stageCandidates.getFirst()); + } + if (stageCandidates.size() > 1) { + return new IdentityLoginResolution.Conflict(stage, stageCandidates.size()); + } + return null; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityAssertion.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityAssertion.java new file mode 100644 index 00000000..fc6163ab --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityAssertion.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Protocol-neutral verified facts accepted by the identity decision module. + * Raw credentials, tokens, protocol payloads and unverified claims never cross this boundary. + */ +public record IdentityAssertion( + Optional organizationId, + String connectionId, + URI issuer, + SubjectRef subject, + Optional email, + Optional loginName, + Optional displayName, + Optional avatarUrl, + Set assurance, + Instant authenticatedAt, + Map attributes +) { + + public IdentityAssertion { + organizationId = normalizeOptionalText(organizationId, "organizationId"); + connectionId = requireText(connectionId, "connectionId"); + Objects.requireNonNull(issuer, "issuer must not be null"); + if (!issuer.isAbsolute()) { + throw new IllegalArgumentException("issuer must be an absolute URI"); + } + Objects.requireNonNull(subject, "subject must not be null"); + email = Objects.requireNonNull(email, "email must not be null"); + loginName = normalizeOptionalText(loginName, "loginName"); + displayName = normalizeOptionalText(displayName, "displayName"); + avatarUrl = normalizeOptionalText(avatarUrl, "avatarUrl"); + assurance = Set.copyOf(Objects.requireNonNull(assurance, "assurance must not be null")); + Objects.requireNonNull(authenticatedAt, "authenticatedAt must not be null"); + attributes = Map.copyOf(Objects.requireNonNull(attributes, "attributes must not be null")); + attributes.forEach((key, attribute) -> { + Objects.requireNonNull(key, "attribute map key must not be null"); + Objects.requireNonNull(attribute, "attribute must not be null"); + if (!key.equals(attribute.key())) { + throw new IllegalArgumentException("attribute key must match its map key"); + } + }); + } + + private static Optional normalizeOptionalText(Optional value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + return value.map(text -> requireText(text, field)); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreActivation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreActivation.java new file mode 100644 index 00000000..d9fe54fa --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreActivation.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Optional; + +/** Resolves the rollout mode for a platform or organization-scoped identity assertion. */ +@FunctionalInterface +public interface IdentityCoreActivation { + + IdentityCoreMode modeFor(Optional organizationId); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreDisabledException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreDisabledException.java new file mode 100644 index 00000000..ba46b71e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreDisabledException.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Raised when callers attempt to enter the unified core while the legacy path is selected. */ +public final class IdentityCoreDisabledException extends IllegalStateException { + + public IdentityCoreDisabledException() { + super("Unified identity core is disabled"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreMode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreMode.java new file mode 100644 index 00000000..fb4bc1e1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCoreMode.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Rollout mode for the unified identity core. */ +public enum IdentityCoreMode { + LEGACY, + SHADOW, + ACTIVE +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationCandidates.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationCandidates.java new file mode 100644 index 00000000..88f72bef --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationCandidates.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.List; + +/** Read-only candidate lookup port used by deterministic identity correlation. */ +public interface IdentityCorrelationCandidates { + + List findExactBinding(ExternalIdentityCoordinate coordinate); + + List findPreProvisionedSubject(ExternalIdentityCoordinate coordinate); + + List findByVerifiedEmail(String organizationId, VerifiedEmail email); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicy.java new file mode 100644 index 00000000..d7a0c7da --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicy.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Organization policy decisions that are intentionally external to the correlation algorithm. */ +public interface IdentityCorrelationPolicy { + + boolean allowsVerifiedEmailCorrelation(IdentityAssertion assertion); + + boolean allowsJitProvisioning(IdentityAssertion assertion); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicySettings.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicySettings.java new file mode 100644 index 00000000..7737517d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicySettings.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Immutable platform policy attached to one Login Connection revision. */ +public record IdentityCorrelationPolicySettings( + boolean verifiedEmailCorrelationEnabled, + boolean jitProvisioningEnabled +) implements IdentityCorrelationPolicy { + + private static final Assurance VERIFIED_EMAIL_ASSURANCE = + new Assurance("email_verified"); + + public IdentityCorrelationPolicySettings { + if (jitProvisioningEnabled && !verifiedEmailCorrelationEnabled) { + throw new IllegalArgumentException( + "JIT provisioning requires verified-email candidate evaluation" + ); + } + } + + public static IdentityCorrelationPolicySettings disabled() { + return new IdentityCorrelationPolicySettings(false, false); + } + + public static IdentityCorrelationPolicySettings verifiedEmailOnly() { + return new IdentityCorrelationPolicySettings(true, false); + } + + public static IdentityCorrelationPolicySettings verifiedEmailWithJit() { + return new IdentityCorrelationPolicySettings(true, true); + } + + @Override + public boolean allowsVerifiedEmailCorrelation(IdentityAssertion assertion) { + return verifiedEmailCorrelationEnabled && hasTrustedVerifiedEmail(assertion); + } + + @Override + public boolean allowsJitProvisioning(IdentityAssertion assertion) { + return jitProvisioningEnabled && hasTrustedVerifiedEmail(assertion); + } + + private static boolean hasTrustedVerifiedEmail(IdentityAssertion assertion) { + IdentityAssertion verifiedAssertion = Objects.requireNonNull(assertion, "assertion"); + return verifiedAssertion.email().isPresent() + && verifiedAssertion.assurance().contains(VERIFIED_EMAIL_ASSURANCE); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationStage.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationStage.java new file mode 100644 index 00000000..32a0b025 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationStage.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Ordered correlation stage that produced a match or conflict. */ +public enum IdentityCorrelationStage { + EXACT_BINDING, + PRE_PROVISIONED_SUBJECT, + VERIFIED_EMAIL, + JIT_PROVISIONING +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationTarget.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationTarget.java new file mode 100644 index 00000000..f36555fb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationTarget.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import java.util.Optional; + +/** Candidate Platform Account and/or pre-provisioned Organization Membership. */ +public record IdentityCorrelationTarget(Optional userId, Optional membershipId) { + + public IdentityCorrelationTarget { + userId = normalizeOptionalText(userId, "userId"); + membershipId = normalizeOptionalText(membershipId, "membershipId"); + if (userId.isEmpty() && membershipId.isEmpty()) { + throw new IllegalArgumentException("correlation target must identify an account or membership"); + } + } + + public static IdentityCorrelationTarget account(String userId) { + return new IdentityCorrelationTarget(Optional.of(userId), Optional.empty()); + } + + public static IdentityCorrelationTarget membership(String membershipId) { + return new IdentityCorrelationTarget(Optional.empty(), Optional.of(membershipId)); + } + + public static IdentityCorrelationTarget linked(String userId, String membershipId) { + return new IdentityCorrelationTarget(Optional.of(userId), Optional.of(membershipId)); + } + + private static Optional normalizeOptionalText(Optional value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + return value.map(text -> { + String normalized = Objects.requireNonNull(text, field + " must not be null").trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + }); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginDenialReason.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginDenialReason.java new file mode 100644 index 00000000..fb5374bc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginDenialReason.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Stable reason for a non-conflicting identity correlation denial. */ +public enum IdentityLoginDenialReason { + NO_SAFE_MATCH +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuard.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuard.java new file mode 100644 index 00000000..affb0d5c --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuard.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.auth.federation.core; + +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.Objects; +import java.util.function.Function; + +/** Final, protocol-neutral gate before an authenticated principal or session may be created. */ +public final class IdentityLoginGuard { + + public ApprovedIdentityLogin requireAllowed(IdentityLoginGuardContext context) { + Objects.requireNonNull(context, "context"); + requireAccountAllowed(context); + context.organizationState().ifPresent(this::requireOrganizationAllowed); + context.membershipState().ifPresent(this::requireMembershipAllowed); + requireConnectionAllowed(context.connectionState()); + requireExternalIdentityAllowed(context.externalIdentityState()); + return new ApprovedIdentityLogin(context); + } + + public T continueAfterApproval( + IdentityLoginGuardContext context, + Function continuation + ) { + Objects.requireNonNull(continuation, "continuation"); + return continuation.apply(requireAllowed(context)); + } + + private static void requireAccountAllowed(IdentityLoginGuardContext context) { + if (context.systemAccount()) { + reject(IdentityLoginRejectionReason.SYSTEM_ACCOUNT_FORBIDDEN); + } + if (context.accountStatus() == UserStatus.PENDING) { + reject(IdentityLoginRejectionReason.ACCOUNT_PENDING); + } + if (context.accountStatus() == UserStatus.DISABLED) { + reject(IdentityLoginRejectionReason.ACCOUNT_DISABLED); + } + if (context.accountStatus() == UserStatus.MERGED) { + reject(IdentityLoginRejectionReason.ACCOUNT_MERGED); + } + } + + private void requireOrganizationAllowed(OrganizationLoginState state) { + if (state == OrganizationLoginState.SUSPENDED) { + reject(IdentityLoginRejectionReason.ORGANIZATION_SUSPENDED); + } + if (state == OrganizationLoginState.DECOMMISSIONED) { + reject(IdentityLoginRejectionReason.ORGANIZATION_DECOMMISSIONED); + } + } + + private void requireMembershipAllowed(MembershipLoginState state) { + switch (state) { + case ACTIVE -> { + } + case INVITED, PROVISIONED -> reject(IdentityLoginRejectionReason.MEMBERSHIP_NOT_ACTIVE); + case SUSPENDED -> reject(IdentityLoginRejectionReason.MEMBERSHIP_SUSPENDED); + case DEPROVISIONED -> reject(IdentityLoginRejectionReason.MEMBERSHIP_DEPROVISIONED); + } + } + + private void requireConnectionAllowed(LoginConnectionGuardState state) { + if (state != LoginConnectionGuardState.ACTIVE) { + reject(IdentityLoginRejectionReason.CONNECTION_NOT_ACTIVE); + } + } + + private void requireExternalIdentityAllowed(ExternalIdentityGuardState state) { + if (state == ExternalIdentityGuardState.SUSPENDED) { + reject(IdentityLoginRejectionReason.EXTERNAL_IDENTITY_SUSPENDED); + } + if (state == ExternalIdentityGuardState.REVOKED) { + reject(IdentityLoginRejectionReason.EXTERNAL_IDENTITY_REVOKED); + } + } + + private static void reject(IdentityLoginRejectionReason reason) { + throw new IdentityLoginRejectedException(reason); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuardContext.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuardContext.java new file mode 100644 index 00000000..599e0964 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuardContext.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.auth.federation.core; + +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.Objects; +import java.util.Optional; + +/** Immutable state inspected immediately before principal or session creation. */ +public record IdentityLoginGuardContext( + UserStatus accountStatus, + boolean systemAccount, + Optional organizationState, + Optional membershipState, + LoginConnectionGuardState connectionState, + ExternalIdentityGuardState externalIdentityState +) { + public IdentityLoginGuardContext { + Objects.requireNonNull(accountStatus, "accountStatus"); + Objects.requireNonNull(organizationState, "organizationState"); + Objects.requireNonNull(membershipState, "membershipState"); + Objects.requireNonNull(connectionState, "connectionState"); + Objects.requireNonNull(externalIdentityState, "externalIdentityState"); + if (organizationState.isPresent() != membershipState.isPresent()) { + throw new IllegalArgumentException( + "organization and membership states must either both be present or both be absent" + ); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginRejectedException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginRejectedException.java new file mode 100644 index 00000000..bf1315b0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginRejectedException.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Internal denial carrying an auditable reason without exposing account state to callers. */ +public final class IdentityLoginRejectedException extends RuntimeException { + + private final IdentityLoginRejectionReason reason; + + public IdentityLoginRejectedException(IdentityLoginRejectionReason reason) { + super("Enterprise identity login was rejected"); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + public IdentityLoginRejectionReason reason() { + return reason; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginRejectionReason.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginRejectionReason.java new file mode 100644 index 00000000..f2702a21 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginRejectionReason.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Stable internal reason codes for login-policy denial and audit events. */ +public enum IdentityLoginRejectionReason { + ACCOUNT_PENDING, + ACCOUNT_DISABLED, + ACCOUNT_MERGED, + SYSTEM_ACCOUNT_FORBIDDEN, + ORGANIZATION_SUSPENDED, + ORGANIZATION_DECOMMISSIONED, + MEMBERSHIP_NOT_ACTIVE, + MEMBERSHIP_SUSPENDED, + MEMBERSHIP_DEPROVISIONED, + CONNECTION_NOT_ACTIVE, + EXTERNAL_IDENTITY_SUSPENDED, + EXTERNAL_IDENTITY_REVOKED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginResolution.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginResolution.java new file mode 100644 index 00000000..ec0ead8d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginResolution.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Exhaustive result of the pure identity correlation stage. */ +public sealed interface IdentityLoginResolution + permits IdentityLoginResolution.Matched, + IdentityLoginResolution.ProvisionNew, + IdentityLoginResolution.Denied, + IdentityLoginResolution.Conflict { + + record Matched(IdentityCorrelationStage stage, IdentityCorrelationTarget target) + implements IdentityLoginResolution { + public Matched { + Objects.requireNonNull(stage, "correlation stage must not be null"); + Objects.requireNonNull(target, "correlation target must not be null"); + } + } + + record ProvisionNew() implements IdentityLoginResolution { + } + + record Denied(IdentityLoginDenialReason reason) implements IdentityLoginResolution { + public Denied { + Objects.requireNonNull(reason, "denial reason must not be null"); + } + } + + record Conflict(IdentityCorrelationStage stage, int candidateCount) implements IdentityLoginResolution { + public Conflict { + Objects.requireNonNull(stage, "correlation stage must not be null"); + if (candidateCount < 2) { + throw new IllegalArgumentException("conflict candidate count must be at least two"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/LoginConnectionGuardState.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/LoginConnectionGuardState.java new file mode 100644 index 00000000..bc9112e4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/LoginConnectionGuardState.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Login-connection states relevant to the final login guard. */ +public enum LoginConnectionGuardState { + DRAFT, + ACTIVE, + SUSPENDED, + ERROR, + DISABLED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/MembershipLoginState.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/MembershipLoginState.java new file mode 100644 index 00000000..a2d26ee8 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/MembershipLoginState.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Enterprise membership states relevant to the final login guard. */ +public enum MembershipLoginState { + INVITED, + PROVISIONED, + ACTIVE, + SUSPENDED, + DEPROVISIONED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/NormalizedAttribute.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/NormalizedAttribute.java new file mode 100644 index 00000000..c3b336f3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/NormalizedAttribute.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import java.util.Set; + +/** Typed normalized attribute plus the assurance evidence attached to that fact. */ +public record NormalizedAttribute(AttributeKey key, AttributeValue value, Set assurance) { + + public NormalizedAttribute { + Objects.requireNonNull(key, "attribute key must not be null"); + Objects.requireNonNull(value, "attribute value must not be null"); + Objects.requireNonNull(assurance, "attribute assurance must not be null"); + assurance = Set.copyOf(assurance); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/OrganizationLoginState.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/OrganizationLoginState.java new file mode 100644 index 00000000..866dec9b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/OrganizationLoginState.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Organization states relevant to the final login guard. */ +public enum OrganizationLoginState { + ACTIVE, + SUSPENDED, + DECOMMISSIONED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityResolver.java new file mode 100644 index 00000000..d3a93d93 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityResolver.java @@ -0,0 +1,63 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; + +/** Resolves one profile field update using field-specific, platform-owned authority policy. */ +public final class ProfileAuthorityResolver { + + public ProfileFieldResolution resolve( + Optional current, + ProfileFieldCandidate candidate, + ProfileFieldAuthorityPolicy policy + ) { + Objects.requireNonNull(current, "current"); + Objects.requireNonNull(candidate, "candidate"); + Objects.requireNonNull(policy, "policy"); + requireSameField(current, candidate, policy); + + OptionalInt candidatePriority = policy.priorityFor(candidate.source()); + if (candidatePriority.isEmpty()) { + return current.map(state -> new ProfileFieldResolution.Preserved( + state, + ProfileFieldResolutionReason.SOURCE_NOT_AUTHORIZED + )).orElseGet(() -> new ProfileFieldResolution.Rejected( + ProfileFieldResolutionReason.SOURCE_NOT_AUTHORIZED + )); + } + + ProfileFieldState proposed = new ProfileFieldState( + candidate.field(), + candidate.value(), + new ProfileFieldAuthority(candidate.source(), candidatePriority.getAsInt()) + ); + if (current.isEmpty()) { + return new ProfileFieldResolution.Applied(proposed); + } + + ProfileFieldState existing = current.orElseThrow(); + int comparison = Integer.compare(candidatePriority.getAsInt(), existing.authority().priority()); + if (comparison > 0 || (comparison == 0 + && candidate.source().equals(existing.authority().source()))) { + return new ProfileFieldResolution.Applied(proposed); + } + ProfileFieldResolutionReason reason = comparison < 0 + ? ProfileFieldResolutionReason.LOWER_AUTHORITY + : ProfileFieldResolutionReason.EQUAL_AUTHORITY_CONFLICT; + return new ProfileFieldResolution.Preserved(existing, reason); + } + + private static void requireSameField( + Optional current, + ProfileFieldCandidate candidate, + ProfileFieldAuthorityPolicy policy + ) { + if (!candidate.field().equals(policy.field())) { + throw new IllegalArgumentException("candidate field must match authority policy field"); + } + if (current.isPresent() && !current.orElseThrow().field().equals(candidate.field())) { + throw new IllegalArgumentException("current and candidate profile fields must match"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityRule.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityRule.java new file mode 100644 index 00000000..7dca47e2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityRule.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import java.util.Optional; + +/** Field-level authority granted to a source family or one exact connection source. */ +public record ProfileAuthorityRule( + ProfileSourceKind kind, + Optional sourceId, + int priority +) { + public ProfileAuthorityRule { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(sourceId, "sourceId"); + sourceId = sourceId.map(ProfileAuthorityRule::requireSourceId); + if (priority <= 0) { + throw new IllegalArgumentException("profile authority priority must be positive"); + } + } + + public static ProfileAuthorityRule forKind(ProfileSourceKind kind, int priority) { + return new ProfileAuthorityRule(kind, Optional.empty(), priority); + } + + public static ProfileAuthorityRule forSource( + ProfileSourceKind kind, + String sourceId, + int priority + ) { + return new ProfileAuthorityRule(kind, Optional.of(sourceId), priority); + } + + boolean matchesExactly(ProfileValueSource source) { + return kind == source.kind() && sourceId.filter(source.sourceId()::equals).isPresent(); + } + + boolean matchesKind(ProfileValueSource source) { + return kind == source.kind() && sourceId.isEmpty(); + } + + private static String requireSourceId(String sourceId) { + Objects.requireNonNull(sourceId, "sourceId"); + String normalized = sourceId.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("sourceId must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldAuthority.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldAuthority.java new file mode 100644 index 00000000..5c697116 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldAuthority.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Source and evaluated priority persisted alongside one profile field value. */ +public record ProfileFieldAuthority(ProfileValueSource source, int priority) { + public ProfileFieldAuthority { + Objects.requireNonNull(source, "source"); + if (priority <= 0) { + throw new IllegalArgumentException("profile authority priority must be positive"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldAuthorityPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldAuthorityPolicy.java new file mode 100644 index 00000000..0fbbadd0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldAuthorityPolicy.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.OptionalInt; +import java.util.Set; + +/** Allowlist and deterministic priority rules for one field coordinate. */ +public record ProfileFieldAuthorityPolicy( + ProfileFieldCoordinate field, + List rules +) { + public ProfileFieldAuthorityPolicy { + Objects.requireNonNull(field, "field"); + rules = List.copyOf(Objects.requireNonNull(rules, "rules")); + Set selectors = new HashSet<>(); + for (ProfileAuthorityRule rule : rules) { + Objects.requireNonNull(rule, "profile authority rule must not be null"); + RuleSelector selector = new RuleSelector(rule.kind(), rule.sourceId().orElse(null)); + if (!selectors.add(selector)) { + throw new IllegalArgumentException("duplicate profile authority rule: " + selector); + } + if (field.scope() instanceof ProfileScope.Global + && rule.kind() == ProfileSourceKind.ORGANIZATION_ADMIN) { + throw new IllegalArgumentException( + "organization-managed source cannot have authority over a global profile field" + ); + } + } + } + + OptionalInt priorityFor(ProfileValueSource source) { + for (ProfileAuthorityRule rule : rules) { + if (rule.matchesExactly(source)) { + return OptionalInt.of(rule.priority()); + } + } + for (ProfileAuthorityRule rule : rules) { + if (rule.matchesKind(source)) { + return OptionalInt.of(rule.priority()); + } + } + return OptionalInt.empty(); + } + + private record RuleSelector(ProfileSourceKind kind, String sourceId) { + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldCandidate.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldCandidate.java new file mode 100644 index 00000000..65240599 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldCandidate.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Proposed profile value whose authority must be resolved from platform policy. */ +public record ProfileFieldCandidate( + ProfileFieldCoordinate field, + AttributeValue value, + ProfileValueSource source +) { + public ProfileFieldCandidate { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(source, "source"); + if (!field.scope().equals(source.scope())) { + throw new IllegalArgumentException("profile source scope must match field scope"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldCoordinate.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldCoordinate.java new file mode 100644 index 00000000..ccdec6a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldCoordinate.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** A normalized profile field within exactly one global or organization scope. */ +public record ProfileFieldCoordinate(ProfileScope scope, AttributeKey field) { + public ProfileFieldCoordinate { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(field, "field"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldResolution.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldResolution.java new file mode 100644 index 00000000..b82b84af --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldResolution.java @@ -0,0 +1,32 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Result of evaluating one field update without performing persistence. */ +public sealed interface ProfileFieldResolution + permits ProfileFieldResolution.Applied, + ProfileFieldResolution.Preserved, + ProfileFieldResolution.Rejected { + + record Applied(ProfileFieldState state) implements ProfileFieldResolution { + public Applied { + Objects.requireNonNull(state, "state"); + } + } + + record Preserved( + ProfileFieldState state, + ProfileFieldResolutionReason reason + ) implements ProfileFieldResolution { + public Preserved { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(reason, "reason"); + } + } + + record Rejected(ProfileFieldResolutionReason reason) implements ProfileFieldResolution { + public Rejected { + Objects.requireNonNull(reason, "reason"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldResolutionReason.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldResolutionReason.java new file mode 100644 index 00000000..141b34a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldResolutionReason.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Stable reason codes for preserving or rejecting a proposed profile value. */ +public enum ProfileFieldResolutionReason { + SOURCE_NOT_AUTHORIZED, + LOWER_AUTHORITY, + EQUAL_AUTHORITY_CONFLICT +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldState.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldState.java new file mode 100644 index 00000000..0d1b3e49 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileFieldState.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Current value and authority metadata for one profile field. */ +public record ProfileFieldState( + ProfileFieldCoordinate field, + AttributeValue value, + ProfileFieldAuthority authority +) { + public ProfileFieldState { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(authority, "authority"); + if (!field.scope().equals(authority.source().scope())) { + throw new IllegalArgumentException("profile authority source scope must match field scope"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileScope.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileScope.java new file mode 100644 index 00000000..8b320e08 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileScope.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Separates platform-global profile data from organization-local profile data. */ +public sealed interface ProfileScope permits ProfileScope.Global, ProfileScope.Organization { + + static ProfileScope global() { + return new Global(); + } + + static ProfileScope organization(String organizationId) { + return new Organization(organizationId); + } + + record Global() implements ProfileScope { + } + + record Organization(String organizationId) implements ProfileScope { + public Organization { + Objects.requireNonNull(organizationId, "organizationId"); + organizationId = organizationId.trim(); + if (organizationId.isEmpty()) { + throw new IllegalArgumentException("organizationId must not be blank"); + } + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileSourceKind.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileSourceKind.java new file mode 100644 index 00000000..2c21ac61 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileSourceKind.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** Platform-defined profile source families; protocol and vendor names do not belong here. */ +public enum ProfileSourceKind { + LOGIN_ASSERTION, + USER_MANAGED, + ORGANIZATION_ADMIN +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileValueSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileValueSource.java new file mode 100644 index 00000000..8cfb3ba4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/ProfileValueSource.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** Stable source identity recorded with a single profile field value. */ +public record ProfileValueSource(ProfileSourceKind kind, String sourceId, ProfileScope scope) { + + public ProfileValueSource { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(sourceId, "sourceId"); + sourceId = sourceId.trim(); + if (sourceId.isEmpty()) { + throw new IllegalArgumentException("sourceId must not be blank"); + } + Objects.requireNonNull(scope, "scope"); + if (requiresOrganization(kind) && !(scope instanceof ProfileScope.Organization)) { + throw new IllegalArgumentException(kind + " source requires organization scope"); + } + } + + private static boolean requiresOrganization(ProfileSourceKind kind) { + return kind == ProfileSourceKind.ORGANIZATION_ADMIN; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/Slf4jEnterpriseIdentitySecurityEventRecorder.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/Slf4jEnterpriseIdentitySecurityEventRecorder.java new file mode 100644 index 00000000..fc156210 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/Slf4jEnterpriseIdentitySecurityEventRecorder.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +/** Emits a stable security-event shape without protocol claims, email, token or session data. */ +@Component +public class Slf4jEnterpriseIdentitySecurityEventRecorder + implements EnterpriseIdentitySecurityEventRecorder { + + private static final Logger LOGGER = LoggerFactory.getLogger( + Slf4jEnterpriseIdentitySecurityEventRecorder.class + ); + + @Override + public void record( + EnterpriseIdentitySecurityEventType type, + String organizationId, + String connectionId, + String targetReference + ) { + LOGGER.warn( + "enterprise_identity_security_event type={} organizationId={} connectionId={} " + + "targetReference={} requestId={}", + Objects.requireNonNull(type, "type"), + requireOpaqueReference(organizationId, "organizationId"), + requireOpaqueReference(connectionId, "connectionId"), + requireOpaqueReference(targetReference, "targetReference"), + requestId() + ); + } + + private static String requestId() { + String requestId = MDC.get("requestId"); + return requestId == null || requestId.isBlank() ? "unavailable" : requestId; + } + + private static String requireOpaqueReference(String value, String field) { + Objects.requireNonNull(value, field); + if (value.isBlank() || value.length() > 128 + || value.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException(field + " is invalid"); + } + return value; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/SubjectRef.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/SubjectRef.java new file mode 100644 index 00000000..fc532ee5 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/SubjectRef.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; + +/** A case-preserving immutable identifier in an adapter-defined subject namespace. */ +public record SubjectRef(SubjectType type, String value) { + + private static final int MAX_VALUE_LENGTH = 1024; + + public SubjectRef { + Objects.requireNonNull(type, "subject type must not be null"); + Objects.requireNonNull(value, "subject value must not be null"); + if (value.isBlank()) { + throw new IllegalArgumentException("subject value must not be blank"); + } + if (value.length() > MAX_VALUE_LENGTH) { + throw new IllegalArgumentException("subject value exceeds " + MAX_VALUE_LENGTH + " characters"); + } + if (value.codePoints().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("subject value must not contain control characters"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/SubjectType.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/SubjectType.java new file mode 100644 index 00000000..25657820 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/SubjectType.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Stable, adapter-supplied classification for an external subject identifier. */ +public record SubjectType(String value) { + + private static final Pattern VALUE_PATTERN = Pattern.compile("[a-z][a-z0-9._-]{0,63}"); + + public SubjectType { + Objects.requireNonNull(value, "subject type must not be null"); + value = value.trim(); + if (!VALUE_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("subject type must be a normalized stable key"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/VerifiedAuthenticationFactsAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/VerifiedAuthenticationFactsAdapter.java new file mode 100644 index 00000000..853d2aaa --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/VerifiedAuthenticationFactsAdapter.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.federation.core; + +/** + * Post-verification seam that turns protocol-specific trusted facts into the only assertion shape + * accepted by the identity decision module. + * + *

This does not replace interaction-specific authentication interfaces. Implementations must + * not write platform state or forward credentials, tokens, or unverified claims. Invalid input is + * normalized to {@link AuthenticationAdapterException} rather than leaking provider exceptions. + */ +@FunctionalInterface +public interface VerifiedAuthenticationFactsAdapter { + + IdentityAssertion toAssertion(F verifiedFacts); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/VerifiedEmail.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/VerifiedEmail.java new file mode 100644 index 00000000..d6a74200 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/VerifiedEmail.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Email address whose ownership has already been established by a trusted adapter. */ +public record VerifiedEmail(String value) { + + private static final Pattern VALUE_PATTERN = Pattern.compile( + "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$" + ); + + public VerifiedEmail { + Objects.requireNonNull(value, "verified email must not be null"); + value = value.trim().toLowerCase(Locale.ROOT); + if (value.length() > 254 || !VALUE_PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("verified email is invalid"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/package-info.java new file mode 100644 index 00000000..9942bbd2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/core/package-info.java @@ -0,0 +1,2 @@ +/** Protocol-neutral contracts consumed by SkillHub's identity decision module. */ +package com.iflytek.skillhub.auth.federation.core; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/IdentityBindingReadMetrics.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/IdentityBindingReadMetrics.java new file mode 100644 index 00000000..8ae0407f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/IdentityBindingReadMetrics.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.federation.migration; + +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; + +/** Observability port for binding migration reads; implementations must use bounded tags only. */ +@FunctionalInterface +public interface IdentityBindingReadMetrics { + + void record(IdentityCoreMode mode, IdentityBindingReadOutcome outcome); + + static IdentityBindingReadMetrics noop() { + return (mode, outcome) -> { }; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/IdentityBindingReadOutcome.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/IdentityBindingReadOutcome.java new file mode 100644 index 00000000..aace53ba --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/IdentityBindingReadOutcome.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.auth.federation.migration; + +/** Low-cardinality outcomes emitted by the legacy-to-V2 identity binding read seam. */ +public enum IdentityBindingReadOutcome { + LEGACY_HIT, + LEGACY_MISS, + MATCH, + V2_MISS_LEGACY_FALLBACK, + V2_ONLY, + MISMATCH, + V2_UNAVAILABLE +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/LegacyIdentityBindingDualReader.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/LegacyIdentityBindingDualReader.java new file mode 100644 index 00000000..a30db465 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/LegacyIdentityBindingDualReader.java @@ -0,0 +1,102 @@ +package com.iflytek.skillhub.auth.federation.migration; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentity; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentityRepository; +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationTarget; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** V2-first read seam for legacy platform bindings, with an immediate legacy rollback mode. */ +public final class LegacyIdentityBindingDualReader { + + private final ExternalIdentityRepository externalIdentities; + private final IdentityBindingRepository legacyBindings; + private final IdentityBindingReadMetrics metrics; + + public LegacyIdentityBindingDualReader( + ExternalIdentityRepository externalIdentities, + IdentityBindingRepository legacyBindings, + IdentityBindingReadMetrics metrics + ) { + this.externalIdentities = Objects.requireNonNull(externalIdentities, "externalIdentities"); + this.legacyBindings = Objects.requireNonNull(legacyBindings, "legacyBindings"); + this.metrics = Objects.requireNonNull(metrics, "metrics"); + } + + public List findExactBinding( + IdentityCoreMode mode, + ExternalIdentityCoordinate coordinate, + String provider, + String subject + ) { + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(coordinate, "coordinate"); + if (mode == IdentityCoreMode.LEGACY) { + return legacyOnly(mode, provider, subject); + } + + Optional v2 = externalIdentities.findByCoordinate(coordinate); + Optional legacy = legacyBindings.findByProviderCodeAndSubject(provider, subject); + if (v2.isEmpty()) { + metrics.record(mode, legacy.isPresent() + ? IdentityBindingReadOutcome.V2_MISS_LEGACY_FALLBACK + : IdentityBindingReadOutcome.LEGACY_MISS); + return target(legacy.map(IdentityBinding::getUserId)); + } + + ExternalIdentity identity = v2.orElseThrow(); + if (!identity.isActive()) { + metrics.record(mode, IdentityBindingReadOutcome.V2_UNAVAILABLE); + if (mode == IdentityCoreMode.SHADOW) { + return target(legacy.map(IdentityBinding::getUserId)); + } + identity.requireActive(); + } + + Optional legacyUserId = legacy.map(IdentityBinding::getUserId); + if (legacyUserId.isEmpty()) { + metrics.record(mode, IdentityBindingReadOutcome.V2_ONLY); + if (mode == IdentityCoreMode.ACTIVE) { + throw new IllegalStateException( + "Legacy platform identity exists only in V2 while legacy writes remain enabled" + ); + } + return List.of(); + } + if (!identity.getUserId().equals(legacyUserId.orElseThrow())) { + metrics.record(mode, IdentityBindingReadOutcome.MISMATCH); + if (mode == IdentityCoreMode.ACTIVE) { + throw new IllegalStateException("V2 and legacy identity bindings resolve different accounts"); + } + return target(legacyUserId); + } + + metrics.record(mode, IdentityBindingReadOutcome.MATCH); + return target(Optional.of(mode == IdentityCoreMode.ACTIVE + ? identity.getUserId() + : legacyUserId.orElseThrow())); + } + + private List legacyOnly( + IdentityCoreMode mode, + String provider, + String subject + ) { + Optional userId = legacyBindings.findByProviderCodeAndSubject(provider, subject) + .map(IdentityBinding::getUserId); + metrics.record(mode, userId.isPresent() + ? IdentityBindingReadOutcome.LEGACY_HIT + : IdentityBindingReadOutcome.LEGACY_MISS); + return target(userId); + } + + private static List target(Optional userId) { + return userId.map(value -> List.of(IdentityCorrelationTarget.account(value))) + .orElseGet(List::of); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/package-info.java new file mode 100644 index 00000000..e5008424 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/migration/package-info.java @@ -0,0 +1,2 @@ +/** Compatibility seams for migrating legacy platform identities to External Identity V2. */ +package com.iflytek.skillhub.auth.federation.migration; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/package-info.java new file mode 100644 index 00000000..ef2366c9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/package-info.java @@ -0,0 +1,2 @@ +/** Enterprise login orchestration between protocol adapters and the protocol-neutral identity core. */ +package com.iflytek.skillhub.auth.federation; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCore.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCore.java new file mode 100644 index 00000000..29b786cc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCore.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.oauth; + +/** Compatibility seam that lets public OAuth/OIDC exercise the unified identity core. */ +@FunctionalInterface +public interface LegacyPlatformIdentityCore { + + LegacyPlatformIdentityDecision evaluate(OAuthClaims claims); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridge.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridge.java new file mode 100644 index 00000000..87ad10ba --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridge.java @@ -0,0 +1,158 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityLoginModule; +import com.iflytek.skillhub.auth.federation.core.IdentityAssertion; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreActivation; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationCandidates; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationPolicy; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationStage; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationTarget; +import com.iflytek.skillhub.auth.federation.core.IdentityLoginResolution; +import com.iflytek.skillhub.auth.federation.core.VerifiedAuthenticationFactsAdapter; +import com.iflytek.skillhub.auth.federation.core.VerifiedEmail; +import com.iflytek.skillhub.auth.federation.migration.LegacyIdentityBindingDualReader; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Adapts legacy platform bindings to the unified correlation core while legacy persistence remains + * authoritative. SHADOW failures are observable but cannot affect the existing login outcome. + */ +@Service +public final class LegacyPlatformIdentityCoreBridge implements LegacyPlatformIdentityCore { + + private static final Logger log = LoggerFactory.getLogger(LegacyPlatformIdentityCoreBridge.class); + + private final IdentityCoreActivation activation; + private final LegacyIdentityBindingDualReader bindings; + private final VerifiedAuthenticationFactsAdapter assertionAdapter; + + public LegacyPlatformIdentityCoreBridge( + IdentityCoreActivation activation, + LegacyIdentityBindingDualReader bindings, + VerifiedAuthenticationFactsAdapter assertionAdapter + ) { + this.activation = Objects.requireNonNull(activation, "activation"); + this.bindings = Objects.requireNonNull(bindings, "bindings"); + this.assertionAdapter = Objects.requireNonNull(assertionAdapter, "assertionAdapter"); + } + + @Override + public LegacyPlatformIdentityDecision evaluate(OAuthClaims claims) { + IdentityCoreMode mode = Objects.requireNonNull( + activation.modeFor(Optional.empty()), + "identity core mode" + ); + if (mode == IdentityCoreMode.LEGACY) { + return LegacyPlatformIdentityDecision.legacy(); + } + + try { + IdentityAssertion assertion = assertionAdapter.toAssertion(claims); + IdentityLoginResolution resolution = module(mode, claims, assertion).resolve(assertion); + requireCompatibleLegacyResolution(resolution); + log.debug( + "Unified identity {} evaluation completed for legacy platform connection '{}' with {}", + mode, + assertion.connectionId(), + resolution.getClass().getSimpleName() + ); + return LegacyPlatformIdentityDecision.evaluated(mode, resolution); + } catch (RuntimeException failure) { + if (mode == IdentityCoreMode.SHADOW) { + log.warn( + "Unified identity SHADOW evaluation failed for legacy provider '{}', category '{}'; legacy login continues", + safeProvider(claims), + failure.getClass().getSimpleName() + ); + return new LegacyPlatformIdentityDecision(IdentityCoreMode.SHADOW, Optional.empty()); + } + throw new OAuthIdentityCoreException(failure); + } + } + + private ExternalIdentityLoginModule module( + IdentityCoreMode mode, + OAuthClaims claims, + IdentityAssertion assertion + ) { + ExternalIdentityCoordinate expected = ExternalIdentityCoordinate.from(assertion); + IdentityCorrelationCandidates candidates = new IdentityCorrelationCandidates() { + @Override + public List findExactBinding(ExternalIdentityCoordinate coordinate) { + if (!expected.equals(coordinate)) { + return List.of(); + } + return bindings.findExactBinding( + mode, + coordinate, + claims.provider(), + claims.subject() + ); + } + + /** + * Deliberately empty on the legacy platform path: pre-provisioned subjects are an + * enterprise control-plane concept, and this batch must not introduce a correlation + * path that could match an account the legacy flow would not have matched. + */ + @Override + public List findPreProvisionedSubject( + ExternalIdentityCoordinate coordinate + ) { + return List.of(); + } + + /** Deliberately empty; see {@link #allowsVerifiedEmailCorrelation}. */ + @Override + public List findByVerifiedEmail( + String organizationId, + VerifiedEmail email + ) { + return List.of(); + } + }; + IdentityCorrelationPolicy policy = new IdentityCorrelationPolicy() { + /** + * Disabled for public OAuth in this batch. Verified-email correlation can join an + * external login to an existing account, so enabling it here would change matching + * results while legacy {@code identity_binding} is still the write authority. It + * belongs to the batch that also owns the enterprise correlation policy. + */ + @Override + public boolean allowsVerifiedEmailCorrelation(IdentityAssertion ignored) { + return false; + } + + @Override + public boolean allowsJitProvisioning(IdentityAssertion ignored) { + return true; + } + }; + return new ExternalIdentityLoginModule(ignored -> mode, candidates, policy); + } + + private static void requireCompatibleLegacyResolution(IdentityLoginResolution resolution) { + if (resolution instanceof IdentityLoginResolution.ProvisionNew) { + return; + } + if (resolution instanceof IdentityLoginResolution.Matched matched + && matched.stage() == IdentityCorrelationStage.EXACT_BINDING + && matched.target().userId().isPresent()) { + return; + } + throw new IllegalStateException( + "Unified identity result is incompatible with the legacy platform flow" + ); + } + + private static String safeProvider(OAuthClaims claims) { + return claims == null || claims.provider() == null ? "unknown" : claims.provider(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityDecision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityDecision.java new file mode 100644 index 00000000..3ad635a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityDecision.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.federation.core.IdentityLoginResolution; +import java.util.Objects; +import java.util.Optional; + +/** + * Result of evaluating one platform-scoped legacy OAuth login against the unified identity core. + * + *

LEGACY mode intentionally carries no resolution because the old binding path remains the only + * decision-maker. SHADOW and ACTIVE carry the core resolution when evaluation was possible.

+ */ +public record LegacyPlatformIdentityDecision( + IdentityCoreMode mode, + Optional resolution +) { + + public LegacyPlatformIdentityDecision { + Objects.requireNonNull(mode, "mode must not be null"); + resolution = Objects.requireNonNull(resolution, "resolution must not be null"); + if (mode == IdentityCoreMode.LEGACY && resolution.isPresent()) { + throw new IllegalArgumentException("LEGACY mode must not carry a unified identity resolution"); + } + } + + public static LegacyPlatformIdentityDecision legacy() { + return new LegacyPlatformIdentityDecision(IdentityCoreMode.LEGACY, Optional.empty()); + } + + public static LegacyPlatformIdentityDecision evaluated( + IdentityCoreMode mode, + IdentityLoginResolution resolution + ) { + if (mode == IdentityCoreMode.LEGACY) { + throw new IllegalArgumentException("Use legacy() for LEGACY mode"); + } + return new LegacyPlatformIdentityDecision( + mode, + Optional.of(Objects.requireNonNull(resolution, "resolution must not be null")) + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index a347dcc3..a75f2457 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -1,23 +1,20 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.session.PlatformSessionService; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; - import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.auth.session.PlatformSessionService; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - /** * Login success handler that copies the resolved platform principal into the * HTTP session and then redirects to the stored return target or default URL. - * + * *

This handler extends {@link SimpleUrlAuthenticationSuccessHandler} and only * uses the returnTo parameter stored in session and the default target URL for * redirect decisions, ignoring any saved request from Spring Security's RequestCache. diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityCoreException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityCoreException.java new file mode 100644 index 00000000..1e32a607 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthIdentityCoreException.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +/** Privacy-safe failure raised when ACTIVE unified-core evaluation cannot reach a safe decision. */ +public final class OAuthIdentityCoreException extends OAuth2AuthenticationException { + + public OAuthIdentityCoreException(Throwable cause) { + super(error(), "Unified identity evaluation failed", cause); + } + + private static OAuth2Error error() { + return new OAuth2Error("access_denied", "Unified identity evaluation failed", null); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java index ac3d413c..9a2c2824 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowService.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.federation.adapter.RemoteIdentityIoExecutor; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.federation.core.IdentityLoginResolution; import com.iflytek.skillhub.auth.identity.IdentityBindingService; import com.iflytek.skillhub.auth.policy.AccessDecision; import com.iflytek.skillhub.auth.policy.AccessPolicy; @@ -11,11 +14,14 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.AuthenticationException; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -29,40 +35,90 @@ import org.springframework.stereotype.Service; @Service public class OAuthLoginFlowService { - private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); private final Map extractors; private final AccessPolicy accessPolicy; private final IdentityBindingService identityBindingService; + private final LegacyPlatformIdentityCore identityCore; + private final OAuth2UserService delegate; + private final RemoteIdentityIoExecutor remoteIdentityIo; + @Autowired public OAuthLoginFlowService(List extractorList, AccessPolicy accessPolicy, - IdentityBindingService identityBindingService) { + IdentityBindingService identityBindingService, + LegacyPlatformIdentityCore identityCore, + RemoteIdentityIoExecutor remoteIdentityIo) { + this( + extractorList, + accessPolicy, + identityBindingService, + identityCore, + new DefaultOAuth2UserService(), + remoteIdentityIo + ); + } + + OAuthLoginFlowService(List extractorList, + AccessPolicy accessPolicy, + IdentityBindingService identityBindingService, + LegacyPlatformIdentityCore identityCore, + OAuth2UserService delegate, + RemoteIdentityIoExecutor remoteIdentityIo) { this.extractors = extractorList.stream() .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); this.accessPolicy = accessPolicy; this.identityBindingService = identityBindingService; + this.identityCore = identityCore; + this.delegate = delegate; + this.remoteIdentityIo = remoteIdentityIo; + } + + OAuthLoginFlowService(List extractorList, + AccessPolicy accessPolicy, + IdentityBindingService identityBindingService, + LegacyPlatformIdentityCore identityCore) { + this( + extractorList, + accessPolicy, + identityBindingService, + identityCore, + new DefaultOAuth2UserService(), + directRemoteIdentityIo() + ); + } + + OAuthLoginFlowService(List extractorList, + AccessPolicy accessPolicy, + IdentityBindingService identityBindingService) { + this(extractorList, accessPolicy, identityBindingService, ignored -> LegacyPlatformIdentityDecision.legacy()); } public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) { - OAuth2User upstreamUser = delegate.loadUser(request); - String registrationId = request.getClientRegistration().getRegistrationId(); - - OAuthClaimsExtractor extractor = extractors.get(registrationId); - if (extractor == null) { - throw new OAuth2AuthenticationException( - new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null) + LoadedProviderIdentity loadedIdentity = remoteIdentityIo.execute(() -> { + OAuth2User upstreamUser = delegate.loadUser(request); + String registrationId = request.getClientRegistration().getRegistrationId(); + OAuthClaimsExtractor extractor = extractors.get(registrationId); + if (extractor == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null) + ); + } + return new LoadedProviderIdentity( + upstreamUser, + extractor.extract(request, upstreamUser) ); - } + }); - OAuthClaims claims = extractor.extract(request, upstreamUser); - PlatformPrincipal principal = authenticate(claims); - return new AuthenticatedLoginContext(upstreamUser, principal); + PlatformPrincipal principal = authenticate(loadedIdentity.claims()); + return new AuthenticatedLoginContext(loadedIdentity.upstreamUser(), principal); } public PlatformPrincipal authenticate(OAuthClaims claims) { AccessDecision decision = accessPolicy.evaluate(claims); if (decision == AccessDecision.PENDING_APPROVAL) { + LegacyPlatformIdentityDecision identityDecision = identityCore.evaluate(claims); + ensureActiveCoreAllowsPlatformLogin(identityDecision); identityBindingService.createPendingUserIfAbsent(claims); throw new AccountPendingException(); } @@ -72,9 +128,32 @@ public class OAuthLoginFlowService { ); } + LegacyPlatformIdentityDecision identityDecision = identityCore.evaluate(claims); + ensureActiveCoreAllowsPlatformLogin(identityDecision); return identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE); } + private static void ensureActiveCoreAllowsPlatformLogin(LegacyPlatformIdentityDecision decision) { + Objects.requireNonNull(decision, "identity decision must not be null"); + if (decision.mode() != IdentityCoreMode.ACTIVE) { + return; + } + IdentityLoginResolution resolution = decision.resolution() + .orElseThrow(() -> new OAuthIdentityCoreException( + new IllegalStateException("ACTIVE mode requires a unified identity resolution") + )); + if (resolution instanceof IdentityLoginResolution.Matched matched + && matched.target().userId().isPresent()) { + return; + } + if (resolution instanceof IdentityLoginResolution.ProvisionNew) { + return; + } + throw new OAuthIdentityCoreException( + new IllegalStateException("Unified identity denied platform OAuth login") + ); + } + public void rememberReturnTo(HttpServletRequest request) { String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(request.getParameter("returnTo")); HttpSession session = request.getSession(); @@ -103,6 +182,9 @@ public class OAuthLoginFlowService { || exception instanceof SystemAccountLoginException) { return "/access-denied"; } + if (exception instanceof OAuthIdentityCoreException) { + return "/access-denied"; + } if (exception instanceof OAuth2AuthenticationException oauth2Exception && "access_denied".equals(oauth2Exception.getError().getErrorCode())) { return "/access-denied"; @@ -115,4 +197,16 @@ public class OAuthLoginFlowService { public record AuthenticatedLoginContext(OAuth2User upstreamUser, PlatformPrincipal principal) { } + + private record LoadedProviderIdentity(OAuth2User upstreamUser, OAuthClaims claims) { + } + + private static RemoteIdentityIoExecutor directRemoteIdentityIo() { + return new RemoteIdentityIoExecutor() { + @Override + public T execute(java.util.function.Supplier remoteIo) { + return remoteIo.get(); + } + }; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java index feb60816..14b8115a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java @@ -6,7 +6,7 @@ package com.iflytek.skillhub.auth.oauth; public final class OAuthLoginRedirectSupport { public static final String SESSION_RETURN_TO_ATTRIBUTE = "skillhub.oauth.returnTo"; - public static final String DEFAULT_TARGET_URL = "/dashboard"; + public static final String DEFAULT_TARGET_URL = "/"; private OAuthLoginRedirectSupport() { } @@ -19,7 +19,8 @@ public final class OAuthLoginRedirectSupport { if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { return null; } - if (trimmed.contains("\r") || trimmed.contains("\n")) { + if (trimmed.indexOf('\\') >= 0 + || trimmed.codePoints().anyMatch(Character::isISOControl)) { return null; } return trimmed; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 132ca515..4603ad11 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -26,6 +26,8 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/me"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/session/bootstrap"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"), + RouteAuthorizationPolicy.permitAll(null, "/oauth2/authorization/**"), + RouteAuthorizationPolicy.permitAll(null, "/login/oauth2/**"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapterConformanceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapterConformanceTest.java new file mode 100644 index 00000000..a209b85b --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapterConformanceTest.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.auth.federation.adapter; + +import com.iflytek.skillhub.auth.federation.conformance.AuthenticationAdapterConformance; +import com.iflytek.skillhub.auth.federation.core.VerifiedAuthenticationFactsAdapter; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; + +class LegacyOAuthVerifiedFactsAdapterConformanceTest + extends AuthenticationAdapterConformance { + + private static final Instant NOW = Instant.parse("2026-09-07T15:00:00Z"); + + private final LegacyOAuthVerifiedFactsAdapter adapter = new LegacyOAuthVerifiedFactsAdapter( + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + @Override + protected VerifiedAuthenticationFactsAdapter adapter() { + return adapter; + } + + @Override + protected OAuthClaims validFacts() { + return new OAuthClaims( + "github", + "subject-1", + "Alice@Example.com", + true, + "alice", + Map.of("iss", "https://github.example/issuer") + ); + } + + @Override + protected OAuthClaims factsWithoutVerifiedEmail() { + return new OAuthClaims( + "github", + "subject-1", + "unverified@example.com", + false, + "alice", + Map.of("iss", "https://github.example/issuer") + ); + } + + @Override + protected OAuthClaims factsContainingSecret(String secret) { + return new OAuthClaims( + "github", + "subject-1", + "alice@example.com", + true, + "alice", + Map.of( + "iss", "https://github.example/issuer", + "access_token", secret, + "client_secret", secret + ) + ); + } + + @Override + protected OAuthClaims invalidFactsContainingSecret(String secret) { + return new OAuthClaims( + secret + ":", + "subject-1", + "alice@example.com", + true, + "alice", + Map.of("access_token", secret) + ); + } + + @Override + protected ExpectedIdentity expectedIdentity() { + return new ExpectedIdentity( + LegacyOAuthIdentityCoordinate.connectionId("github"), + URI.create("urn:skillhub:legacy-oauth"), + "legacy-oauth-subject", + "subject-1", + "alice@example.com", + "email_verified" + ); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapterTest.java new file mode 100644 index 00000000..18ee0a08 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/LegacyOAuthVerifiedFactsAdapterTest.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.auth.federation.adapter; + +import com.iflytek.skillhub.auth.federation.core.Assurance; +import com.iflytek.skillhub.auth.federation.core.IdentityAssertion; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LegacyOAuthVerifiedFactsAdapterTest { + + private static final Instant NOW = Instant.parse("2026-09-07T14:00:00Z"); + + private final LegacyOAuthVerifiedFactsAdapter adapter = new LegacyOAuthVerifiedFactsAdapter( + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + @Test + void mapsPublicOAuthClaimsToPlatformScopedLegacyConnection() { + OAuthClaims claims = new OAuthClaims( + "github", + "subject-1", + "Alice@Example.com", + true, + "alice", + Map.of("avatar_url", "https://avatars.example/alice.png") + ); + + IdentityAssertion assertion = adapter.toAssertion(claims); + + assertThat(assertion.organizationId()).isEmpty(); + assertThat(assertion.connectionId()) + .isEqualTo(LegacyOAuthIdentityCoordinate.connectionId("github")); + assertThat(assertion.issuer()).isEqualTo(URI.create("urn:skillhub:legacy-oauth")); + assertThat(assertion.subject().type().value()).isEqualTo("legacy-oauth-subject"); + assertThat(assertion.subject().value()).isEqualTo("subject-1"); + assertThat(assertion.email()).get().extracting("value").isEqualTo("alice@example.com"); + assertThat(assertion.loginName()).contains("alice"); + assertThat(assertion.displayName()).contains("alice"); + assertThat(assertion.avatarUrl()).contains("https://avatars.example/alice.png"); + assertThat(assertion.assurance()).containsExactly(new Assurance("email_verified")); + assertThat(assertion.authenticatedAt()).isEqualTo(NOW); + assertThat(assertion.attributes()).isEmpty(); + } + + @Test + void keepsLegacyIssuerStableWithoutForwardingRawClaims() { + OAuthClaims claims = new OAuthClaims( + "corp-oidc", + "subject-2", + null, + false, + "bob", + Map.of( + "iss", "https://idp.example/tenant", + "access_token", "must-not-cross-core", + "groups", java.util.List.of("admins") + ) + ); + + IdentityAssertion assertion = adapter.toAssertion(claims); + + assertThat(assertion.issuer()).isEqualTo(URI.create("urn:skillhub:legacy-oauth")); + assertThat(assertion.email()).isEmpty(); + assertThat(assertion.assurance()).isEmpty(); + assertThat(assertion.attributes()).isEmpty(); + assertThat(assertion.toString()).doesNotContain("must-not-cross-core", "admins"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/RemoteIdentityIoExecutorIntegrationTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/RemoteIdentityIoExecutorIntegrationTest.java new file mode 100644 index 00000000..0962c124 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/adapter/RemoteIdentityIoExecutorIntegrationTest.java @@ -0,0 +1,100 @@ +package com.iflytek.skillhub.auth.federation.adapter; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +class RemoteIdentityIoExecutorIntegrationTest { + + @Test + void execute_suspendsAmbientTransactionForSimulatedMetadataTokenAndUserinfoIo() { + TrackingTransactionManager transactionManager = new TrackingTransactionManager(); + RemoteIdentityIoExecutor executor = new TransactionSuspendingRemoteIdentityIoExecutor( + transactionManager + ); + TransactionTemplate ambientTransaction = new TransactionTemplate(transactionManager); + AtomicInteger completedRemoteCalls = new AtomicInteger(); + + ambientTransaction.executeWithoutResult(ignored -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); + + for (String phase : List.of("metadata", "token", "userinfo")) { + String result = executor.execute(() -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()) + .as("%s I/O must run outside a database transaction", phase) + .isFalse(); + completedRemoteCalls.incrementAndGet(); + return phase; + }); + assertThat(result).isEqualTo(phase); + assertThat(TransactionSynchronizationManager.isActualTransactionActive()) + .as("the caller transaction must be restored after %s I/O", phase) + .isTrue(); + } + }); + + assertThat(completedRemoteCalls).hasValue(3); + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + } + + private static final class TrackingTransactionManager extends AbstractPlatformTransactionManager { + + private final ThreadLocal current = new ThreadLocal<>(); + + @Override + protected Object doGetTransaction() { + TransactionObject transaction = current.get(); + return transaction == null ? new TransactionObject() : transaction; + } + + @Override + protected boolean isExistingTransaction(Object transaction) { + return ((TransactionObject) transaction).active; + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + TransactionObject transactionObject = (TransactionObject) transaction; + transactionObject.active = true; + current.set(transactionObject); + } + + @Override + protected Object doSuspend(Object transaction) { + current.remove(); + return transaction; + } + + @Override + protected void doResume(Object transaction, Object suspendedResources) { + current.set((TransactionObject) suspendedResources); + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + // The test manager only models Spring's thread-bound transaction lifecycle. + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + // The test manager only models Spring's thread-bound transaction lifecycle. + } + + @Override + protected void doCleanupAfterCompletion(Object transaction) { + ((TransactionObject) transaction).active = false; + current.remove(); + } + } + + private static final class TransactionObject { + private boolean active; + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/association/IdentityAssociationModelTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/association/IdentityAssociationModelTest.java new file mode 100644 index 00000000..cc55b86d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/association/IdentityAssociationModelTest.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.auth.federation.association; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.core.SubjectRef; +import com.iflytek.skillhub.auth.federation.core.SubjectType; +import java.net.URI; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class IdentityAssociationModelTest { + + private static final Instant NOW = Instant.parse("2026-09-08T09:00:00Z"); + + @Test + void persistenceModelsPreserveTheOpaqueCaseSensitiveSubjectExactly() { + ExternalIdentityCoordinate coordinate = new ExternalIdentityCoordinate( + Optional.of("organization-1"), + "connection-1", + URI.create("https://identity.example.com"), + new SubjectRef(new SubjectType("oidc-sub"), " Employee-42 ") + ); + + ExternalIdentity identity = ExternalIdentity.bind(coordinate, "user-1", NOW); + PreProvisionedLoginSubject claim = PreProvisionedLoginSubject.claim( + "organization-1", + "membership-1", + coordinate, + NOW + ); + + assertThat(identity.coordinate()).isEqualTo(coordinate); + assertThat(claim.coordinate()).isEqualTo(coordinate); + } + + @Test + void revocationIsTerminalAndIdempotent() { + ExternalIdentityCoordinate coordinate = new ExternalIdentityCoordinate( + Optional.of("organization-1"), + "connection-1", + URI.create("https://identity.example.com"), + new SubjectRef(new SubjectType("oidc-sub"), "employee-42") + ); + PreProvisionedLoginSubject claim = PreProvisionedLoginSubject.claim( + "organization-1", + "membership-1", + coordinate, + NOW + ); + + claim.revoke(NOW.plusSeconds(1)); + claim.revoke(NOW.plusSeconds(2)); + + assertThat(claim.isActive()).isFalse(); + assertThat(claim.getRevokedAt()).isEqualTo(NOW.plusSeconds(1)); + assertThat(claim.getUpdatedAt()).isEqualTo(NOW.plusSeconds(1)); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/config/ConfiguredIdentityCoreActivationTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/config/ConfiguredIdentityCoreActivationTest.java new file mode 100644 index 00000000..67ffd00f --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/config/ConfiguredIdentityCoreActivationTest.java @@ -0,0 +1,82 @@ +package com.iflytek.skillhub.auth.federation.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.federation.association.ExternalIdentityRepository; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class ConfiguredIdentityCoreActivationTest { + + @Test + void defaultsToLegacyForPlatformAndEveryOrganization() { + ConfiguredIdentityCoreActivation activation = activation( + new IdentityCoreProperties(), + new EnterpriseIdentityRolloutProperties() + ); + + assertThat(activation.modeFor(Optional.empty())).isEqualTo(IdentityCoreMode.LEGACY); + assertThat(activation.modeFor(Optional.of("org_1"))).isEqualTo(IdentityCoreMode.LEGACY); + } + + @Test + void organizationMustBeExplicitlyAllowlistedEvenWhenCoreModeIsActive() { + IdentityCoreProperties core = new IdentityCoreProperties(); + core.setMode(IdentityCoreMode.ACTIVE); + EnterpriseIdentityRolloutProperties rollout = new EnterpriseIdentityRolloutProperties(); + rollout.setOrganizationAllowlist(Set.of("org_allowed")); + ConfiguredIdentityCoreActivation activation = activation(core, rollout); + + assertThat(activation.modeFor(Optional.empty())).isEqualTo(IdentityCoreMode.ACTIVE); + assertThat(activation.modeFor(Optional.of("org_allowed"))).isEqualTo(IdentityCoreMode.ACTIVE); + assertThat(activation.modeFor(Optional.of("org_other"))).isEqualTo(IdentityCoreMode.LEGACY); + } + + @Test + void shadowModeIsAlsoLimitedToAllowlistedOrganizations() { + IdentityCoreProperties core = new IdentityCoreProperties(); + core.setMode(IdentityCoreMode.SHADOW); + EnterpriseIdentityRolloutProperties rollout = new EnterpriseIdentityRolloutProperties(); + rollout.setOrganizationAllowlist(Set.of(" org_shadow ")); + ConfiguredIdentityCoreActivation activation = activation(core, rollout); + + assertThat(activation.modeFor(Optional.of("org_shadow"))).isEqualTo(IdentityCoreMode.SHADOW); + assertThat(activation.modeFor(Optional.of("org_other"))).isEqualTo(IdentityCoreMode.LEGACY); + } + + @Test + void bindsDocumentedPropertyNamesAndPublishesActivationPort() { + new ApplicationContextRunner() + .withUserConfiguration(EnterpriseIdentityCoreConfiguration.class) + .withBean(ExternalIdentityRepository.class, + () -> mock(ExternalIdentityRepository.class)) + .withBean(IdentityBindingRepository.class, + () -> mock(IdentityBindingRepository.class)) + .withPropertyValues( + "skillhub.identity.core.mode=SHADOW", + "skillhub.enterprise.organization-allowlist=org_1,org_2" + ) + .run(context -> { + assertThat(context).hasSingleBean( + com.iflytek.skillhub.auth.federation.core.IdentityCoreActivation.class + ); + var activation = context.getBean( + com.iflytek.skillhub.auth.federation.core.IdentityCoreActivation.class + ); + assertThat(activation.modeFor(Optional.of("org_1"))).isEqualTo(IdentityCoreMode.SHADOW); + assertThat(activation.modeFor(Optional.of("org_3"))).isEqualTo(IdentityCoreMode.LEGACY); + }); + } + + private static ConfiguredIdentityCoreActivation activation( + IdentityCoreProperties core, + EnterpriseIdentityRolloutProperties rollout + ) { + return new ConfiguredIdentityCoreActivation(core, rollout); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/AuthenticationAdapterConformance.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/AuthenticationAdapterConformance.java new file mode 100644 index 00000000..5ec9f753 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/AuthenticationAdapterConformance.java @@ -0,0 +1,125 @@ +package com.iflytek.skillhub.auth.federation.conformance; + +import com.iflytek.skillhub.auth.federation.core.Assurance; +import com.iflytek.skillhub.auth.federation.core.AuthenticationAdapterException; +import com.iflytek.skillhub.auth.federation.core.AuthenticationAdapterFailureReason; +import com.iflytek.skillhub.auth.federation.core.IdentityAssertion; +import com.iflytek.skillhub.auth.federation.core.VerifiedAuthenticationFactsAdapter; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Executable Interface shared by every adapter that emits verified identity facts. */ +public abstract class AuthenticationAdapterConformance { + + @Test + void verifiedFactsProduceStableProtocolNeutralIdentity() { + IdentityAssertion first = adapter().toAssertion(validFacts()); + IdentityAssertion second = adapter().toAssertion(validFacts()); + ExpectedIdentity expected = expectedIdentity(); + + assertThat(first.connectionId()).isEqualTo(second.connectionId()); + assertThat(first.issuer()).isEqualTo(second.issuer()); + assertThat(first.subject()).isEqualTo(second.subject()); + assertThat(first.connectionId()).isEqualTo(expected.connectionId()); + assertThat(first.issuer()).isEqualTo(expected.issuer()); + assertThat(first.subject().type().value()).isEqualTo(expected.subjectType()); + assertThat(first.subject().value()).isEqualTo(expected.subject()); + assertThat(first.email()).get().extracting("value").isEqualTo(expected.verifiedEmail()); + assertThat(first.assurance()).contains(new Assurance(expected.emailAssurance())); + } + + @Test + void unverifiedEmailIsNotPromotedToAVerifiedFact() { + IdentityAssertion assertion = adapter().toAssertion(factsWithoutVerifiedEmail()); + + assertThat(assertion.email()).isEmpty(); + assertThat(assertion.assurance()) + .noneMatch(assurance -> assurance.value().equals("email_verified")); + } + + @Test + void rawSecretsNeverCrossTheAssertionInterface() { + String secret = "secret-material-that-must-not-cross"; + + IdentityAssertion assertion = adapter().toAssertion(factsContainingSecret(secret)); + + assertThat(assertion.toString()).doesNotContain(secret); + } + + @Test + void invalidFactsProduceAStandardRedactedError() { + String secret = "invalid-secret-that-must-not-leak"; + + assertThatThrownBy(() -> adapter().toAssertion(invalidFactsContainingSecret(secret))) + .isInstanceOfSatisfying(AuthenticationAdapterException.class, failure -> { + assertThat(failure.reason()) + .isEqualTo(AuthenticationAdapterFailureReason.INVALID_ASSERTION); + assertThat(failure.getMessage()) + .isEqualTo(AuthenticationAdapterFailureReason.INVALID_ASSERTION.publicMessage()) + .doesNotContain(secret); + assertThat(failure.getCause()).isNull(); + assertThat(failure.toString()).doesNotContain(secret); + }); + } + + @Test + void adapterInterfaceAndImplementationExposeNoPlatformStateSideEffects() { + Set forbiddenTypeFragments = Set.of( + "UserAccount", + "OrganizationMembership", + "NamespaceMember", + "IdentityBindingRepository", + "PlatformPrincipal", + "SessionRepository" + ); + Method[] interfaceMethods = VerifiedAuthenticationFactsAdapter.class.getDeclaredMethods(); + + assertThat(interfaceMethods).hasSize(1); + assertThat(interfaceMethods[0].getReturnType()).isEqualTo(IdentityAssertion.class); + + Stream> exposedInterfaceTypes = Arrays.stream(interfaceMethods) + .flatMap(method -> Stream.concat( + Stream.of(method.getReturnType()), + Arrays.stream(method.getParameterTypes()) + )); + Stream> implementationDependencyTypes = Stream.concat( + Arrays.stream(adapter().getClass().getDeclaredFields()).map(field -> field.getType()), + Arrays.stream(adapter().getClass().getDeclaredConstructors()) + .flatMap((Constructor constructor) -> Arrays.stream(constructor.getParameterTypes())) + ); + + assertThat(Stream.concat(exposedInterfaceTypes, implementationDependencyTypes) + .map(Class::getName)) + .noneMatch(name -> forbiddenTypeFragments.stream().anyMatch(name::contains)); + } + + protected abstract VerifiedAuthenticationFactsAdapter adapter(); + + protected abstract F validFacts(); + + protected abstract F factsWithoutVerifiedEmail(); + + protected abstract F factsContainingSecret(String secret); + + protected abstract F invalidFactsContainingSecret(String secret); + + protected abstract ExpectedIdentity expectedIdentity(); + + public record ExpectedIdentity( + String connectionId, + URI issuer, + String subjectType, + String subject, + String verifiedEmail, + String emailAssurance + ) { + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/TestVerifiedAuthenticationFactsAdapterConformanceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/TestVerifiedAuthenticationFactsAdapterConformanceTest.java new file mode 100644 index 00000000..4b618b4c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/TestVerifiedAuthenticationFactsAdapterConformanceTest.java @@ -0,0 +1,85 @@ +package com.iflytek.skillhub.auth.federation.conformance; + +import com.iflytek.skillhub.auth.federation.core.Assurance; +import com.iflytek.skillhub.auth.federation.core.AuthenticationAdapterException; +import com.iflytek.skillhub.auth.federation.core.AuthenticationAdapterFailureReason; +import com.iflytek.skillhub.auth.federation.core.IdentityAssertion; +import com.iflytek.skillhub.auth.federation.core.SubjectRef; +import com.iflytek.skillhub.auth.federation.core.SubjectType; +import com.iflytek.skillhub.auth.federation.core.VerifiedAuthenticationFactsAdapter; +import com.iflytek.skillhub.auth.federation.core.VerifiedEmail; +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +class TestVerifiedAuthenticationFactsAdapterConformanceTest + extends AuthenticationAdapterConformance { + + private static final URI ISSUER = URI.create("https://test-idp.example/issuer"); + private static final Instant AUTHENTICATED_AT = Instant.parse("2026-09-07T15:10:00Z"); + + private final VerifiedAuthenticationFactsAdapter adapter = facts -> { + if (facts.subject().isBlank()) { + throw new AuthenticationAdapterException(AuthenticationAdapterFailureReason.INVALID_ASSERTION); + } + return new IdentityAssertion( + Optional.of("org_test"), + "connection_test", + ISSUER, + new SubjectRef(new SubjectType("test-subject"), facts.subject()), + facts.emailVerified() + ? Optional.of(new VerifiedEmail(facts.email())) + : Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + facts.emailVerified() + ? Set.of(new Assurance("email_verified")) + : Set.of(), + AUTHENTICATED_AT, + Map.of() + ); + }; + + @Override + protected VerifiedAuthenticationFactsAdapter adapter() { + return adapter; + } + + @Override + protected TestFacts validFacts() { + return new TestFacts("fake-subject-1", "fake@example.com", true, null); + } + + @Override + protected TestFacts factsWithoutVerifiedEmail() { + return new TestFacts("fake-subject-1", "unverified@example.com", false, null); + } + + @Override + protected TestFacts factsContainingSecret(String secret) { + return new TestFacts("fake-subject-1", "fake@example.com", true, secret); + } + + @Override + protected TestFacts invalidFactsContainingSecret(String secret) { + return new TestFacts(" ", "fake@example.com", true, secret); + } + + @Override + protected ExpectedIdentity expectedIdentity() { + return new ExpectedIdentity( + "connection_test", + ISSUER, + "test-subject", + "fake-subject-1", + "fake@example.com", + "email_verified" + ); + } + + record TestFacts(String subject, String email, boolean emailVerified, String secret) { + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/package-info.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/package-info.java new file mode 100644 index 00000000..aae5f61a --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/conformance/package-info.java @@ -0,0 +1,2 @@ +/** Shared executable conformance fixtures for enterprise identity adapters. */ +package com.iflytek.skillhub.auth.federation.conformance; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterFailureReasonTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterFailureReasonTest.java new file mode 100644 index 00000000..b966ac2b --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/AuthenticationAdapterFailureReasonTest.java @@ -0,0 +1,32 @@ +package com.iflytek.skillhub.auth.federation.core; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuthenticationAdapterFailureReasonTest { + + @Test + void taxonomyHasStablePrivacySafeReasonsAndRetrySemantics() { + assertThat(AuthenticationAdapterFailureReason.values()).containsExactly( + AuthenticationAdapterFailureReason.INVALID_ASSERTION, + AuthenticationAdapterFailureReason.AUTHENTICATION_DENIED, + AuthenticationAdapterFailureReason.UPSTREAM_UNAVAILABLE, + AuthenticationAdapterFailureReason.CONNECTION_MISCONFIGURED + ); + assertThat(Map.of( + AuthenticationAdapterFailureReason.INVALID_ASSERTION, false, + AuthenticationAdapterFailureReason.AUTHENTICATION_DENIED, false, + AuthenticationAdapterFailureReason.UPSTREAM_UNAVAILABLE, true, + AuthenticationAdapterFailureReason.CONNECTION_MISCONFIGURED, false + )).allSatisfy((reason, retryable) -> { + AuthenticationAdapterException failure = new AuthenticationAdapterException(reason); + + assertThat(failure.reason()).isEqualTo(reason); + assertThat(failure.retryable()).isEqualTo(retryable); + assertThat(failure.getMessage()).isEqualTo(reason.publicMessage()); + assertThat(failure.getCause()).isNull(); + }); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityLoginModuleTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityLoginModuleTest.java new file mode 100644 index 00000000..14c3285d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/ExternalIdentityLoginModuleTest.java @@ -0,0 +1,212 @@ +package com.iflytek.skillhub.auth.federation.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ExternalIdentityLoginModuleTest { + + private static final IdentityCorrelationTarget BOUND_TARGET = + IdentityCorrelationTarget.linked("usr_bound", "member_bound"); + private static final IdentityCorrelationTarget PROVISIONED_TARGET = + IdentityCorrelationTarget.membership("member_provisioned"); + private static final IdentityCorrelationTarget EMAIL_TARGET = + IdentityCorrelationTarget.linked("usr_email", "member_email"); + + @Test + void exactBindingWinsWithoutConsultingWeakerCorrelationStages() { + RecordingCandidates candidates = new RecordingCandidates(); + candidates.exact = List.of(BOUND_TARGET); + ExternalIdentityLoginModule module = module(candidates, permissivePolicy()); + + IdentityLoginResolution resolution = module.resolve(assertion(true)); + + assertThat(resolution).isEqualTo(new IdentityLoginResolution.Matched( + IdentityCorrelationStage.EXACT_BINDING, + BOUND_TARGET + )); + assertThat(candidates.calls).containsExactly("exact"); + } + + @Test + void preProvisionedImmutableSubjectWinsBeforeVerifiedEmail() { + RecordingCandidates candidates = new RecordingCandidates(); + candidates.preProvisioned = List.of(PROVISIONED_TARGET); + ExternalIdentityLoginModule module = module(candidates, permissivePolicy()); + + IdentityLoginResolution resolution = module.resolve(assertion(true)); + + assertThat(resolution).isEqualTo(new IdentityLoginResolution.Matched( + IdentityCorrelationStage.PRE_PROVISIONED_SUBJECT, + PROVISIONED_TARGET + )); + assertThat(candidates.calls).containsExactly("exact", "pre-provisioned"); + } + + @Test + void verifiedEmailIsUsedOnlyAfterStrongerStagesMissAndPolicyAllowsIt() { + RecordingCandidates candidates = new RecordingCandidates(); + candidates.verifiedEmail = List.of(EMAIL_TARGET); + ExternalIdentityLoginModule module = module(candidates, permissivePolicy()); + + IdentityLoginResolution resolution = module.resolve(assertion(true)); + + assertThat(resolution).isEqualTo(new IdentityLoginResolution.Matched( + IdentityCorrelationStage.VERIFIED_EMAIL, + EMAIL_TARGET + )); + assertThat(candidates.calls).containsExactly("exact", "pre-provisioned", "verified-email"); + } + + @Test + void ambiguousCandidatesStopAtTheStageThatDetectedTheConflict() { + RecordingCandidates candidates = new RecordingCandidates(); + candidates.verifiedEmail = List.of( + EMAIL_TARGET, + IdentityCorrelationTarget.linked("usr_other", "member_other") + ); + ExternalIdentityLoginModule module = module(candidates, permissivePolicy()); + + IdentityLoginResolution resolution = module.resolve(assertion(true)); + + assertThat(resolution).isEqualTo(new IdentityLoginResolution.Conflict( + IdentityCorrelationStage.VERIFIED_EMAIL, + 2 + )); + assertThat(candidates.calls).containsExactly("exact", "pre-provisioned", "verified-email"); + } + + @Test + void noSafeCandidateIsDeniedWhenJitProvisioningIsNotAllowed() { + RecordingCandidates candidates = new RecordingCandidates(); + IdentityCorrelationPolicy policy = policy(true, false); + ExternalIdentityLoginModule module = module(candidates, policy); + + IdentityLoginResolution resolution = module.resolve(assertion(true)); + + assertThat(resolution).isEqualTo(new IdentityLoginResolution.Denied( + IdentityLoginDenialReason.NO_SAFE_MATCH + )); + } + + @Test + void noSafeCandidateRequestsProvisioningOnlyWhenPolicyExplicitlyAllowsIt() { + RecordingCandidates candidates = new RecordingCandidates(); + ExternalIdentityLoginModule module = module(candidates, permissivePolicy()); + + IdentityLoginResolution resolution = module.resolve(assertion(true)); + + assertThat(resolution).isEqualTo(new IdentityLoginResolution.ProvisionNew()); + } + + @Test + void absentVerifiedEmailSkipsEmailLookupEvenWhenPolicyAllowsCorrelation() { + RecordingCandidates candidates = new RecordingCandidates(); + ExternalIdentityLoginModule module = module(candidates, policy(true, false)); + + IdentityLoginResolution resolution = module.resolve(assertion(false)); + + assertThat(resolution).isEqualTo(new IdentityLoginResolution.Denied( + IdentityLoginDenialReason.NO_SAFE_MATCH + )); + assertThat(candidates.calls).containsExactly("exact", "pre-provisioned"); + } + + @Test + void legacyModeFailsBeforeAnyCorrelationLookup() { + RecordingCandidates candidates = new RecordingCandidates(); + ExternalIdentityLoginModule module = new ExternalIdentityLoginModule( + organizationId -> IdentityCoreMode.LEGACY, + candidates, + permissivePolicy() + ); + + assertThatThrownBy(() -> module.resolve(assertion(true))) + .isInstanceOf(IdentityCoreDisabledException.class); + assertThat(candidates.calls).isEmpty(); + } + + private static ExternalIdentityLoginModule module( + IdentityCorrelationCandidates candidates, + IdentityCorrelationPolicy policy + ) { + return new ExternalIdentityLoginModule( + organizationId -> IdentityCoreMode.ACTIVE, + candidates, + policy + ); + } + + private static IdentityCorrelationPolicy permissivePolicy() { + return policy(true, true); + } + + private static IdentityCorrelationPolicy policy(boolean email, boolean jit) { + return new IdentityCorrelationPolicy() { + @Override + public boolean allowsVerifiedEmailCorrelation(IdentityAssertion assertion) { + return email; + } + + @Override + public boolean allowsJitProvisioning(IdentityAssertion assertion) { + return jit; + } + }; + } + + private static IdentityAssertion assertion(boolean withVerifiedEmail) { + return new IdentityAssertion( + Optional.of("org_1"), + "connection_1", + URI.create("https://identity.example.com"), + new SubjectRef(new SubjectType("opaque-user-id"), "subject-123"), + withVerifiedEmail + ? Optional.of(new VerifiedEmail("alice@example.com")) + : Optional.empty(), + Optional.of("alice"), + Optional.of("Alice"), + Optional.empty(), + Set.of(new Assurance("single-factor")), + Instant.parse("2026-09-07T12:00:00Z"), + Map.of() + ); + } + + private static final class RecordingCandidates implements IdentityCorrelationCandidates { + + private final List calls = new ArrayList<>(); + private List exact = List.of(); + private List preProvisioned = List.of(); + private List verifiedEmail = List.of(); + + @Override + public List findExactBinding(ExternalIdentityCoordinate coordinate) { + calls.add("exact"); + return exact; + } + + @Override + public List findPreProvisionedSubject(ExternalIdentityCoordinate coordinate) { + calls.add("pre-provisioned"); + return preProvisioned; + } + + @Override + public List findByVerifiedEmail( + String organizationId, + VerifiedEmail email + ) { + calls.add("verified-email"); + return verifiedEmail; + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityAssertionTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityAssertionTest.java new file mode 100644 index 00000000..c1d56f30 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityAssertionTest.java @@ -0,0 +1,147 @@ +package com.iflytek.skillhub.auth.federation.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.RecordComponent; +import java.net.URI; +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class IdentityAssertionTest { + + @Test + void createsImmutableProtocolNeutralAssertion() { + Set assurance = new HashSet<>(Set.of(new Assurance("multi-factor"))); + AttributeKey department = new AttributeKey("profile.department"); + Map attributes = new HashMap<>(Map.of( + department, + new NormalizedAttribute( + department, + new AttributeValue.Text("Research"), + Set.of(new Assurance("directory-verified")) + ) + )); + + IdentityAssertion assertion = new IdentityAssertion( + Optional.of("org_1"), + "connection_1", + URI.create("https://identity.example.com"), + new SubjectRef(new SubjectType("opaque-user-id"), "subject-123"), + Optional.of(new VerifiedEmail("Alice@Example.com")), + Optional.of("alice"), + Optional.of("Alice"), + Optional.empty(), + assurance, + Instant.parse("2026-09-07T12:00:00Z"), + attributes + ); + + assurance.clear(); + attributes.clear(); + + assertThat(assertion.organizationId()).contains("org_1"); + assertThat(assertion.email()).map(VerifiedEmail::value).contains("alice@example.com"); + assertThat(assertion.assurance()).containsExactly(new Assurance("multi-factor")); + assertThat(assertion.attributes()).containsOnlyKeys(department); + assertThatThrownBy(() -> assertion.assurance().add(new Assurance("single-factor"))) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> assertion.attributes().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void rejectsMissingConnection() { + assertThatThrownBy(() -> validAssertion(" ", URI.create("https://identity.example.com"), subject())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("connectionId"); + } + + @Test + void rejectsMissingOrRelativeIssuer() { + assertThatThrownBy(() -> validAssertion("connection_1", null, subject())) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("issuer"); + assertThatThrownBy(() -> validAssertion("connection_1", URI.create("/tenant"), subject())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("issuer"); + } + + @Test + void rejectsMissingOrBlankSubject() { + assertThatThrownBy(() -> validAssertion( + "connection_1", URI.create("https://identity.example.com"), null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("subject"); + assertThatThrownBy(() -> new SubjectRef(new SubjectType("opaque-user-id"), " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("subject value"); + assertThatThrownBy(() -> new SubjectType(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("subject type"); + } + + @Test + void rejectsMismatchedAttributeMapKey() { + AttributeKey mapKey = new AttributeKey("profile.department"); + AttributeKey attributeKey = new AttributeKey("profile.cost-center"); + + assertThatThrownBy(() -> new IdentityAssertion( + Optional.empty(), + "connection_1", + URI.create("urn:skillhub:legacy"), + subject(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Set.of(), + Instant.parse("2026-09-07T12:00:00Z"), + Map.of(mapKey, new NormalizedAttribute( + attributeKey, + new AttributeValue.Text("cc-1"), + Set.of() + )) + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attribute key"); + } + + @Test + void publicContractHasNoRawCredentialOrTokenComponents() { + assertThat(IdentityAssertion.class.getRecordComponents()) + .extracting(RecordComponent::getName) + .doesNotContain( + "accessToken", "idToken", "refreshToken", "rawToken", + "rawClaims", "rawAssertion", "credential", "password" + ); + } + + private static IdentityAssertion validAssertion( + String connectionId, + URI issuer, + SubjectRef subject + ) { + return new IdentityAssertion( + Optional.empty(), + connectionId, + issuer, + subject, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Set.of(), + Instant.parse("2026-09-07T12:00:00Z"), + Map.of() + ); + } + + private static SubjectRef subject() { + return new SubjectRef(new SubjectType("opaque-user-id"), "subject-123"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicySettingsTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicySettingsTest.java new file mode 100644 index 00000000..38c1f4b4 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityCorrelationPolicySettingsTest.java @@ -0,0 +1,64 @@ +package com.iflytek.skillhub.auth.federation.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class IdentityCorrelationPolicySettingsTest { + + @Test + void defaultPolicyDisablesEmailCorrelationAndJit() { + IdentityCorrelationPolicySettings policy = IdentityCorrelationPolicySettings.disabled(); + + assertThat(policy.allowsVerifiedEmailCorrelation(assertion(true, true))).isFalse(); + assertThat(policy.allowsJitProvisioning(assertion(true, true))).isFalse(); + } + + @Test + void verifiedEmailCorrelationRequiresBothTrustedEmailAndAssurance() { + IdentityCorrelationPolicySettings policy = + IdentityCorrelationPolicySettings.verifiedEmailOnly(); + + assertThat(policy.allowsVerifiedEmailCorrelation(assertion(true, true))).isTrue(); + assertThat(policy.allowsVerifiedEmailCorrelation(assertion(true, false))).isFalse(); + assertThat(policy.allowsVerifiedEmailCorrelation(assertion(false, true))).isFalse(); + } + + @Test + void jitRequiresEmailCorrelationAndTheSameTrustedSignal() { + assertThatThrownBy(() -> new IdentityCorrelationPolicySettings(false, true)) + .isInstanceOf(IllegalArgumentException.class); + + IdentityCorrelationPolicySettings policy = + IdentityCorrelationPolicySettings.verifiedEmailWithJit(); + assertThat(policy.allowsJitProvisioning(assertion(true, true))).isTrue(); + assertThat(policy.allowsJitProvisioning(assertion(true, false))).isFalse(); + assertThat(policy.allowsJitProvisioning(assertion(false, true))).isFalse(); + } + + private static IdentityAssertion assertion(boolean email, boolean assurance) { + return new IdentityAssertion( + Optional.of("organization-1"), + "connection-1", + URI.create("https://identity.example.com"), + new SubjectRef(new SubjectType("oidc-sub"), "employee-42"), + email + ? Optional.of(new VerifiedEmail("alice@example.com")) + : Optional.empty(), + Optional.of("alice"), + Optional.of("Alice Example"), + Optional.empty(), + assurance + ? Set.of(new Assurance("email_verified")) + : Set.of(new Assurance("oidc")), + Instant.parse("2026-09-08T10:00:00Z"), + Map.of() + ); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuardTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuardTest.java new file mode 100644 index 00000000..955345cc --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/IdentityLoginGuardTest.java @@ -0,0 +1,219 @@ +package com.iflytek.skillhub.auth.federation.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.domain.user.UserStatus; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class IdentityLoginGuardTest { + + private final IdentityLoginGuard guard = new IdentityLoginGuard(); + + @Test + void activePlatformAndEnterpriseContextsCanContinueToPrincipalCreation() { + String platformPrincipal = guard.continueAfterApproval( + platformContext(), + approved -> "platform-principal" + ); + String enterprisePrincipal = guard.continueAfterApproval( + enterpriseContext(), + approved -> "enterprise-principal" + ); + + assertThat(platformPrincipal).isEqualTo("platform-principal"); + assertThat(enterprisePrincipal).isEqualTo("enterprise-principal"); + } + + @Test + void disabledMergedAndSystemAccountsAreRejectedBeforeContinuation() { + assertRejected( + withAccount(UserStatus.PENDING, false), + IdentityLoginRejectionReason.ACCOUNT_PENDING + ); + assertRejected( + withAccount(UserStatus.DISABLED, false), + IdentityLoginRejectionReason.ACCOUNT_DISABLED + ); + assertRejected( + withAccount(UserStatus.MERGED, false), + IdentityLoginRejectionReason.ACCOUNT_MERGED + ); + assertRejected( + withAccount(UserStatus.ACTIVE, true), + IdentityLoginRejectionReason.SYSTEM_ACCOUNT_FORBIDDEN + ); + } + + @Test + void suspendedOrganizationAndMembershipAreRejectedBeforeContinuation() { + assertRejected( + withOrganization(OrganizationLoginState.SUSPENDED), + IdentityLoginRejectionReason.ORGANIZATION_SUSPENDED + ); + assertRejected( + withOrganization(OrganizationLoginState.DECOMMISSIONED), + IdentityLoginRejectionReason.ORGANIZATION_DECOMMISSIONED + ); + assertRejected( + withMembership(MembershipLoginState.INVITED), + IdentityLoginRejectionReason.MEMBERSHIP_NOT_ACTIVE + ); + assertRejected( + withMembership(MembershipLoginState.PROVISIONED), + IdentityLoginRejectionReason.MEMBERSHIP_NOT_ACTIVE + ); + assertRejected( + withMembership(MembershipLoginState.SUSPENDED), + IdentityLoginRejectionReason.MEMBERSHIP_SUSPENDED + ); + } + + @Test + void deprovisionedMembershipCannotBeRestoredByOrdinaryLogin() { + assertRejected( + withMembership(MembershipLoginState.DEPROVISIONED), + IdentityLoginRejectionReason.MEMBERSHIP_DEPROVISIONED + ); + } + + @Test + void inactiveConnectionAndExternalIdentityAreRejectedBeforeContinuation() { + assertRejected( + withConnection(LoginConnectionGuardState.DISABLED), + IdentityLoginRejectionReason.CONNECTION_NOT_ACTIVE + ); + assertRejected( + withConnection(LoginConnectionGuardState.DRAFT), + IdentityLoginRejectionReason.CONNECTION_NOT_ACTIVE + ); + assertRejected( + withConnection(LoginConnectionGuardState.SUSPENDED), + IdentityLoginRejectionReason.CONNECTION_NOT_ACTIVE + ); + assertRejected( + withConnection(LoginConnectionGuardState.ERROR), + IdentityLoginRejectionReason.CONNECTION_NOT_ACTIVE + ); + assertRejected( + withExternalIdentity(ExternalIdentityGuardState.SUSPENDED), + IdentityLoginRejectionReason.EXTERNAL_IDENTITY_SUSPENDED + ); + assertRejected( + withExternalIdentity(ExternalIdentityGuardState.REVOKED), + IdentityLoginRejectionReason.EXTERNAL_IDENTITY_REVOKED + ); + } + + @Test + void organizationAndMembershipMustBePresentTogether() { + assertThatThrownBy(() -> new IdentityLoginGuardContext( + UserStatus.ACTIVE, + false, + Optional.of(OrganizationLoginState.ACTIVE), + Optional.empty(), + LoginConnectionGuardState.ACTIVE, + ExternalIdentityGuardState.ACTIVE + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("organization and membership"); + } + + private void assertRejected( + IdentityLoginGuardContext context, + IdentityLoginRejectionReason expectedReason + ) { + AtomicBoolean continuationInvoked = new AtomicBoolean(); + + assertThatThrownBy(() -> guard.continueAfterApproval(context, approved -> { + continuationInvoked.set(true); + return "principal-or-session"; + })).isInstanceOf(IdentityLoginRejectedException.class) + .extracting("reason") + .isEqualTo(expectedReason); + assertThat(continuationInvoked).isFalse(); + } + + private static IdentityLoginGuardContext platformContext() { + return new IdentityLoginGuardContext( + UserStatus.ACTIVE, + false, + Optional.empty(), + Optional.empty(), + LoginConnectionGuardState.ACTIVE, + ExternalIdentityGuardState.ACTIVE + ); + } + + private static IdentityLoginGuardContext enterpriseContext() { + return new IdentityLoginGuardContext( + UserStatus.ACTIVE, + false, + Optional.of(OrganizationLoginState.ACTIVE), + Optional.of(MembershipLoginState.ACTIVE), + LoginConnectionGuardState.ACTIVE, + ExternalIdentityGuardState.ACTIVE + ); + } + + private static IdentityLoginGuardContext withAccount(UserStatus status, boolean systemAccount) { + IdentityLoginGuardContext context = enterpriseContext(); + return new IdentityLoginGuardContext( + status, + systemAccount, + context.organizationState(), + context.membershipState(), + context.connectionState(), + context.externalIdentityState() + ); + } + + private static IdentityLoginGuardContext withOrganization(OrganizationLoginState state) { + IdentityLoginGuardContext context = enterpriseContext(); + return new IdentityLoginGuardContext( + context.accountStatus(), + context.systemAccount(), + Optional.of(state), + context.membershipState(), + context.connectionState(), + context.externalIdentityState() + ); + } + + private static IdentityLoginGuardContext withMembership(MembershipLoginState state) { + IdentityLoginGuardContext context = enterpriseContext(); + return new IdentityLoginGuardContext( + context.accountStatus(), + context.systemAccount(), + context.organizationState(), + Optional.of(state), + context.connectionState(), + context.externalIdentityState() + ); + } + + private static IdentityLoginGuardContext withConnection(LoginConnectionGuardState state) { + IdentityLoginGuardContext context = enterpriseContext(); + return new IdentityLoginGuardContext( + context.accountStatus(), + context.systemAccount(), + context.organizationState(), + context.membershipState(), + state, + context.externalIdentityState() + ); + } + + private static IdentityLoginGuardContext withExternalIdentity(ExternalIdentityGuardState state) { + IdentityLoginGuardContext context = enterpriseContext(); + return new IdentityLoginGuardContext( + context.accountStatus(), + context.systemAccount(), + context.organizationState(), + context.membershipState(), + context.connectionState(), + state + ); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityResolverTest.java new file mode 100644 index 00000000..dba2da6c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/core/ProfileAuthorityResolverTest.java @@ -0,0 +1,223 @@ +package com.iflytek.skillhub.auth.federation.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ProfileAuthorityResolverTest { + + private static final AttributeKey DISPLAY_NAME = new AttributeKey("profile.display_name"); + private static final AttributeKey DEPARTMENT = new AttributeKey("profile.department"); + + private final ProfileAuthorityResolver resolver = new ProfileAuthorityResolver(); + + @Test + void lowerAuthorityLoginCannotOverwriteUserManagedGlobalField() { + ProfileFieldCoordinate field = field(ProfileScope.global(), DISPLAY_NAME); + ProfileValueSource user = source(ProfileSourceKind.USER_MANAGED, "usr_1", field.scope()); + ProfileValueSource login = source(ProfileSourceKind.LOGIN_ASSERTION, "login_1", field.scope()); + ProfileFieldAuthorityPolicy policy = policy(field, + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 10), + ProfileAuthorityRule.forKind(ProfileSourceKind.USER_MANAGED, 20)); + ProfileFieldState current = state(field, "Alice", user, 20); + + ProfileFieldResolution result = resolver.resolve( + Optional.of(current), + candidate(field, "Alice from login", login), + policy + ); + + assertThat(result).isEqualTo(new ProfileFieldResolution.Preserved( + current, + ProfileFieldResolutionReason.LOWER_AUTHORITY + )); + } + + @Test + void lowerAuthorityLoginCannotOverwriteOrganizationAdminManagedField() { + ProfileFieldCoordinate field = field(ProfileScope.organization("org_1"), DISPLAY_NAME); + ProfileValueSource organizationAdmin = source( + ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", field.scope()); + ProfileValueSource login = source(ProfileSourceKind.LOGIN_ASSERTION, "login_1", field.scope()); + ProfileFieldAuthorityPolicy policy = policy(field, + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 10), + ProfileAuthorityRule.forSource(ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", 30)); + ProfileFieldState current = state(field, "Managed Alice", organizationAdmin, 30); + + ProfileFieldResolution result = resolver.resolve( + Optional.of(current), + candidate(field, "Login Alice", login), + policy + ); + + assertThat(result).isEqualTo(new ProfileFieldResolution.Preserved( + current, + ProfileFieldResolutionReason.LOWER_AUTHORITY + )); + } + + @Test + void authorizedSourceCanFillEmptyFieldAndRefreshItsOwnValue() { + ProfileFieldCoordinate field = field(ProfileScope.organization("org_1"), DISPLAY_NAME); + ProfileValueSource login = source(ProfileSourceKind.LOGIN_ASSERTION, "login_1", field.scope()); + ProfileFieldAuthorityPolicy policy = policy(field, + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 10)); + + ProfileFieldResolution first = resolver.resolve( + Optional.empty(), candidate(field, "Alice", login), policy); + ProfileFieldState created = ((ProfileFieldResolution.Applied) first).state(); + ProfileFieldResolution refreshed = resolver.resolve( + Optional.of(created), candidate(field, "Alice Updated", login), policy); + + assertThat(created.value()).isEqualTo(new AttributeValue.Text("Alice")); + assertThat(created.authority()).isEqualTo(new ProfileFieldAuthority(login, 10)); + assertThat(refreshed).isEqualTo(new ProfileFieldResolution.Applied( + state(field, "Alice Updated", login, 10) + )); + } + + @Test + void higherAuthorityOrganizationAdminCanTakeOwnershipFromLogin() { + ProfileFieldCoordinate field = field(ProfileScope.organization("org_1"), DEPARTMENT); + ProfileValueSource login = source(ProfileSourceKind.LOGIN_ASSERTION, "login_1", field.scope()); + ProfileValueSource organizationAdmin = source( + ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", field.scope()); + ProfileFieldAuthorityPolicy policy = policy(field, + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 10), + ProfileAuthorityRule.forSource(ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", 30)); + + ProfileFieldResolution result = resolver.resolve( + Optional.of(state(field, "Engineering", login, 10)), + candidate(field, "Research", organizationAdmin), + policy + ); + + assertThat(result).isEqualTo(new ProfileFieldResolution.Applied( + state(field, "Research", organizationAdmin, 30) + )); + } + + @Test + void authorityIsConfiguredPerFieldAndPerSource() { + ProfileScope scope = ProfileScope.organization("org_1"); + ProfileValueSource organizationAdmin = source( + ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", scope); + ProfileFieldCoordinate displayName = field(scope, DISPLAY_NAME); + ProfileFieldCoordinate department = field(scope, DEPARTMENT); + ProfileFieldAuthorityPolicy displayNamePolicy = policy(displayName, + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 10)); + ProfileFieldAuthorityPolicy departmentPolicy = policy(department, + ProfileAuthorityRule.forSource(ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", 30)); + + ProfileFieldResolution denied = resolver.resolve( + Optional.empty(), candidate(displayName, "Managed Alice", organizationAdmin), displayNamePolicy); + ProfileFieldResolution applied = resolver.resolve( + Optional.empty(), candidate(department, "Research", organizationAdmin), departmentPolicy); + + assertThat(denied).isEqualTo(new ProfileFieldResolution.Rejected( + ProfileFieldResolutionReason.SOURCE_NOT_AUTHORIZED + )); + assertThat(applied).isInstanceOf(ProfileFieldResolution.Applied.class); + } + + @Test + void equalAuthorityFromDifferentSourcesDoesNotDependOnArrivalOrder() { + ProfileFieldCoordinate field = field(ProfileScope.organization("org_1"), DEPARTMENT); + ProfileValueSource adminOne = source( + ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", field.scope()); + ProfileValueSource adminTwo = source( + ProfileSourceKind.ORGANIZATION_ADMIN, "admin_2", field.scope()); + ProfileFieldAuthorityPolicy policy = policy(field, + ProfileAuthorityRule.forSource(ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", 30), + ProfileAuthorityRule.forSource(ProfileSourceKind.ORGANIZATION_ADMIN, "admin_2", 30)); + ProfileFieldState current = state(field, "Research", adminOne, 30); + + ProfileFieldResolution result = resolver.resolve( + Optional.of(current), candidate(field, "Engineering", adminTwo), policy); + + assertThat(result).isEqualTo(new ProfileFieldResolution.Preserved( + current, + ProfileFieldResolutionReason.EQUAL_AUTHORITY_CONFLICT + )); + } + + @Test + void sourceAndFieldScopesCannotCrossOrganizationsOrGlobalBoundary() { + ProfileScope organizationOne = ProfileScope.organization("org_1"); + ProfileScope organizationTwo = ProfileScope.organization("org_2"); + ProfileValueSource orgOneLogin = source( + ProfileSourceKind.LOGIN_ASSERTION, "login_1", organizationOne); + ProfileFieldCoordinate orgTwoField = field(organizationTwo, DISPLAY_NAME); + ProfileFieldAuthorityPolicy policy = policy(orgTwoField, + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 10)); + + assertThatThrownBy(() -> candidate(orgTwoField, "Alice", orgOneLogin)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("scope"); + assertThatThrownBy(() -> source( + ProfileSourceKind.ORGANIZATION_ADMIN, "admin_1", ProfileScope.global())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("organization"); + } + + @Test + void policyRejectsDuplicateRulesAndGlobalOrganizationAdminAuthority() { + ProfileFieldCoordinate globalField = field(ProfileScope.global(), DISPLAY_NAME); + + assertThatThrownBy(() -> policy(globalField, + ProfileAuthorityRule.forKind(ProfileSourceKind.ORGANIZATION_ADMIN, 30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("global"); + assertThatThrownBy(() -> new ProfileFieldAuthorityPolicy( + globalField, + List.of( + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 10), + ProfileAuthorityRule.forKind(ProfileSourceKind.LOGIN_ASSERTION, 20) + ) + )).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate"); + } + + private static ProfileFieldAuthorityPolicy policy( + ProfileFieldCoordinate field, + ProfileAuthorityRule... rules + ) { + return new ProfileFieldAuthorityPolicy(field, List.of(rules)); + } + + private static ProfileFieldCoordinate field(ProfileScope scope, AttributeKey key) { + return new ProfileFieldCoordinate(scope, key); + } + + private static ProfileValueSource source( + ProfileSourceKind kind, + String sourceId, + ProfileScope scope + ) { + return new ProfileValueSource(kind, sourceId, scope); + } + + private static ProfileFieldCandidate candidate( + ProfileFieldCoordinate field, + String value, + ProfileValueSource source + ) { + return new ProfileFieldCandidate(field, new AttributeValue.Text(value), source); + } + + private static ProfileFieldState state( + ProfileFieldCoordinate field, + String value, + ProfileValueSource source, + int priority + ) { + return new ProfileFieldState( + field, + new AttributeValue.Text(value), + new ProfileFieldAuthority(source, priority) + ); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/migration/LegacyIdentityBindingDualReaderTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/migration/LegacyIdentityBindingDualReaderTest.java new file mode 100644 index 00000000..8b45a841 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/federation/migration/LegacyIdentityBindingDualReaderTest.java @@ -0,0 +1,147 @@ +package com.iflytek.skillhub.auth.federation.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.federation.adapter.LegacyOAuthIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentity; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentityRepository; +import com.iflytek.skillhub.auth.federation.core.ExternalIdentityCoordinate; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.federation.core.SubjectRef; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class LegacyIdentityBindingDualReaderTest { + + private static final String PROVIDER = "github"; + private static final String SUBJECT = "subject-1"; + private static final ExternalIdentityCoordinate COORDINATE = new ExternalIdentityCoordinate( + Optional.empty(), + LegacyOAuthIdentityCoordinate.connectionId(PROVIDER), + LegacyOAuthIdentityCoordinate.ISSUER, + new SubjectRef(LegacyOAuthIdentityCoordinate.SUBJECT_TYPE, SUBJECT) + ); + + private ExternalIdentityRepository v2; + private IdentityBindingRepository legacy; + private IdentityBindingReadMetrics metrics; + private LegacyIdentityBindingDualReader reader; + + @BeforeEach + void setUp() { + v2 = mock(ExternalIdentityRepository.class); + legacy = mock(IdentityBindingRepository.class); + metrics = mock(IdentityBindingReadMetrics.class); + reader = new LegacyIdentityBindingDualReader(v2, legacy, metrics); + } + + @Test + void legacyModeBypassesV2AndRemainsAnImmediateRollbackPath() { + when(legacy.findByProviderCodeAndSubject(PROVIDER, SUBJECT)) + .thenReturn(Optional.of(binding("user-legacy"))); + + var targets = reader.findExactBinding( + IdentityCoreMode.LEGACY, COORDINATE, PROVIDER, SUBJECT); + + assertThat(targets).singleElement() + .extracting(target -> target.userId().orElseThrow()) + .isEqualTo("user-legacy"); + verify(v2, never()).findByCoordinate(COORDINATE); + verify(metrics).record(IdentityCoreMode.LEGACY, IdentityBindingReadOutcome.LEGACY_HIT); + } + + @Test + void shadowReadsV2FirstButKeepsLegacyAuthoritativeOnMismatch() { + when(v2.findByCoordinate(COORDINATE)).thenReturn(Optional.of(v2("user-v2"))); + when(legacy.findByProviderCodeAndSubject(PROVIDER, SUBJECT)) + .thenReturn(Optional.of(binding("user-legacy"))); + + var targets = reader.findExactBinding( + IdentityCoreMode.SHADOW, COORDINATE, PROVIDER, SUBJECT); + + assertThat(targets).singleElement() + .extracting(target -> target.userId().orElseThrow()) + .isEqualTo("user-legacy"); + InOrder order = inOrder(v2, legacy); + order.verify(v2).findByCoordinate(COORDINATE); + order.verify(legacy).findByProviderCodeAndSubject(PROVIDER, SUBJECT); + verify(metrics).record(IdentityCoreMode.SHADOW, IdentityBindingReadOutcome.MISMATCH); + } + + @Test + void activeUsesV2WhenBothStoresMatch() { + when(v2.findByCoordinate(COORDINATE)).thenReturn(Optional.of(v2("user-1"))); + when(legacy.findByProviderCodeAndSubject(PROVIDER, SUBJECT)) + .thenReturn(Optional.of(binding("user-1"))); + + var targets = reader.findExactBinding( + IdentityCoreMode.ACTIVE, COORDINATE, PROVIDER, SUBJECT); + + assertThat(targets).singleElement() + .extracting(target -> target.userId().orElseThrow()) + .isEqualTo("user-1"); + verify(metrics).record(IdentityCoreMode.ACTIVE, IdentityBindingReadOutcome.MATCH); + } + + @Test + void activeFallsBackToLegacyWhenV2IsMissing() { + when(v2.findByCoordinate(COORDINATE)).thenReturn(Optional.empty()); + when(legacy.findByProviderCodeAndSubject(PROVIDER, SUBJECT)) + .thenReturn(Optional.of(binding("user-legacy"))); + + var targets = reader.findExactBinding( + IdentityCoreMode.ACTIVE, COORDINATE, PROVIDER, SUBJECT); + + assertThat(targets).singleElement() + .extracting(target -> target.userId().orElseThrow()) + .isEqualTo("user-legacy"); + verify(metrics).record( + IdentityCoreMode.ACTIVE, + IdentityBindingReadOutcome.V2_MISS_LEGACY_FALLBACK + ); + } + + @Test + void activeFailsClosedWhenStoresPointToDifferentAccounts() { + when(v2.findByCoordinate(COORDINATE)).thenReturn(Optional.of(v2("user-v2"))); + when(legacy.findByProviderCodeAndSubject(PROVIDER, SUBJECT)) + .thenReturn(Optional.of(binding("user-legacy"))); + + assertThatThrownBy(() -> reader.findExactBinding( + IdentityCoreMode.ACTIVE, COORDINATE, PROVIDER, SUBJECT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("resolve different accounts"); + verify(metrics).record(IdentityCoreMode.ACTIVE, IdentityBindingReadOutcome.MISMATCH); + } + + @Test + void activeFailsClosedForV2OnlyPlatformIdentityWhileLegacyWritesRemainEnabled() { + when(v2.findByCoordinate(COORDINATE)).thenReturn(Optional.of(v2("user-v2"))); + when(legacy.findByProviderCodeAndSubject(PROVIDER, SUBJECT)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reader.findExactBinding( + IdentityCoreMode.ACTIVE, COORDINATE, PROVIDER, SUBJECT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("only in V2"); + verify(metrics).record(IdentityCoreMode.ACTIVE, IdentityBindingReadOutcome.V2_ONLY); + } + + private static IdentityBinding binding(String userId) { + return new IdentityBinding(userId, PROVIDER, SUBJECT, "alice"); + } + + private static ExternalIdentity v2(String userId) { + return ExternalIdentity.bind(COORDINATE, userId, Instant.parse("2026-09-07T12:00:00Z")); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridgeTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridgeTest.java new file mode 100644 index 00000000..aa83d69f --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/LegacyPlatformIdentityCoreBridgeTest.java @@ -0,0 +1,131 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.federation.adapter.LegacyOAuthVerifiedFactsAdapter; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentityRepository; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.federation.core.IdentityLoginResolution; +import com.iflytek.skillhub.auth.federation.migration.IdentityBindingReadMetrics; +import com.iflytek.skillhub.auth.federation.migration.LegacyIdentityBindingDualReader; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +class LegacyPlatformIdentityCoreBridgeTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-09-07T14:00:00Z"), ZoneOffset.UTC); + + @Test + void legacyModeDoesNotEnterUnifiedCoreOrReadBindings() { + IdentityBindingRepository bindings = mock(IdentityBindingRepository.class); + LegacyPlatformIdentityCoreBridge bridge = bridge(IdentityCoreMode.LEGACY, bindings); + + LegacyPlatformIdentityDecision decision = bridge.evaluate(claims()); + + assertThat(decision.mode()).isEqualTo(IdentityCoreMode.LEGACY); + assertThat(decision.resolution()).isEmpty(); + verify(bindings, never()).findByProviderCodeAndSubject("github", "subject-1"); + } + + @Test + void shadowModeEvaluatesExactLegacyBindingWithoutChangingLoginOutcome() { + IdentityBindingRepository bindings = mock(IdentityBindingRepository.class); + when(bindings.findByProviderCodeAndSubject("github", "subject-1")) + .thenReturn(Optional.of(new IdentityBinding( + "usr_1", "github", "subject-1", "alice"))); + LegacyPlatformIdentityCoreBridge bridge = bridge(IdentityCoreMode.SHADOW, bindings); + + LegacyPlatformIdentityDecision decision = bridge.evaluate(claims()); + + assertThat(decision.mode()).isEqualTo(IdentityCoreMode.SHADOW); + assertThat(decision.resolution()).hasValueSatisfying(resolution -> + assertThat(resolution).isInstanceOf(IdentityLoginResolution.Matched.class)); + verify(bindings).findByProviderCodeAndSubject("github", "subject-1"); + } + + @Test + void activeModeEvaluatesNewAndExistingLegacyIdentities() { + IdentityBindingRepository bindings = mock(IdentityBindingRepository.class); + LegacyPlatformIdentityCoreBridge bridge = bridge(IdentityCoreMode.ACTIVE, bindings); + + assertThat(bridge.evaluate(claims()).resolution()) + .hasValueSatisfying(resolution -> + assertThat(resolution).isInstanceOf(IdentityLoginResolution.ProvisionNew.class)); + when(bindings.findByProviderCodeAndSubject("github", "subject-1")) + .thenReturn(Optional.of(new IdentityBinding( + "usr_1", "github", "subject-1", "alice"))); + assertThat(bridge.evaluate(claims()).resolution()) + .hasValueSatisfying(resolution -> + assertThat(resolution).isInstanceOf(IdentityLoginResolution.Matched.class)); + } + + @Test + void shadowFailureCannotBreakLegacyLoginButActiveFailureIsFailClosed() { + IdentityBindingRepository bindings = mock(IdentityBindingRepository.class); + when(bindings.findByProviderCodeAndSubject("github", "subject-1")) + .thenThrow(new IllegalStateException("provider-private-error subject-1 alice@example.com")); + + Logger logger = (Logger) LoggerFactory.getLogger(LegacyPlatformIdentityCoreBridge.class); + Level originalLevel = logger.getLevel(); + ListAppender appender = new ListAppender<>(); + logger.setLevel(Level.WARN); + appender.start(); + logger.addAppender(appender); + try { + LegacyPlatformIdentityDecision shadowDecision = + bridge(IdentityCoreMode.SHADOW, bindings).evaluate(claims()); + assertThat(shadowDecision.mode()).isEqualTo(IdentityCoreMode.SHADOW); + assertThat(shadowDecision.resolution()).isEmpty(); + assertThat(appender.list) + .extracting(ILoggingEvent::getFormattedMessage) + .allSatisfy(message -> assertThat(message).doesNotContain( + "provider-private-error", "subject-1", "alice@example.com")); + } finally { + logger.detachAppender(appender); + appender.stop(); + logger.setLevel(originalLevel); + } + assertThatThrownBy(() -> bridge(IdentityCoreMode.ACTIVE, bindings).evaluate(claims())) + .isInstanceOf(OAuthIdentityCoreException.class) + .hasMessageContaining("Unified identity evaluation failed"); + } + + private static LegacyPlatformIdentityCoreBridge bridge( + IdentityCoreMode mode, + IdentityBindingRepository bindings + ) { + return new LegacyPlatformIdentityCoreBridge( + ignored -> mode, + new LegacyIdentityBindingDualReader( + mock(ExternalIdentityRepository.class), + bindings, + IdentityBindingReadMetrics.noop() + ), + new LegacyOAuthVerifiedFactsAdapter(CLOCK) + ); + } + + private static OAuthClaims claims() { + return new OAuthClaims( + "github", "subject-1", "alice@example.com", true, "alice", Map.of() + ); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index de3a8411..b2b94e54 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -112,7 +112,7 @@ class OAuth2LoginHandlersTest { * Regression test: when an unauthenticated client hits a protected API endpoint, Spring Security * caches that request. With {@code SavedRequestAwareAuthenticationSuccessHandler} the post-login * redirect would resolve to the cached API URL, leaving the user staring at raw JSON instead of - * the dashboard. The handler must ignore the saved request and fall back to the default target. + * the product home. The handler must ignore the saved request and fall back to the default target. */ @Test void successHandler_ignoresSavedApiRequestAndRedirectsToDefault() throws Exception { @@ -141,7 +141,7 @@ class OAuth2LoginHandlersTest { handler.onAuthenticationSuccess(request, response, authentication); - assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard"); + assertThat(response.getRedirectedUrl()).isEqualTo("/"); } @Test diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index d51311da..29a280f1 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -1,19 +1,244 @@ package com.iflytek.skillhub.auth.oauth; +import com.iflytek.skillhub.auth.federation.adapter.RemoteIdentityIoExecutor; +import com.iflytek.skillhub.auth.federation.adapter.LegacyOAuthVerifiedFactsAdapter; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentityRepository; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreMode; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationStage; +import com.iflytek.skillhub.auth.federation.core.IdentityLoginResolution; +import com.iflytek.skillhub.auth.federation.migration.IdentityBindingReadMetrics; +import com.iflytek.skillhub.auth.federation.migration.LegacyIdentityBindingDualReader; import com.iflytek.skillhub.auth.identity.IdentityBindingService; +import com.iflytek.skillhub.auth.policy.AccessDecision; import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.user.UserStatus; import jakarta.servlet.http.HttpSession; +import java.time.Clock; +import java.time.Instant; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class OAuthLoginFlowServiceTest { + @Test + void loadLoginContext_executesUserinfoThroughRemoteIdentityIoBoundary() { + OAuthClaims claims = claims(); + OAuthClaimsExtractor extractor = new OAuthClaimsExtractor() { + @Override + public String getProvider() { + return "github"; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User user) { + return claims; + } + }; + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuth2UserService delegate = mock(); + AtomicInteger boundaryCalls = new AtomicInteger(); + RemoteIdentityIoExecutor remoteIdentityIo = new RemoteIdentityIoExecutor() { + @Override + public T execute(java.util.function.Supplier operation) { + boundaryCalls.incrementAndGet(); + return operation.get(); + } + }; + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(extractor), + accessPolicy, + identityBindingService, + identityCore, + delegate, + remoteIdentityIo + ); + OAuth2UserRequest request = oauthUserRequest(); + OAuth2User upstreamUser = new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("OAUTH_USER")), + Map.of("id", "gh_1"), + "id" + ); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", "alice", "alice@example.com", null, "github", Set.of("USER") + ); + when(delegate.loadUser(request)).thenReturn(upstreamUser); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW); + when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy()); + when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal); + + OAuthLoginFlowService.AuthenticatedLoginContext result = service.loadLoginContext(request); + + assertThat(result.upstreamUser()).isSameAs(upstreamUser); + assertThat(result.principal()).isSameAs(principal); + assertThat(boundaryCalls).hasValue(1); + verify(delegate).loadUser(request); + } + + @ParameterizedTest + @EnumSource(IdentityCoreMode.class) + void authenticate_preservesPrincipalAcrossLegacyShadowAndActiveModes(IdentityCoreMode mode) { + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + IdentityBindingRepository bindingRepository = mock(IdentityBindingRepository.class); + LegacyPlatformIdentityCore identityCore = new LegacyPlatformIdentityCoreBridge( + ignored -> mode, + new LegacyIdentityBindingDualReader( + mock(ExternalIdentityRepository.class), + bindingRepository, + IdentityBindingReadMetrics.noop() + ), + new LegacyOAuthVerifiedFactsAdapter(Clock.systemUTC()) + ); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), accessPolicy, identityBindingService, identityCore + ); + OAuthClaims claims = claims(); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", "alice", "alice@example.com", null, "github", Set.of("USER") + ); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW); + when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal); + + assertThat(service.authenticate(claims)).isSameAs(principal); + + verify(identityBindingService).bindOrCreate(claims, UserStatus.ACTIVE); + } + + @Test + void authenticate_allowsApprovedClaimsAndReturnsPlatformPrincipal() { + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), accessPolicy, identityBindingService, identityCore + ); + OAuthClaims claims = claims(); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", "alice", "alice@example.com", null, "github", Set.of("USER") + ); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW); + when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy()); + when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal); + + assertThat(service.authenticate(claims)).isSameAs(principal); + var ordered = inOrder(identityCore, identityBindingService); + ordered.verify(identityCore).evaluate(claims); + ordered.verify(identityBindingService).bindOrCreate(claims, UserStatus.ACTIVE); + } + + @Test + void authenticate_createsPendingIdentityWithoutCreatingActivePrincipal() { + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), accessPolicy, identityBindingService, identityCore + ); + OAuthClaims claims = claims(); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.PENDING_APPROVAL); + when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.legacy()); + + assertThatThrownBy(() -> service.authenticate(claims)) + .isInstanceOf(AccountPendingException.class); + + verify(identityCore).evaluate(claims); + verify(identityBindingService).createPendingUserIfAbsent(claims); + verify(identityBindingService, never()).bindOrCreate(claims, UserStatus.ACTIVE); + } + + @Test + void authenticate_activeUnifiedCoreConflictFailsBeforeLegacyBindingWrite() { + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), accessPolicy, identityBindingService, identityCore + ); + OAuthClaims claims = claims(); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW); + when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.evaluated( + IdentityCoreMode.ACTIVE, + new IdentityLoginResolution.Conflict(IdentityCorrelationStage.EXACT_BINDING, 2) + )); + + assertThatThrownBy(() -> service.authenticate(claims)) + .isInstanceOfSatisfying(OAuthIdentityCoreException.class, + exception -> assertThat(exception.getError().getErrorCode()).isEqualTo("access_denied")); + + verify(identityBindingService, never()).bindOrCreate(claims, UserStatus.ACTIVE); + verify(identityBindingService, never()).createPendingUserIfAbsent(claims); + } + + @Test + void authenticate_pendingApprovalStillFailsBeforePendingWriteWhenActiveCoreConflicts() { + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), accessPolicy, identityBindingService, identityCore + ); + OAuthClaims claims = claims(); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.PENDING_APPROVAL); + when(identityCore.evaluate(claims)).thenReturn(LegacyPlatformIdentityDecision.evaluated( + IdentityCoreMode.ACTIVE, + new IdentityLoginResolution.Conflict(IdentityCorrelationStage.EXACT_BINDING, 2) + )); + + assertThatThrownBy(() -> service.authenticate(claims)) + .isInstanceOf(OAuthIdentityCoreException.class); + + verify(identityBindingService, never()).createPendingUserIfAbsent(claims); + verify(identityBindingService, never()).bindOrCreate(claims, UserStatus.ACTIVE); + } + + @Test + void authenticate_deniesClaimsWithoutCreatingOrBindingAnAccount() { + AccessPolicy accessPolicy = mock(AccessPolicy.class); + IdentityBindingService identityBindingService = mock(IdentityBindingService.class); + LegacyPlatformIdentityCore identityCore = mock(LegacyPlatformIdentityCore.class); + OAuthLoginFlowService service = new OAuthLoginFlowService( + List.of(), accessPolicy, identityBindingService, identityCore + ); + OAuthClaims claims = claims(); + when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.DENY); + + assertThatThrownBy(() -> service.authenticate(claims)) + .isInstanceOfSatisfying(OAuth2AuthenticationException.class, + exception -> assertThat(exception.getError().getErrorCode()).isEqualTo("access_denied")); + + verify(identityCore, never()).evaluate(claims); + verify(identityBindingService, never()).createPendingUserIfAbsent(claims); + verify(identityBindingService, never()).bindOrCreate(claims, UserStatus.ACTIVE); + } + @Test void rememberReturnTo_stores_sanitized_return_target() { OAuthLoginFlowService service = new OAuthLoginFlowService( @@ -88,4 +313,31 @@ class OAuthLoginFlowServiceTest { assertThat(returnTo).isNull(); assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); } + + private static OAuthClaims claims() { + return new OAuthClaims( + "github", "gh_1", "alice@example.com", true, "alice", Map.of() + ); + } + + private static OAuth2UserRequest oauthUserRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("github") + .clientId("client") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("https://skillhub.example/login/oauth2/code/github") + .authorizationUri("https://github.example/oauth/authorize") + .tokenUri("https://github.example/oauth/token") + .userInfoUri("https://github.example/user") + .userNameAttributeName("id") + .build(); + Instant issuedAt = Instant.parse("2026-09-07T00:00:00Z"); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "access-token", + issuedAt, + issuedAt.plusSeconds(300) + ); + return new OAuth2UserRequest(registration, accessToken); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupportTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupportTest.java new file mode 100644 index 00000000..20ea7640 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupportTest.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class OAuthLoginRedirectSupportTest { + @Test + void defaultTargetIsHomeAndValidSourceIsPreserved() { + assertThat(OAuthLoginRedirectSupport.DEFAULT_TARGET_URL).isEqualTo("/"); + assertThat(OAuthLoginRedirectSupport.sanitizeReturnTo(" /search?q=player#results ")) + .isEqualTo("/search?q=player#results"); + assertThat(OAuthLoginRedirectSupport.sanitizeReturnTo("/cli/auth")).isEqualTo("/cli/auth"); + } + + @Test + void unsafeTargetsAreRejected() { + assertThat(OAuthLoginRedirectSupport.sanitizeReturnTo(null)).isNull(); + for (String target : List.of("", "https://outside.test", "//outside.test", "/\\outside.test", "/sea\nrch", "/search\u007f")) { + assertThat(OAuthLoginRedirectSupport.sanitizeReturnTo(target)).isNull(); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/MembershipSourceType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/MembershipSourceType.java new file mode 100644 index 00000000..8f8dbc6b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/MembershipSourceType.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.organization; + +/** Stable source families that can create an organization membership. */ +public enum MembershipSourceType { + INVITATION, + MANUAL, + JIT +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/Organization.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/Organization.java new file mode 100644 index 00000000..e8e73d8b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/Organization.java @@ -0,0 +1,196 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; + +/** Enterprise tenant aggregate whose lifecycle gates every organization-scoped capability. */ +@Entity +@Table(name = "organization") +public class Organization { + + private static final Pattern SLUG_PATTERN = + Pattern.compile("[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,63}"); + + @Id + @Column(length = 64) + private String id; + + @Column(nullable = false, unique = true, length = 64) + private String slug; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OrganizationStatus status; + + @Column(name = "authority_version", nullable = false) + private long authorityVersion; + + @Column(name = "created_by", nullable = false, length = 128) + private String createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long version; + + protected Organization() { + } + + private Organization( + String id, + String slug, + String displayName, + String createdBy, + Instant createdAt + ) { + this.id = requireNonBlank(id, "id"); + this.slug = requireSlug(slug); + this.displayName = requireNonBlank(displayName, "displayName"); + this.createdBy = requireNonBlank(createdBy, "createdBy"); + this.status = OrganizationStatus.ACTIVE; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.updatedAt = createdAt; + } + + public static Organization create( + String slug, + String displayName, + String createdBy, + Instant createdAt + ) { + return new Organization( + UUID.randomUUID().toString(), + slug, + displayName, + createdBy, + createdAt + ); + } + + public void suspend(Instant occurredAt) { + transition(OrganizationStatus.ACTIVE, OrganizationStatus.SUSPENDED, occurredAt); + } + + public void reactivate(Instant occurredAt) { + transition(OrganizationStatus.SUSPENDED, OrganizationStatus.ACTIVE, occurredAt); + } + + public void decommission(Instant occurredAt) { + transition(OrganizationStatus.SUSPENDED, OrganizationStatus.DECOMMISSIONED, occurredAt); + } + + /** Marks an effective Organization RBAC change so existing authorization becomes stale. */ + public void recordRoleBindingChange(Instant occurredAt) { + Instant changeTime = Objects.requireNonNull(occurredAt, "occurredAt"); + if (changeTime.isBefore(updatedAt)) { + throw new DomainBadRequestException("error.organization.state.transition.stale"); + } + authorityVersion++; + updatedAt = changeTime; + } + + private void transition( + OrganizationStatus requiredCurrent, + OrganizationStatus target, + Instant occurredAt + ) { + if (status == target) { + return; + } + if (status != requiredCurrent) { + throw new DomainBadRequestException( + "error.organization.state.transition.invalid", + status, + target + ); + } + Instant transitionTime = Objects.requireNonNull(occurredAt, "occurredAt"); + if (transitionTime.isBefore(updatedAt)) { + throw new DomainBadRequestException("error.organization.state.transition.stale"); + } + status = target; + authorityVersion++; + updatedAt = transitionTime; + } + + @PrePersist + void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(Clock.systemUTC()); + } + if (updatedAt == null) { + updatedAt = createdAt; + } + } + + private static String requireSlug(String value) { + String slug = requireNonBlank(value, "slug"); + if (!SLUG_PATTERN.matcher(slug).matches()) { + throw new DomainBadRequestException("error.organization.slug.invalid"); + } + return slug; + } + + private static String requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException("error.organization.field.required", field); + } + return value; + } + + public String getId() { + return id; + } + + public String getSlug() { + return slug; + } + + public String getDisplayName() { + return displayName; + } + + public OrganizationStatus getStatus() { + return status; + } + + public long getAuthorityVersion() { + return authorityVersion; + } + + public String getCreatedBy() { + return createdBy; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getVersion() { + return version; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAccessGuard.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAccessGuard.java new file mode 100644 index 00000000..908fdc96 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAccessGuard.java @@ -0,0 +1,77 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import java.util.Objects; +import org.springframework.stereotype.Service; + +/** Resolves one fail-closed, tenant-scoped authorization snapshot for a Platform Account. */ +@Service +public class OrganizationAccessGuard { + + private final OrganizationRepository organizationRepository; + private final OrganizationMembershipRepository membershipRepository; + + public OrganizationAccessGuard( + OrganizationRepository organizationRepository, + OrganizationMembershipRepository membershipRepository + ) { + this.organizationRepository = Objects.requireNonNull( + organizationRepository, + "organizationRepository" + ); + this.membershipRepository = Objects.requireNonNull( + membershipRepository, + "membershipRepository" + ); + } + + public OrganizationAccessSnapshot requireCurrent(String organizationId, String userId) { + return requireCurrent(organizationId, userId, true); + } + + OrganizationAccessSnapshot requireCurrentForLifecycleRecovery( + String organizationId, + String userId + ) { + return requireCurrent(organizationId, userId, false); + } + + private OrganizationAccessSnapshot requireCurrent( + String organizationId, + String userId, + boolean requireActiveOrganization + ) { + String tenant = requireText(organizationId, "organizationId"); + String subject = requireText(userId, "userId"); + Organization organization = organizationRepository.findById(tenant) + .orElseThrow(OrganizationAccessGuard::denied); + if (requireActiveOrganization && organization.getStatus() != OrganizationStatus.ACTIVE) { + throw denied(); + } + OrganizationMembership membership = membershipRepository + .findCurrentByOrganizationIdAndUserId(tenant, subject) + .filter(candidate -> candidate.getStatus() == OrganizationMembershipStatus.ACTIVE) + .filter(candidate -> subject.equals(candidate.getUserId())) + .orElseThrow(OrganizationAccessGuard::denied); + return new OrganizationAccessSnapshot( + tenant, + membership.getId(), + subject, + organization.getAuthorityVersion(), + membership.getAuthorityVersion() + ); + } + + private static DomainForbiddenException denied() { + return new DomainForbiddenException("error.organization.permission.denied"); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAccessSnapshot.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAccessSnapshot.java new file mode 100644 index 00000000..237e19d2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAccessSnapshot.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.Objects; + +/** Current tenant authorization coordinates used by session and credential guards. */ +public record OrganizationAccessSnapshot( + String organizationId, + String membershipId, + String userId, + long organizationAuthorityVersion, + long membershipAuthorityVersion +) { + public OrganizationAccessSnapshot { + organizationId = requireText(organizationId, "organizationId"); + membershipId = requireText(membershipId, "membershipId"); + userId = requireText(userId, "userId"); + if (organizationAuthorityVersion < 0 || membershipAuthorityVersion < 0) { + throw new IllegalArgumentException("authority versions must not be negative"); + } + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field + " must not be null"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAdministrativeAction.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAdministrativeAction.java new file mode 100644 index 00000000..77cca1ca --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAdministrativeAction.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.domain.organization; + +/** Administrative actions evaluated only against Organization-scoped roles. */ +public enum OrganizationAdministrativeAction { + VIEW_ORGANIZATION, + VIEW_ORGANIZATION_ROLES, + VIEW_DOMAINS, + VIEW_MEMBERS, + VIEW_LOGIN_CONNECTIONS, + MANAGE_ORGANIZATION_LIFECYCLE, + MANAGE_ORGANIZATION_ROLES, + MANAGE_DOMAINS, + MANAGE_LOGIN_CONNECTIONS, + ROTATE_LOGIN_SECRETS, + RESOLVE_IDENTITY_CONFLICTS, + MANAGE_MEMBERS, + VIEW_AUDIT, + VIEW_HEALTH +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicy.java new file mode 100644 index 00000000..7c462073 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicy.java @@ -0,0 +1,106 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.springframework.stereotype.Component; + +/** Explicit Organization RBAC matrix; Platform and Namespace roles are not valid inputs. */ +@Component +public class OrganizationAuthorizationPolicy { + + private static final Set ALL_ROLES = Set.of(OrganizationRole.values()); + + private static final Map> + ALLOWED_ROLES = Map.ofEntries( + Map.entry(OrganizationAdministrativeAction.VIEW_ORGANIZATION, ALL_ROLES), + Map.entry( + OrganizationAdministrativeAction.VIEW_ORGANIZATION_ROLES, + Set.of(OrganizationRole.ORG_OWNER, OrganizationRole.ORG_AUDITOR) + ), + Map.entry( + OrganizationAdministrativeAction.VIEW_DOMAINS, + Set.of( + OrganizationRole.ORG_OWNER, + OrganizationRole.IDENTITY_ADMIN, + OrganizationRole.LOGIN_SECRET_ADMIN, + OrganizationRole.MEMBER_ADMIN, + OrganizationRole.ORG_AUDITOR + ) + ), + Map.entry( + OrganizationAdministrativeAction.VIEW_MEMBERS, + Set.of( + OrganizationRole.ORG_OWNER, + OrganizationRole.MEMBER_ADMIN, + OrganizationRole.ORG_AUDITOR + ) + ), + Map.entry( + OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS, + Set.of( + OrganizationRole.ORG_OWNER, + OrganizationRole.IDENTITY_ADMIN, + OrganizationRole.LOGIN_SECRET_ADMIN, + OrganizationRole.MEMBER_ADMIN, + OrganizationRole.ORG_AUDITOR + ) + ), + Map.entry( + OrganizationAdministrativeAction.MANAGE_ORGANIZATION_LIFECYCLE, + Set.of(OrganizationRole.ORG_OWNER) + ), + Map.entry( + OrganizationAdministrativeAction.MANAGE_ORGANIZATION_ROLES, + Set.of(OrganizationRole.ORG_OWNER) + ), + Map.entry( + OrganizationAdministrativeAction.MANAGE_DOMAINS, + Set.of(OrganizationRole.IDENTITY_ADMIN) + ), + Map.entry( + OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS, + Set.of(OrganizationRole.IDENTITY_ADMIN) + ), + Map.entry( + OrganizationAdministrativeAction.ROTATE_LOGIN_SECRETS, + Set.of(OrganizationRole.LOGIN_SECRET_ADMIN) + ), + Map.entry( + OrganizationAdministrativeAction.RESOLVE_IDENTITY_CONFLICTS, + Set.of(OrganizationRole.IDENTITY_ADMIN) + ), + Map.entry( + OrganizationAdministrativeAction.MANAGE_MEMBERS, + Set.of(OrganizationRole.MEMBER_ADMIN) + ), + Map.entry( + OrganizationAdministrativeAction.VIEW_AUDIT, + Set.of(OrganizationRole.ORG_OWNER, OrganizationRole.ORG_AUDITOR) + ), + Map.entry(OrganizationAdministrativeAction.VIEW_HEALTH, ALL_ROLES) + ); + + public boolean isAllowed( + Set roles, + OrganizationAdministrativeAction action + ) { + Objects.requireNonNull(action, "action"); + if (roles == null || roles.isEmpty()) { + return false; + } + Set allowedRoles = ALLOWED_ROLES.get(action); + return allowedRoles != null && !Collections.disjoint(roles, allowedRoles); + } + + public void requireAllowed( + Set roles, + OrganizationAdministrativeAction action + ) { + if (!isAllowed(roles, action)) { + throw new DomainForbiddenException("error.organization.permission.denied"); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationService.java new file mode 100644 index 00000000..754235bf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationService.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +/** Resolves active Organization roles and applies the explicit administrative policy. */ +@Service +public class OrganizationAuthorizationService { + + private final OrganizationAccessGuard accessGuard; + private final OrganizationRoleBindingRepository roleBindingRepository; + private final OrganizationAuthorizationPolicy authorizationPolicy; + + public OrganizationAuthorizationService( + OrganizationAccessGuard accessGuard, + OrganizationRoleBindingRepository roleBindingRepository, + OrganizationAuthorizationPolicy authorizationPolicy + ) { + this.accessGuard = accessGuard; + this.roleBindingRepository = roleBindingRepository; + this.authorizationPolicy = authorizationPolicy; + } + + public void requireAllowed( + String organizationId, + String userId, + OrganizationAdministrativeAction action + ) { + if (action == OrganizationAdministrativeAction.MANAGE_ORGANIZATION_LIFECYCLE) { + accessGuard.requireCurrentForLifecycleRecovery(organizationId, userId); + } else { + accessGuard.requireCurrent(organizationId, userId); + } + Set roles = roleBindingRepository + .findActiveByOrganizationIdAndUserId(organizationId, userId) + .stream() + .map(OrganizationRoleBinding::getRole) + .collect(Collectors.toUnmodifiableSet()); + authorizationPolicy.requireAllowed(roles, action); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomain.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomain.java new file mode 100644 index 00000000..34e7840e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomain.java @@ -0,0 +1,252 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; + +/** Organization-owned domain claim used only after explicit ownership verification. */ +@Entity +@Table(name = "organization_domain") +public class OrganizationDomain { + + private static final Pattern LABEL_PATTERN = + Pattern.compile("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"); + private static final Pattern IPV4_LIKE_PATTERN = + Pattern.compile("(?:[0-9]{1,3}\\.){3}[0-9]{1,3}"); + + @Id + @Column(length = 64) + private String id; + + @Column(name = "organization_id", nullable = false, length = 64) + private String organizationId; + + @Column(nullable = false, length = 253) + private String domain; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OrganizationDomainStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "verification_method", length = 64) + private OrganizationDomainVerificationMethod verificationMethod; + + @Column(name = "verification_token_hash", length = 255) + private String verificationTokenHash; + + @Column(name = "verified_at") + private Instant verifiedAt; + + @Column(name = "last_checked_at") + private Instant lastCheckedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long version; + + protected OrganizationDomain() { + } + + private OrganizationDomain( + String organizationId, + String domain, + OrganizationDomainVerificationMethod verificationMethod, + String challengeDigest, + Instant createdAt + ) { + this.id = UUID.randomUUID().toString(); + this.organizationId = requireNonBlank(organizationId, "organizationId"); + this.domain = normalize(domain); + this.verificationMethod = Objects.requireNonNull( + verificationMethod, + "verificationMethod" + ); + this.verificationTokenHash = requireNonBlank(challengeDigest, "challengeDigest"); + this.status = OrganizationDomainStatus.PENDING; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.updatedAt = createdAt; + } + + public static OrganizationDomain claim( + String organizationId, + String domain, + OrganizationDomainVerificationMethod verificationMethod, + String challengeDigest, + Instant createdAt + ) { + return new OrganizationDomain( + organizationId, + domain, + verificationMethod, + challengeDigest, + createdAt + ); + } + + /** Normalizes and validates the exact host name accepted for ownership claims. */ + public static String normalize(String value) { + if (value == null) { + throw invalidDomain(); + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty() + || normalized.length() > 253 + || IPV4_LIKE_PATTERN.matcher(normalized).matches()) { + throw invalidDomain(); + } + String[] labels = normalized.split("\\.", -1); + for (String label : labels) { + if (!LABEL_PATTERN.matcher(label).matches()) { + throw invalidDomain(); + } + } + return normalized; + } + + public boolean verify(Instant occurredAt) { + if (status == OrganizationDomainStatus.VERIFIED) { + return false; + } + if (status != OrganizationDomainStatus.PENDING) { + throw invalidTransition(); + } + Instant changeTime = requireCurrentOrLater(occurredAt); + status = OrganizationDomainStatus.VERIFIED; + verificationTokenHash = null; + verifiedAt = changeTime; + lastCheckedAt = changeTime; + updatedAt = changeTime; + return true; + } + + public boolean disable(Instant occurredAt) { + if (status == OrganizationDomainStatus.DISABLED) { + return false; + } + Instant changeTime = requireCurrentOrLater(occurredAt); + status = OrganizationDomainStatus.DISABLED; + verificationTokenHash = null; + updatedAt = changeTime; + return true; + } + + public void reissueChallenge( + OrganizationDomainVerificationMethod method, + String challengeDigest, + Instant occurredAt + ) { + if (status == OrganizationDomainStatus.VERIFIED) { + throw invalidTransition(); + } + Instant changeTime = requireCurrentOrLater(occurredAt); + status = OrganizationDomainStatus.PENDING; + verificationMethod = Objects.requireNonNull(method, "verificationMethod"); + verificationTokenHash = requireNonBlank(challengeDigest, "challengeDigest"); + verifiedAt = null; + lastCheckedAt = null; + updatedAt = changeTime; + } + + @PrePersist + void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(Clock.systemUTC()); + } + if (updatedAt == null) { + updatedAt = createdAt; + } + } + + String challengeDigest() { + return verificationTokenHash; + } + + private Instant requireCurrentOrLater(Instant occurredAt) { + Instant changeTime = Objects.requireNonNull(occurredAt, "occurredAt"); + if (changeTime.isBefore(updatedAt)) { + throw new DomainBadRequestException( + "error.organization.domain.transition.stale" + ); + } + return changeTime; + } + + private DomainBadRequestException invalidTransition() { + return new DomainBadRequestException( + "error.organization.domain.transition.invalid" + ); + } + + private static DomainBadRequestException invalidDomain() { + return new DomainBadRequestException("error.organization.domain.invalid"); + } + + private static String requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException( + "error.organization.domain.field.required", + field + ); + } + return value; + } + + public String getId() { + return id; + } + + public String getOrganizationId() { + return organizationId; + } + + public String getDomain() { + return domain; + } + + public OrganizationDomainStatus getStatus() { + return status; + } + + public OrganizationDomainVerificationMethod getVerificationMethod() { + return verificationMethod; + } + + public Instant getVerifiedAt() { + return verifiedAt; + } + + public Instant getLastCheckedAt() { + return lastCheckedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getVersion() { + return version; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallenge.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallenge.java new file mode 100644 index 00000000..9c89dc50 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallenge.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.domain.organization; + +/** Plaintext DNS proof returned once to an authorized administrator. */ +public record OrganizationDomainChallenge( + String domainId, + String domain, + OrganizationDomainVerificationMethod method, + String proofToken +) { + + @Override + public String toString() { + return "OrganizationDomainChallenge[domainId=" + domainId + + ", domain=" + domain + + ", method=" + method + + ", proofToken=]"; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallengeToken.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallengeToken.java new file mode 100644 index 00000000..beaaa802 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallengeToken.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; + +/** One-time plaintext proof plus the digest that may be persisted. */ +public final class OrganizationDomainChallengeToken { + + private final String plaintext; + private final String digest; + + private OrganizationDomainChallengeToken(String plaintext, String digest) { + this.plaintext = requireNonBlank(plaintext, "plaintext"); + this.digest = requireNonBlank(digest, "digest"); + } + + public static OrganizationDomainChallengeToken issued(String plaintext, String digest) { + return new OrganizationDomainChallengeToken(plaintext, digest); + } + + public String plaintext() { + return plaintext; + } + + public String digest() { + return digest; + } + + @Override + public String toString() { + return "OrganizationDomainChallengeToken[redacted]"; + } + + private static String requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException( + "error.organization.domain.challenge.invalid", + field + ); + } + return value; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallengeTokenService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallengeTokenService.java new file mode 100644 index 00000000..2b6b888d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainChallengeTokenService.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.organization; + +/** Generates and verifies one-time ownership proofs without exposing hash mechanics to domain code. */ +public interface OrganizationDomainChallengeTokenService { + + OrganizationDomainChallengeToken issue(); + + boolean matches(String candidate, String storedDigest); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainLookupService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainLookupService.java new file mode 100644 index 00000000..a6a218e4 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainLookupService.java @@ -0,0 +1,32 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.Optional; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Fail-closed lookup used by login discovery and verified-email correlation. */ +@Service +public class OrganizationDomainLookupService { + + private final OrganizationDomainRepository domainRepository; + private final OrganizationRepository organizationRepository; + + public OrganizationDomainLookupService( + OrganizationDomainRepository domainRepository, + OrganizationRepository organizationRepository + ) { + this.domainRepository = domainRepository; + this.organizationRepository = organizationRepository; + } + + @Transactional(readOnly = true) + public Optional findActiveVerifiedOwnership(String domain) { + String normalized = OrganizationDomain.normalize(domain); + return domainRepository.findVerifiedByDomain(normalized) + .filter(claim -> claim.getStatus() == OrganizationDomainStatus.VERIFIED) + .filter(claim -> organizationRepository.findById(claim.getOrganizationId()) + .filter(organization -> + organization.getStatus() == OrganizationStatus.ACTIVE) + .isPresent()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainProofLookupException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainProofLookupException.java new file mode 100644 index 00000000..7eeee8fc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainProofLookupException.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; + +/** Safe error for bounded DNS failures; deliberately carries no record value or token. */ +public class OrganizationDomainProofLookupException extends LocalizedDomainException { + + public OrganizationDomainProofLookupException() { + super("error.organization.domain.dns-unavailable"); + } + + @Override + public int statusCode() { + return 503; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainProofResolver.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainProofResolver.java new file mode 100644 index 00000000..7d4a53dd --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainProofResolver.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.List; + +/** Remote DNS port used by the application layer before opening the verification transaction. */ +public interface OrganizationDomainProofResolver { + + List resolveTxt(String recordName); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainRepository.java new file mode 100644 index 00000000..86871982 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainRepository.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.Optional; + +/** Tenant-scoped persistence port with one explicit verified-ownership lookup. */ +public interface OrganizationDomainRepository { + + Optional findByOrganizationIdAndId( + String organizationId, + String domainId + ); + + Optional findByOrganizationIdAndDomain( + String organizationId, + String domain + ); + + Optional findVerifiedByDomain(String domain); + + /** Serializes identity association with verified-domain disable or transfer operations. */ + Optional findVerifiedByDomainForUpdate(String domain); + + OrganizationDomain save(OrganizationDomain domain); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainService.java new file mode 100644 index 00000000..7a35a4c8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainService.java @@ -0,0 +1,210 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.time.Instant; +import java.util.Collections; +import java.util.Objects; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Owns organization-domain challenge, verification, conflict and disable transitions. */ +@Service +public class OrganizationDomainService { + + private final OrganizationRepository organizationRepository; + private final OrganizationDomainRepository domainRepository; + private final OrganizationAuthorizationService authorizationService; + private final OrganizationDomainChallengeTokenService tokenService; + + public OrganizationDomainService( + OrganizationRepository organizationRepository, + OrganizationDomainRepository domainRepository, + OrganizationAuthorizationService authorizationService, + OrganizationDomainChallengeTokenService tokenService + ) { + this.organizationRepository = organizationRepository; + this.domainRepository = domainRepository; + this.authorizationService = authorizationService; + this.tokenService = tokenService; + } + + @Transactional + public OrganizationDomainChallenge issueChallenge( + String organizationId, + String domain, + OrganizationDomainVerificationMethod method, + String actorUserId, + Instant occurredAt + ) { + String scopedOrganizationId = requireNonBlank(organizationId, "organizationId"); + String requiredActorUserId = requireNonBlank(actorUserId, "actorUserId"); + String normalizedDomain = OrganizationDomain.normalize(domain); + OrganizationDomainVerificationMethod requiredMethod = + Objects.requireNonNull(method, "method"); + Instant challengeTime = Objects.requireNonNull(occurredAt, "occurredAt"); + + authorizationService.requireAllowed( + scopedOrganizationId, + requiredActorUserId, + OrganizationAdministrativeAction.MANAGE_DOMAINS + ); + requireActiveOrganization(scopedOrganizationId); + rejectVerifiedOwner(scopedOrganizationId, normalizedDomain); + + OrganizationDomainChallengeToken token = tokenService.issue(); + OrganizationDomain claim = domainRepository + .findByOrganizationIdAndDomain(scopedOrganizationId, normalizedDomain) + .map(existing -> { + existing.reissueChallenge( + requiredMethod, + token.digest(), + challengeTime + ); + return existing; + }) + .orElseGet(() -> OrganizationDomain.claim( + scopedOrganizationId, + normalizedDomain, + requiredMethod, + token.digest(), + challengeTime + )); + OrganizationDomain saved = domainRepository.save(claim); + return new OrganizationDomainChallenge( + saved.getId(), + saved.getDomain(), + saved.getVerificationMethod(), + token.plaintext() + ); + } + + @Transactional + public OrganizationDomain verify( + String organizationId, + String domainId, + String observedProof, + String actorUserId, + Instant occurredAt + ) { + return verifyAny( + organizationId, + domainId, + Collections.singletonList(observedProof), + actorUserId, + occurredAt + ); + } + + @Transactional + public OrganizationDomain verifyAny( + String organizationId, + String domainId, + Iterable observedProofs, + String actorUserId, + Instant occurredAt + ) { + String scopedOrganizationId = requireNonBlank(organizationId, "organizationId"); + String requiredDomainId = requireNonBlank(domainId, "domainId"); + String requiredActorUserId = requireNonBlank(actorUserId, "actorUserId"); + + authorizationService.requireAllowed( + scopedOrganizationId, + requiredActorUserId, + OrganizationAdministrativeAction.MANAGE_DOMAINS + ); + requireActiveOrganization(scopedOrganizationId); + OrganizationDomain claim = getClaim(scopedOrganizationId, requiredDomainId); + if (claim.getStatus() == OrganizationDomainStatus.VERIFIED) { + return claim; + } + if (claim.getStatus() != OrganizationDomainStatus.PENDING) { + throw new DomainBadRequestException( + "error.organization.domain.transition.invalid" + ); + } + boolean matched = false; + if (observedProofs != null) { + for (String observedProof : observedProofs) { + if (tokenService.matches(observedProof, claim.challengeDigest())) { + matched = true; + break; + } + } + } + if (!matched) { + throw new DomainConflictException( + "error.organization.domain.proof-mismatch" + ); + } + rejectVerifiedOwner(scopedOrganizationId, claim.getDomain()); + claim.verify(Objects.requireNonNull(occurredAt, "occurredAt")); + return domainRepository.save(claim); + } + + @Transactional + public OrganizationDomain disable( + String organizationId, + String domainId, + String actorUserId, + Instant occurredAt + ) { + String scopedOrganizationId = requireNonBlank(organizationId, "organizationId"); + String requiredDomainId = requireNonBlank(domainId, "domainId"); + String requiredActorUserId = requireNonBlank(actorUserId, "actorUserId"); + + authorizationService.requireAllowed( + scopedOrganizationId, + requiredActorUserId, + OrganizationAdministrativeAction.MANAGE_DOMAINS + ); + requireActiveOrganization(scopedOrganizationId); + OrganizationDomain claim = getClaim(scopedOrganizationId, requiredDomainId); + if (!claim.disable(Objects.requireNonNull(occurredAt, "occurredAt"))) { + return claim; + } + return domainRepository.save(claim); + } + + private void rejectVerifiedOwner(String organizationId, String domain) { + domainRepository.findVerifiedByDomain(domain).ifPresent(owner -> { + if (owner.getOrganizationId().equals(organizationId)) { + throw new DomainConflictException( + "error.organization.domain.already-verified" + ); + } + throw new DomainConflictException( + "error.organization.domain.ownership-conflict" + ); + }); + } + + private OrganizationDomain getClaim(String organizationId, String domainId) { + return domainRepository.findByOrganizationIdAndId(organizationId, domainId) + .orElseThrow(() -> new DomainNotFoundException( + "error.organization.domain.not-found" + )); + } + + private Organization requireActiveOrganization(String organizationId) { + Organization organization = organizationRepository.findById(organizationId) + .orElseThrow(() -> new DomainNotFoundException( + "error.organization.not-found" + )); + if (organization.getStatus() != OrganizationStatus.ACTIVE) { + throw new DomainConflictException("error.organization.inactive"); + } + return organization; + } + + private static String requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException( + "error.organization.domain.field.required", + field + ); + } + return value; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainStatus.java new file mode 100644 index 00000000..8c69ce09 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainStatus.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.organization; + +/** Lifecycle of a claimed enterprise domain. */ +public enum OrganizationDomainStatus { + PENDING, + VERIFIED, + DISABLED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainVerificationMethod.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainVerificationMethod.java new file mode 100644 index 00000000..7f6a6da2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationDomainVerificationMethod.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.domain.organization; + +/** Supported proof mechanisms for organization domain ownership. */ +public enum OrganizationDomainVerificationMethod { + DNS_TXT +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembership.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembership.java new file mode 100644 index 00000000..0c6f7dba --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembership.java @@ -0,0 +1,295 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Membership aggregate kept separate from the globally login-capable platform account. */ +@Entity +@Table(name = "organization_membership") +public class OrganizationMembership { + + @Id + @Column(length = 64) + private String id; + + @Column(name = "organization_id", nullable = false, length = 64) + private String organizationId; + + @Column(name = "user_id", length = 128) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OrganizationMembershipStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 32) + private MembershipSourceType sourceType; + + @Column(name = "source_id", length = 256) + private String sourceId; + + @Column(name = "display_name", length = 128) + private String displayName; + + @Column(name = "primary_email", length = 256) + private String primaryEmail; + + @Column(length = 256) + private String department; + + @Column(name = "employee_number", length = 128) + private String employeeNumber; + + @Column(name = "authority_version", nullable = false) + private long authorityVersion; + + @Column(name = "activated_at") + private Instant activatedAt; + + @Column(name = "suspended_at") + private Instant suspendedAt; + + @Column(name = "deprovisioned_at") + private Instant deprovisionedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long version; + + protected OrganizationMembership() { + } + + private OrganizationMembership( + String organizationId, + OrganizationMembershipStatus status, + MembershipSourceType sourceType, + String sourceId, + String displayName, + String primaryEmail, + Instant createdAt + ) { + this.id = UUID.randomUUID().toString(); + this.organizationId = requireNonBlank(organizationId, "organizationId"); + this.status = Objects.requireNonNull(status, "status"); + this.sourceType = Objects.requireNonNull(sourceType, "sourceType"); + this.sourceId = optionalText(sourceId); + this.displayName = optionalText(displayName); + this.primaryEmail = optionalText(primaryEmail); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.updatedAt = createdAt; + } + + public static OrganizationMembership provisioned( + String organizationId, + MembershipSourceType sourceType, + String sourceId, + String displayName, + String primaryEmail, + Instant createdAt + ) { + return new OrganizationMembership( + organizationId, + OrganizationMembershipStatus.PROVISIONED, + sourceType, + sourceId, + displayName, + primaryEmail, + createdAt + ); + } + + public static OrganizationMembership invited( + String organizationId, + String invitationId, + String displayName, + String primaryEmail, + Instant createdAt + ) { + return new OrganizationMembership( + organizationId, + OrganizationMembershipStatus.INVITED, + MembershipSourceType.INVITATION, + invitationId, + displayName, + primaryEmail, + createdAt + ); + } + + public void activate(String platformUserId, Instant occurredAt) { + String requestedUserId = requireNonBlank(platformUserId, "userId"); + if (status == OrganizationMembershipStatus.ACTIVE) { + if (!requestedUserId.equals(userId)) { + throw invalidTransition(OrganizationMembershipStatus.ACTIVE); + } + return; + } + if (status != OrganizationMembershipStatus.PROVISIONED + && status != OrganizationMembershipStatus.INVITED + && status != OrganizationMembershipStatus.SUSPENDED) { + throw invalidTransition(OrganizationMembershipStatus.ACTIVE); + } + if (userId != null && !requestedUserId.equals(userId)) { + throw invalidTransition(OrganizationMembershipStatus.ACTIVE); + } + Instant transitionTime = requireCurrentOrLater(occurredAt); + userId = requestedUserId; + status = OrganizationMembershipStatus.ACTIVE; + activatedAt = transitionTime; + suspendedAt = null; + authorityVersion++; + updatedAt = transitionTime; + } + + public void suspend(Instant occurredAt) { + if (status == OrganizationMembershipStatus.SUSPENDED) { + return; + } + if (status != OrganizationMembershipStatus.ACTIVE) { + throw invalidTransition(OrganizationMembershipStatus.SUSPENDED); + } + Instant transitionTime = requireCurrentOrLater(occurredAt); + status = OrganizationMembershipStatus.SUSPENDED; + suspendedAt = transitionTime; + authorityVersion++; + updatedAt = transitionTime; + } + + public void deprovision(Instant occurredAt) { + if (status == OrganizationMembershipStatus.DEPROVISIONED) { + return; + } + Instant transitionTime = requireCurrentOrLater(occurredAt); + status = OrganizationMembershipStatus.DEPROVISIONED; + deprovisionedAt = transitionTime; + authorityVersion++; + updatedAt = transitionTime; + } + + @PrePersist + void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(Clock.systemUTC()); + } + if (updatedAt == null) { + updatedAt = createdAt; + } + } + + private Instant requireCurrentOrLater(Instant occurredAt) { + Instant transitionTime = Objects.requireNonNull(occurredAt, "occurredAt"); + if (transitionTime.isBefore(updatedAt)) { + throw new DomainBadRequestException("error.organization.membership.transition.stale"); + } + return transitionTime; + } + + private DomainBadRequestException invalidTransition(OrganizationMembershipStatus target) { + return new DomainBadRequestException( + "error.organization.membership.transition.invalid", + status, + target + ); + } + + private static String requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException("error.organization.membership.field.required", field); + } + return value; + } + + private static String optionalText(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + public String getId() { + return id; + } + + public String getOrganizationId() { + return organizationId; + } + + public String getUserId() { + return userId; + } + + public OrganizationMembershipStatus getStatus() { + return status; + } + + public MembershipSourceType getSourceType() { + return sourceType; + } + + public String getSourceId() { + return sourceId; + } + + public String getDisplayName() { + return displayName; + } + + public String getPrimaryEmail() { + return primaryEmail; + } + + public String getDepartment() { + return department; + } + + public String getEmployeeNumber() { + return employeeNumber; + } + + public long getAuthorityVersion() { + return authorityVersion; + } + + public Instant getActivatedAt() { + return activatedAt; + } + + public Instant getSuspendedAt() { + return suspendedAt; + } + + public Instant getDeprovisionedAt() { + return deprovisionedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getVersion() { + return version; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipRepository.java new file mode 100644 index 00000000..1002a0e0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipRepository.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.List; +import java.util.Optional; + +/** Tenant-scoped repository port for organization membership persistence. */ +public interface OrganizationMembershipRepository { + + Optional findByOrganizationIdAndId( + String organizationId, + String membershipId + ); + + Optional findCurrentByOrganizationIdAndUserId( + String organizationId, + String userId + ); + + Optional findCurrentByOrganizationIdAndSource( + String organizationId, + MembershipSourceType sourceType, + String sourceId + ); + + /** + * Returns at most two current/history rows: zero is absent, one is unique and two is ambiguous. + * Inactive rows participate so login cannot bypass suspension or deprovisioning via JIT. + */ + List findIdentityCorrelationCandidatesByPrimaryEmail( + String organizationId, + String email + ); + + OrganizationMembership save(OrganizationMembership membership); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipService.java new file mode 100644 index 00000000..ad2d0ca7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipService.java @@ -0,0 +1,216 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.time.Instant; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Owns manual membership creation and guarded administrator-driven lifecycle changes. */ +@Service +public class OrganizationMembershipService { + + private final OrganizationRepository organizationRepository; + private final OrganizationMembershipRepository membershipRepository; + private final OrganizationRoleBindingRepository roleBindingRepository; + private final UserAccountRepository userAccountRepository; + private final OrganizationAuthorizationService authorizationService; + + public OrganizationMembershipService( + OrganizationRepository organizationRepository, + OrganizationMembershipRepository membershipRepository, + OrganizationRoleBindingRepository roleBindingRepository, + UserAccountRepository userAccountRepository, + OrganizationAuthorizationService authorizationService + ) { + this.organizationRepository = organizationRepository; + this.membershipRepository = membershipRepository; + this.roleBindingRepository = roleBindingRepository; + this.userAccountRepository = userAccountRepository; + this.authorizationService = authorizationService; + } + + @Transactional + public OrganizationMembership addManualMember( + String organizationId, + String targetUserId, + String actorUserId, + Instant occurredAt + ) { + authorize(organizationId, actorUserId); + requireActiveOrganization(organizationId); + UserAccount account = requireEligibleAccount(targetUserId); + membershipRepository.findCurrentByOrganizationIdAndUserId( + organizationId, + targetUserId + ).ifPresent(existing -> { + throw new DomainConflictException( + "error.organization.membership.already-exists" + ); + }); + + OrganizationMembership membership = OrganizationMembership.provisioned( + organizationId, + MembershipSourceType.MANUAL, + targetUserId, + account.getDisplayName(), + account.getEmail(), + occurredAt + ); + membership.activate(targetUserId, occurredAt); + return membershipRepository.save(membership); + } + + @Transactional + public OrganizationMembership preProvisionMember( + String organizationId, + String displayName, + String primaryEmail, + String actorUserId, + Instant occurredAt + ) { + authorize(organizationId, actorUserId); + requireActiveOrganization(organizationId); + OrganizationMembership membership = OrganizationMembership.provisioned( + organizationId, + MembershipSourceType.MANUAL, + null, + displayName, + primaryEmail, + occurredAt + ); + return membershipRepository.save(membership); + } + + @Transactional + public OrganizationMembership suspend( + String organizationId, + String membershipId, + String actorUserId, + Instant occurredAt + ) { + authorize(organizationId, actorUserId); + OrganizationMembership membership = getMembership(organizationId, membershipId); + if (membership.getStatus() == OrganizationMembershipStatus.SUSPENDED) { + return membership; + } + requireNoActiveOwnerRole(organizationId, membership); + membership.suspend(occurredAt); + return membershipRepository.save(membership); + } + + @Transactional + public OrganizationMembership reactivate( + String organizationId, + String membershipId, + String actorUserId, + Instant occurredAt + ) { + authorize(organizationId, actorUserId); + requireActiveOrganization(organizationId); + OrganizationMembership membership = getMembership(organizationId, membershipId); + UserAccount account = requireEligibleAccount(membership.getUserId()); + membership.activate(account.getId(), occurredAt); + return membershipRepository.save(membership); + } + + @Transactional + public OrganizationMembership deprovision( + String organizationId, + String membershipId, + String actorUserId, + Instant occurredAt + ) { + authorize(organizationId, actorUserId); + OrganizationMembership membership = getMembership(organizationId, membershipId); + if (membership.getStatus() == OrganizationMembershipStatus.DEPROVISIONED) { + return membership; + } + requireNoActiveOwnerRole(organizationId, membership); + + List bindings = membership.getUserId() == null + ? List.of() + : roleBindingRepository.findActiveByOrganizationIdAndUserId( + organizationId, + membership.getUserId() + ); + if (!bindings.isEmpty()) { + Organization organization = getOrganization(organizationId); + bindings.forEach(binding -> { + binding.revoke(actorUserId, occurredAt); + roleBindingRepository.save(binding); + }); + organization.recordRoleBindingChange(occurredAt); + organizationRepository.save(organization); + } + membership.deprovision(occurredAt); + return membershipRepository.save(membership); + } + + private void authorize(String organizationId, String actorUserId) { + authorizationService.requireAllowed( + organizationId, + actorUserId, + OrganizationAdministrativeAction.MANAGE_MEMBERS + ); + } + + private void requireNoActiveOwnerRole( + String organizationId, + OrganizationMembership membership + ) { + if (membership.getUserId() == null) { + return; + } + boolean hasActiveOwnerRole = roleBindingRepository.findActiveByOrganizationIdAndUserId( + organizationId, + membership.getUserId() + ).stream().anyMatch(binding -> binding.getRole() == OrganizationRole.ORG_OWNER); + if (hasActiveOwnerRole) { + throw new DomainConflictException( + "error.organization.membership.owner-role-active" + ); + } + } + + private UserAccount requireEligibleAccount(String userId) { + if (userId == null || userId.isBlank()) { + throw new DomainConflictException( + "error.organization.membership.account-not-eligible" + ); + } + UserAccount account = userAccountRepository.findById(userId) + .orElseThrow(() -> new DomainNotFoundException( + "error.organization.membership.account-not-found" + )); + if (!account.isActive() + || account.isSystemAccount() + || account.getMergedToUserId() != null) { + throw new DomainConflictException( + "error.organization.membership.account-not-eligible" + ); + } + return account; + } + + private void requireActiveOrganization(String organizationId) { + if (getOrganization(organizationId).getStatus() != OrganizationStatus.ACTIVE) { + throw new DomainConflictException("error.organization.inactive"); + } + } + + private OrganizationMembership getMembership(String organizationId, String membershipId) { + return membershipRepository.findByOrganizationIdAndId(organizationId, membershipId) + .orElseThrow(() -> new DomainNotFoundException( + "error.organization.membership.not-found" + )); + } + + private Organization getOrganization(String organizationId) { + return organizationRepository.findById(organizationId) + .orElseThrow(() -> new DomainNotFoundException("error.organization.not-found")); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipStatus.java new file mode 100644 index 00000000..ed16d169 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationMembershipStatus.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.organization; + +/** Lifecycle state of a person inside one enterprise tenant. */ +public enum OrganizationMembershipStatus { + INVITED, + PROVISIONED, + ACTIVE, + SUSPENDED, + DEPROVISIONED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRepository.java new file mode 100644 index 00000000..905d00d8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRepository.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.Optional; + +/** Domain repository port for organization aggregate persistence. */ +public interface OrganizationRepository { + + Optional findById(String id); + + /** Serializes identity association with Organization lifecycle changes. */ + Optional findByIdForUpdate(String id); + + Optional findBySlug(String slug); + + Organization save(Organization organization); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRole.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRole.java new file mode 100644 index 00000000..fe690254 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRole.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.organization; + +/** Least-privilege administrative roles scoped to one Organization. */ +public enum OrganizationRole { + ORG_OWNER, + IDENTITY_ADMIN, + LOGIN_SECRET_ADMIN, + MEMBER_ADMIN, + ORG_AUDITOR +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBinding.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBinding.java new file mode 100644 index 00000000..4e36e0a8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBinding.java @@ -0,0 +1,177 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** Auditable assignment of one administrative role within one Organization. */ +@Entity +@Table(name = "organization_role_binding") +public class OrganizationRoleBinding { + + @Id + @Column(length = 64) + private String id; + + @Column(name = "organization_id", nullable = false, length = 64) + private String organizationId; + + @Column(name = "user_id", nullable = false, length = 128) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OrganizationRole role; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OrganizationRoleBindingStatus status; + + @Column(name = "created_by", nullable = false, length = 128) + private String createdBy; + + @Column(name = "revoked_by", length = 128) + private String revokedBy; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long version; + + protected OrganizationRoleBinding() { + } + + private OrganizationRoleBinding( + String organizationId, + String userId, + OrganizationRole role, + String createdBy, + Instant createdAt + ) { + this.id = UUID.randomUUID().toString(); + this.organizationId = requireNonBlank(organizationId, "organizationId"); + this.userId = requireNonBlank(userId, "userId"); + this.role = Objects.requireNonNull(role, "role"); + this.status = OrganizationRoleBindingStatus.ACTIVE; + this.createdBy = requireNonBlank(createdBy, "createdBy"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt"); + this.updatedAt = createdAt; + } + + public static OrganizationRoleBinding grant( + String organizationId, + String userId, + OrganizationRole role, + String createdBy, + Instant createdAt + ) { + return new OrganizationRoleBinding( + organizationId, + userId, + role, + createdBy, + createdAt + ); + } + + /** Returns true only when this call performs the terminal state transition. */ + public boolean revoke(String actorId, Instant occurredAt) { + if (status == OrganizationRoleBindingStatus.REVOKED) { + return false; + } + String revocationActor = requireNonBlank(actorId, "revokedBy"); + Instant transitionTime = Objects.requireNonNull(occurredAt, "occurredAt"); + if (transitionTime.isBefore(updatedAt)) { + throw new DomainBadRequestException( + "error.organization.role-binding.transition.stale" + ); + } + status = OrganizationRoleBindingStatus.REVOKED; + revokedBy = revocationActor; + revokedAt = transitionTime; + updatedAt = transitionTime; + return true; + } + + @PrePersist + void prePersist() { + if (createdAt == null) { + createdAt = Instant.now(Clock.systemUTC()); + } + if (updatedAt == null) { + updatedAt = createdAt; + } + } + + private static String requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException( + "error.organization.role-binding.field.required", + field + ); + } + return value; + } + + public String getId() { + return id; + } + + public String getOrganizationId() { + return organizationId; + } + + public String getUserId() { + return userId; + } + + public OrganizationRole getRole() { + return role; + } + + public OrganizationRoleBindingStatus getStatus() { + return status; + } + + public String getCreatedBy() { + return createdBy; + } + + public String getRevokedBy() { + return revokedBy; + } + + public Instant getRevokedAt() { + return revokedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public long getVersion() { + return version; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingRepository.java new file mode 100644 index 00000000..4b205fc4 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingRepository.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.domain.organization; + +import java.util.List; +import java.util.Optional; + +/** Tenant-scoped repository port for Organization administrative role bindings. */ +public interface OrganizationRoleBindingRepository { + + Optional findByOrganizationIdAndId( + String organizationId, + String bindingId + ); + + List findActiveByOrganizationIdAndUserId( + String organizationId, + String userId + ); + + Optional findActiveByOrganizationIdAndUserIdAndRole( + String organizationId, + String userId, + OrganizationRole role + ); + + long countActiveByOrganizationIdAndRole( + String organizationId, + OrganizationRole role + ); + + OrganizationRoleBinding save(OrganizationRoleBinding binding); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingService.java new file mode 100644 index 00000000..989cea37 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingService.java @@ -0,0 +1,139 @@ +package com.iflytek.skillhub.domain.organization; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.time.Instant; +import java.util.Objects; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Enforces membership, authorization and last-owner invariants for role changes. */ +@Service +public class OrganizationRoleBindingService { + + private final OrganizationRepository organizationRepository; + private final OrganizationMembershipRepository membershipRepository; + private final OrganizationRoleBindingRepository roleBindingRepository; + private final OrganizationAuthorizationService authorizationService; + + public OrganizationRoleBindingService( + OrganizationRepository organizationRepository, + OrganizationMembershipRepository membershipRepository, + OrganizationRoleBindingRepository roleBindingRepository, + OrganizationAuthorizationService authorizationService + ) { + this.organizationRepository = organizationRepository; + this.membershipRepository = membershipRepository; + this.roleBindingRepository = roleBindingRepository; + this.authorizationService = authorizationService; + } + + @Transactional + public OrganizationRoleBinding grant( + String organizationId, + String targetUserId, + OrganizationRole role, + String actorUserId, + Instant occurredAt + ) { + String scopedOrganizationId = requireNonBlank(organizationId, "organizationId"); + String requiredTargetUserId = requireNonBlank(targetUserId, "targetUserId"); + String requiredActorUserId = requireNonBlank(actorUserId, "actorUserId"); + OrganizationRole requiredRole = Objects.requireNonNull(role, "role"); + requireRoleAdministrator(scopedOrganizationId, requiredActorUserId); + requireActiveMember(scopedOrganizationId, requiredTargetUserId); + var existing = roleBindingRepository.findActiveByOrganizationIdAndUserIdAndRole( + scopedOrganizationId, + requiredTargetUserId, + requiredRole + ); + if (existing.isPresent()) { + return existing.get(); + } + + Organization organization = getOrganization(scopedOrganizationId); + OrganizationRoleBinding binding = OrganizationRoleBinding.grant( + scopedOrganizationId, + requiredTargetUserId, + requiredRole, + requiredActorUserId, + occurredAt + ); + organization.recordRoleBindingChange(occurredAt); + OrganizationRoleBinding saved = roleBindingRepository.save(binding); + organizationRepository.save(organization); + return saved; + } + + @Transactional + public OrganizationRoleBinding revoke( + String organizationId, + String bindingId, + String actorUserId, + Instant occurredAt + ) { + String scopedOrganizationId = requireNonBlank(organizationId, "organizationId"); + String requiredBindingId = requireNonBlank(bindingId, "bindingId"); + String requiredActorUserId = requireNonBlank(actorUserId, "actorUserId"); + requireRoleAdministrator(scopedOrganizationId, requiredActorUserId); + OrganizationRoleBinding binding = roleBindingRepository + .findByOrganizationIdAndId(scopedOrganizationId, requiredBindingId) + .orElseThrow(() -> new DomainNotFoundException( + "error.organization.role-binding.not-found" + )); + if (binding.getStatus() == OrganizationRoleBindingStatus.REVOKED) { + return binding; + } + if (binding.getRole() == OrganizationRole.ORG_OWNER + && roleBindingRepository.countActiveByOrganizationIdAndRole( + scopedOrganizationId, + OrganizationRole.ORG_OWNER + ) <= 1) { + throw new DomainConflictException("error.organization.role-binding.last-owner"); + } + + Organization organization = getOrganization(scopedOrganizationId); + binding.revoke(requiredActorUserId, occurredAt); + organization.recordRoleBindingChange(occurredAt); + OrganizationRoleBinding saved = roleBindingRepository.save(binding); + organizationRepository.save(organization); + return saved; + } + + private void requireRoleAdministrator(String organizationId, String actorUserId) { + authorizationService.requireAllowed( + organizationId, + actorUserId, + OrganizationAdministrativeAction.MANAGE_ORGANIZATION_ROLES + ); + } + + private void requireActiveMember(String organizationId, String targetUserId) { + boolean active = membershipRepository + .findCurrentByOrganizationIdAndUserId(organizationId, targetUserId) + .filter(membership -> + membership.getStatus() == OrganizationMembershipStatus.ACTIVE) + .isPresent(); + if (!active) { + throw new DomainConflictException( + "error.organization.role-binding.target-not-active" + ); + } + } + + private Organization getOrganization(String organizationId) { + return organizationRepository.findById(organizationId) + .orElseThrow(() -> new DomainNotFoundException("error.organization.not-found")); + } + + private String requireNonBlank(String value, String field) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException( + "error.organization.role-binding.field.required", + field + ); + } + return value; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingStatus.java new file mode 100644 index 00000000..ddfa6c7d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationRoleBindingStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.organization; + +/** Lifecycle state of an Organization-scoped administrative role binding. */ +public enum OrganizationRoleBindingStatus { + ACTIVE, + REVOKED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationStatus.java new file mode 100644 index 00000000..491440cc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationStatus.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.organization; + +/** Lifecycle state of an enterprise tenant boundary. */ +public enum OrganizationStatus { + ACTIVE, + SUSPENDED, + DECOMMISSIONED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/package-info.java new file mode 100644 index 00000000..5836d9c0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/package-info.java @@ -0,0 +1,2 @@ +/** Enterprise organization, membership and tenant-bound policy domain model. */ +package com.iflytek.skillhub.domain.organization; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/JndiOrganizationDomainProofResolver.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/JndiOrganizationDomainProofResolver.java new file mode 100644 index 00000000..58ca3446 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/JndiOrganizationDomainProofResolver.java @@ -0,0 +1,99 @@ +package com.iflytek.skillhub.infra.identity; + +import com.iflytek.skillhub.domain.organization.OrganizationDomainProofLookupException; +import com.iflytek.skillhub.domain.organization.OrganizationDomainProofResolver; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.List; +import javax.naming.Context; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.NameNotFoundException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.InitialDirContext; +import org.springframework.stereotype.Component; + +/** Bounded JNDI DNS adapter used only from the application's non-transactional verification step. */ +@Component +public class JndiOrganizationDomainProofResolver + implements OrganizationDomainProofResolver { + + private static final String TXT_ATTRIBUTE = "TXT"; + private static final String INITIAL_TIMEOUT_MILLIS = "2000"; + private static final String RETRIES = "1"; + + private final TxtLookup lookup; + + public JndiOrganizationDomainProofResolver() { + this(JndiOrganizationDomainProofResolver::lookupWithJndi); + } + + JndiOrganizationDomainProofResolver(TxtLookup lookup) { + this.lookup = lookup; + } + + @Override + public List resolveTxt(String recordName) { + try { + Attributes attributes = lookup.lookup(recordName); + Attribute txt = attributes == null ? null : attributes.get(TXT_ATTRIBUTE); + if (txt == null) { + return List.of(); + } + List records = new ArrayList<>(); + NamingEnumeration values = txt.getAll(); + try { + while (values.hasMore()) { + String normalized = normalizeTxtValue(values.next()); + if (!normalized.isBlank()) { + records.add(normalized); + } + } + } finally { + values.close(); + } + return List.copyOf(records); + } catch (NameNotFoundException exception) { + return List.of(); + } catch (NamingException exception) { + throw new OrganizationDomainProofLookupException(); + } + } + + private static Attributes lookupWithJndi(String recordName) throws NamingException { + Hashtable environment = new Hashtable<>(); + environment.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); + environment.put("com.sun.jndi.dns.timeout.initial", INITIAL_TIMEOUT_MILLIS); + environment.put("com.sun.jndi.dns.timeout.retries", RETRIES); + InitialDirContext context = new InitialDirContext(environment); + try { + return context.getAttributes(recordName, new String[]{TXT_ATTRIBUTE}); + } finally { + context.close(); + } + } + + static String normalizeTxtValue(Object value) { + String text = String.valueOf(value).trim(); + if (!text.contains("\"")) { + return text; + } + StringBuilder normalized = new StringBuilder(text.length()); + boolean insideQuotedSegment = false; + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (current == '\"') { + insideQuotedSegment = !insideQuotedSegment; + } else if (insideQuotedSegment || !Character.isWhitespace(current)) { + normalized.append(current); + } + } + return normalized.toString(); + } + + @FunctionalInterface + interface TxtLookup { + Attributes lookup(String recordName) throws NamingException; + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/SecureOrganizationDomainChallengeTokenService.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/SecureOrganizationDomainChallengeTokenService.java new file mode 100644 index 00000000..7049723d --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/SecureOrganizationDomainChallengeTokenService.java @@ -0,0 +1,63 @@ +package com.iflytek.skillhub.infra.identity; + +import com.iflytek.skillhub.domain.organization.OrganizationDomainChallengeToken; +import com.iflytek.skillhub.domain.organization.OrganizationDomainChallengeTokenService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +/** CSPRNG-backed DNS proof generator that persists only SHA-256 digests. */ +@Component +public class SecureOrganizationDomainChallengeTokenService + implements OrganizationDomainChallengeTokenService { + + private static final int TOKEN_BYTES = 32; + private static final int MAX_CANDIDATE_LENGTH = 1024; + + private final SecureRandom secureRandom; + + public SecureOrganizationDomainChallengeTokenService() { + this(new SecureRandom()); + } + + SecureOrganizationDomainChallengeTokenService(SecureRandom secureRandom) { + this.secureRandom = secureRandom; + } + + @Override + public OrganizationDomainChallengeToken issue() { + byte[] entropy = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(entropy); + String plaintext = Base64.getUrlEncoder().withoutPadding().encodeToString(entropy); + return OrganizationDomainChallengeToken.issued(plaintext, sha256(plaintext)); + } + + @Override + public boolean matches(String candidate, String storedDigest) { + if (candidate == null + || candidate.isBlank() + || candidate.length() > MAX_CANDIDATE_LENGTH + || storedDigest == null + || storedDigest.isBlank()) { + return false; + } + byte[] actual = sha256(candidate).getBytes(StandardCharsets.US_ASCII); + byte[] expected = storedDigest.getBytes(StandardCharsets.US_ASCII); + return MessageDigest.isEqual(actual, expected); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex( + digest.digest(value.getBytes(StandardCharsets.UTF_8)) + ); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is required by the Java runtime", exception); + } + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/package-info.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/package-info.java new file mode 100644 index 00000000..ffca7c66 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/identity/package-info.java @@ -0,0 +1,2 @@ +/** Infrastructure implementations for enterprise identity cryptographic primitives. */ +package com.iflytek.skillhub.infra.identity; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationDomainRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationDomainRepositoryAdapter.java new file mode 100644 index 00000000..a41fbe4b --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationDomainRepositoryAdapter.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.OrganizationDomain; +import com.iflytek.skillhub.domain.organization.OrganizationDomainRepository; +import com.iflytek.skillhub.domain.organization.OrganizationDomainStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +/** JPA adapter with tenant-scoped reads and stable domain ownership conflicts. */ +@Repository +public class JpaOrganizationDomainRepositoryAdapter + implements OrganizationDomainRepository { + + private static final String CLAIM_CONSTRAINT = "uk_organization_domain_claim"; + private static final String VERIFIED_OWNER_CONSTRAINT = + "uk_organization_domain_verified_owner"; + + private final OrganizationDomainSpringDataRepository delegate; + + public JpaOrganizationDomainRepositoryAdapter( + OrganizationDomainSpringDataRepository delegate + ) { + this.delegate = delegate; + } + + @Override + public Optional findByOrganizationIdAndId( + String organizationId, + String domainId + ) { + return delegate.findByOrganizationIdAndId(organizationId, domainId); + } + + @Override + public Optional findByOrganizationIdAndDomain( + String organizationId, + String domain + ) { + return delegate.findByOrganizationIdAndDomain(organizationId, domain); + } + + @Override + public Optional findVerifiedByDomain(String domain) { + return delegate.findByDomainAndStatus(domain, OrganizationDomainStatus.VERIFIED); + } + + @Override + public Optional findVerifiedByDomainForUpdate(String domain) { + return delegate.findVerifiedOwnershipForUpdate( + domain, + OrganizationDomainStatus.VERIFIED + ); + } + + @Override + public OrganizationDomain save(OrganizationDomain domain) { + try { + return delegate.saveAndFlush(domain); + } catch (DataIntegrityViolationException exception) { + String constraintName = constraintName(exception); + if (CLAIM_CONSTRAINT.equalsIgnoreCase(constraintName)) { + throw new DomainConflictException( + "error.organization.domain.claim-conflict" + ); + } + if (VERIFIED_OWNER_CONSTRAINT.equalsIgnoreCase(constraintName)) { + throw new DomainConflictException( + "error.organization.domain.ownership-conflict" + ); + } + throw exception; + } + } + + private String constraintName(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ConstraintViolationException violation) { + return violation.getConstraintName(); + } + current = current.getCause(); + } + return null; + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationMembershipRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationMembershipRepositoryAdapter.java new file mode 100644 index 00000000..c0394f58 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationMembershipRepositoryAdapter.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.MembershipSourceType; +import com.iflytek.skillhub.domain.organization.OrganizationMembership; +import com.iflytek.skillhub.domain.organization.OrganizationMembershipRepository; +import com.iflytek.skillhub.domain.organization.OrganizationMembershipStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** JPA adapter that exposes only organization-scoped membership reads to domain callers. */ +@Repository +public class JpaOrganizationMembershipRepositoryAdapter + implements OrganizationMembershipRepository { + + private final OrganizationMembershipSpringDataRepository delegate; + + public JpaOrganizationMembershipRepositoryAdapter( + OrganizationMembershipSpringDataRepository delegate + ) { + this.delegate = delegate; + } + + @Override + public Optional findByOrganizationIdAndId( + String organizationId, + String membershipId + ) { + return delegate.findByOrganizationIdAndId(organizationId, membershipId); + } + + @Override + public Optional findCurrentByOrganizationIdAndUserId( + String organizationId, + String userId + ) { + return delegate.findByOrganizationIdAndUserIdAndStatusNot( + organizationId, + userId, + OrganizationMembershipStatus.DEPROVISIONED + ); + } + + @Override + public Optional findCurrentByOrganizationIdAndSource( + String organizationId, + MembershipSourceType sourceType, + String sourceId + ) { + return delegate.findByOrganizationIdAndSourceTypeAndSourceIdAndStatusNot( + organizationId, + sourceType, + sourceId, + OrganizationMembershipStatus.DEPROVISIONED + ); + } + + @Override + public List findIdentityCorrelationCandidatesByPrimaryEmail( + String organizationId, + String email + ) { + return delegate.findTop2ByOrganizationIdAndPrimaryEmailIgnoreCaseOrderById( + organizationId, + email + ); + } + + @Override + public OrganizationMembership save(OrganizationMembership membership) { + return delegate.save(membership); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationRepositoryAdapter.java new file mode 100644 index 00000000..69662bbc --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationRepositoryAdapter.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.Organization; +import com.iflytek.skillhub.domain.organization.OrganizationRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +/** JPA adapter for the organization aggregate repository port. */ +@Repository +public class JpaOrganizationRepositoryAdapter implements OrganizationRepository { + + private static final String SLUG_CONSTRAINT = "uk_organization_slug"; + + private final OrganizationSpringDataRepository delegate; + + public JpaOrganizationRepositoryAdapter(OrganizationSpringDataRepository delegate) { + this.delegate = delegate; + } + + @Override + public Optional findById(String id) { + return delegate.findById(id); + } + + @Override + public Optional findByIdForUpdate(String id) { + return delegate.findByIdForUpdate(id); + } + + @Override + public Optional findBySlug(String slug) { + return delegate.findBySlug(slug); + } + + @Override + public Organization save(Organization organization) { + try { + return delegate.saveAndFlush(organization); + } catch (DataIntegrityViolationException exception) { + if (SLUG_CONSTRAINT.equalsIgnoreCase(constraintName(exception))) { + throw new DomainConflictException("error.organization.slug.conflict"); + } + throw exception; + } + } + + private String constraintName(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof ConstraintViolationException violation) { + return violation.getConstraintName(); + } + current = current.getCause(); + } + return null; + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationRoleBindingRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationRoleBindingRepositoryAdapter.java new file mode 100644 index 00000000..57d460af --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaOrganizationRoleBindingRepositoryAdapter.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.OrganizationRole; +import com.iflytek.skillhub.domain.organization.OrganizationRoleBinding; +import com.iflytek.skillhub.domain.organization.OrganizationRoleBindingRepository; +import com.iflytek.skillhub.domain.organization.OrganizationRoleBindingStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** JPA adapter exposing only tenant-scoped Organization role-binding operations. */ +@Repository +public class JpaOrganizationRoleBindingRepositoryAdapter + implements OrganizationRoleBindingRepository { + + private final OrganizationRoleBindingSpringDataRepository delegate; + + public JpaOrganizationRoleBindingRepositoryAdapter( + OrganizationRoleBindingSpringDataRepository delegate + ) { + this.delegate = delegate; + } + + @Override + public Optional findByOrganizationIdAndId( + String organizationId, + String bindingId + ) { + return delegate.findByOrganizationIdAndId(organizationId, bindingId); + } + + @Override + public List findActiveByOrganizationIdAndUserId( + String organizationId, + String userId + ) { + return delegate.findByOrganizationIdAndUserIdAndStatus( + organizationId, + userId, + OrganizationRoleBindingStatus.ACTIVE + ); + } + + @Override + public Optional findActiveByOrganizationIdAndUserIdAndRole( + String organizationId, + String userId, + OrganizationRole role + ) { + return delegate.findByOrganizationIdAndUserIdAndRoleAndStatus( + organizationId, + userId, + role, + OrganizationRoleBindingStatus.ACTIVE + ); + } + + @Override + public long countActiveByOrganizationIdAndRole( + String organizationId, + OrganizationRole role + ) { + return delegate.countByOrganizationIdAndRoleAndStatus( + organizationId, + role, + OrganizationRoleBindingStatus.ACTIVE + ); + } + + @Override + public OrganizationRoleBinding save(OrganizationRoleBinding binding) { + return delegate.save(binding); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationDomainSpringDataRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationDomainSpringDataRepository.java new file mode 100644 index 00000000..becc44a4 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationDomainSpringDataRepository.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.OrganizationDomain; +import com.iflytek.skillhub.domain.organization.OrganizationDomainStatus; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** Internal Spring Data delegate for organization-domain persistence. */ +interface OrganizationDomainSpringDataRepository + extends JpaRepository { + + Optional findByOrganizationIdAndId( + String organizationId, + String domainId + ); + + Optional findByOrganizationIdAndDomain( + String organizationId, + String domain + ); + + Optional findByDomainAndStatus( + String domain, + OrganizationDomainStatus status + ); + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query(""" + select domain + from OrganizationDomain domain + where domain.domain = :domain and domain.status = :status + """) + Optional findVerifiedOwnershipForUpdate( + @Param("domain") String domain, + @Param("status") OrganizationDomainStatus status + ); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationMembershipSpringDataRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationMembershipSpringDataRepository.java new file mode 100644 index 00000000..c5fa974a --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationMembershipSpringDataRepository.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.MembershipSourceType; +import com.iflytek.skillhub.domain.organization.OrganizationMembership; +import com.iflytek.skillhub.domain.organization.OrganizationMembershipStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Internal Spring Data delegate that keeps every membership lookup tenant scoped. */ +interface OrganizationMembershipSpringDataRepository + extends JpaRepository { + + Optional findByOrganizationIdAndId( + String organizationId, + String membershipId + ); + + Optional findByOrganizationIdAndUserIdAndStatusNot( + String organizationId, + String userId, + OrganizationMembershipStatus excludedStatus + ); + + Optional findByOrganizationIdAndSourceTypeAndSourceIdAndStatusNot( + String organizationId, + MembershipSourceType sourceType, + String sourceId, + OrganizationMembershipStatus excludedStatus + ); + + List findTop2ByOrganizationIdAndPrimaryEmailIgnoreCaseOrderById( + String organizationId, + String email + ); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationRoleBindingSpringDataRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationRoleBindingSpringDataRepository.java new file mode 100644 index 00000000..cf525648 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationRoleBindingSpringDataRepository.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.OrganizationRole; +import com.iflytek.skillhub.domain.organization.OrganizationRoleBinding; +import com.iflytek.skillhub.domain.organization.OrganizationRoleBindingStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Internal Spring Data delegate; every role-binding query includes its Organization. */ +interface OrganizationRoleBindingSpringDataRepository + extends JpaRepository { + + Optional findByOrganizationIdAndId( + String organizationId, + String bindingId + ); + + List findByOrganizationIdAndUserIdAndStatus( + String organizationId, + String userId, + OrganizationRoleBindingStatus status + ); + + Optional findByOrganizationIdAndUserIdAndRoleAndStatus( + String organizationId, + String userId, + OrganizationRole role, + OrganizationRoleBindingStatus status + ); + + long countByOrganizationIdAndRoleAndStatus( + String organizationId, + OrganizationRole role, + OrganizationRoleBindingStatus status + ); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationSpringDataRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationSpringDataRepository.java new file mode 100644 index 00000000..1598488f --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/OrganizationSpringDataRepository.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.organization.Organization; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** Internal Spring Data delegate; domain callers use {@link JpaOrganizationRepositoryAdapter}. */ +interface OrganizationSpringDataRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select organization from Organization organization where organization.id = :id") + Optional findByIdForUpdate(@Param("id") String id); + + Optional findBySlug(String slug); +}