Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openspec/changes/enterprise-identity-platform/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ OIDC client secret 不写入 typed configuration、API 响应、审计详情或

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。
动态企业 OIDC 把连接管理开关、匿名登录数据面开关和 Organization allowlist 分开。Organization、Membership 和 Role 是协议无关的租户基础能力,不受 OIDC 开关控制。连接管理开关只控制 OIDC Login Connection 的配置、探测和激活等操作;成员范围的连接摘要读取仍按组织权限校验。连接管理可以先启用,同时保持匿名 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 内闭合,
Expand Down
6 changes: 3 additions & 3 deletions openspec/changes/enterprise-identity-platform/rollout-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ V54–V60 是一条已经联合验证的 expand-only 迁移链:

1. 保持 R1-A 已验收的 identity core 模式。
2. 注入 Secret keyring 和固定的 OIDC public base URI 等运行配置。
3. 设置 `SKILLHUB_ENTERPRISE_OIDC_ENABLED=true`,只装配控制面。
3. Organization 创建、成员与角色管理按自身权限开放,不依赖 OIDC 开关;需要配置和测试 OIDC Login Connection 时,设置 `SKILLHUB_ENTERPRISE_OIDC_ENABLED=true`。此开关在后续连接管理批次实现,不是本批 Organization 创建 API 的运行前提。
4. 明确保持 `SKILLHUB_ENTERPRISE_OIDC_LOGIN_ENABLED=false`。
5. 保持 `SKILLHUB_ENTERPRISE_ORGANIZATION_ALLOWLIST` 为空。
6. 只创建测试 Organization,配置、测试并激活测试连接。
Expand All @@ -178,7 +178,7 @@ V54–V60 是一条已经联合验证的 expand-only 迁移链:

#### 回滚

设置 `SKILLHUB_ENTERPRISE_OIDC_ENABLED=false`,关闭控制面组件。已创建的 Organization、Connection、revision、Secret 和审计记录保留,不影响现有公共登录。
设置 `SKILLHUB_ENTERPRISE_OIDC_ENABLED=false`,关闭 OIDC Login Connection 的配置、测试和激活等操作,而不是关闭 Organization 基础 API。现有成员范围的连接摘要仍可按组织权限读取。已创建的 Organization、Membership、Connection、revision、Secret 和审计记录保留,不影响现有公共登录或其他协议后续接入。

### 4.3 R1-C:动态 OIDC 登录数据面

Expand Down Expand Up @@ -224,7 +224,7 @@ V54–V60 是一条已经联合验证的 expand-only 迁移链:
1. 从 Organization allowlist 移除受影响组织。
2. 设置 `SKILLHUB_ENTERPRISE_OIDC_LOGIN_ENABLED=false`。
3. 如问题位于统一身份核心,再将 `SKILLHUB_IDENTITY_CORE_MODE` 切回 `LEGACY`。
4. 必要时最后关闭 `SKILLHUB_ENTERPRISE_OIDC_ENABLED` 控制面。
4. 必要时最后关闭 `SKILLHUB_ENTERPRISE_OIDC_ENABLED` 所控制的 OIDC 连接配置操作;Organization 基础 API 不随之关闭。

回滚不删除 Organization、Membership、Connection、External Identity、Session origin 或审计数据。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Organization、Membership、Domain、Role Binding、Login Connection、revision

### Requirement: Rollout controls are independent and observable

身份核心模式、动态企业 OIDC 控制面开关、匿名登录数据面开关和 Organization allowlist SHALL 独立配置;默认 SHALL 为 legacy-compatible 且企业 OIDC 控制面与登录数据面关闭。系统 SHALL 暴露不含身份数据的启用状态、连接健康和固定类别指标。
身份核心模式、动态企业 OIDC 连接管理开关、匿名登录数据面开关和 Organization allowlist SHALL 独立配置;默认 SHALL 为 legacy-compatible 且企业 OIDC 连接管理与登录数据面关闭。Organization、Membership 和 Role 的基础 API SHALL 独立于这些 OIDC 开关,继续按平台或组织权限授权。关闭连接管理开关 SHALL 阻断 OIDC 连接的配置、测试和激活等操作,但不影响已授权成员读取非敏感连接摘要。系统 SHALL 暴露不含身份数据的启用状态、连接健康和固定类别指标。

#### Scenario: Candidate is deployed with defaults
- **WHEN** 新版本使用默认配置启动
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ local login、公共 OAuth、CLI Device Flow 和 API Token SHALL 保留既有路

### Requirement: Dynamic enterprise OIDC is disabled by default

动态企业 OIDC SHALL 仅在身份核心对目标 Organization 为 `ACTIVE`、OIDC 登录数据面开关启用且目标 Organization 位于 allowlist 时公开和执行;控制面开关只决定连接管理组件是否装配,不得隐式开放匿名登录。默认配置 SHALL 不改变现有登录。
动态企业 OIDC SHALL 仅在身份核心对目标 Organization 为 `ACTIVE`、OIDC 登录数据面开关启用且目标 Organization 位于 allowlist 时公开和执行;连接管理开关只决定 OIDC 连接配置、测试和激活等操作是否可用,不得关闭 Organization 基础 API 或隐式开放匿名登录。默认配置 SHALL 不改变现有登录。

#### Scenario: Global switch is disabled
- **WHEN** operator 未开启动态企业 OIDC
Expand Down
11 changes: 11 additions & 0 deletions openspec/changes/enterprise-identity-r1b-org-create/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Boundary

平台管理 API 只处理创建。租户内读写继续由 Organization Membership 和组织角色授权,平台 `SUPER_ADMIN` 不在租户 API 获得隐式通行权。

Organization 及 Membership 是协议无关的租户基础能力。本批创建接口和已合入的成员范围只读接口不依赖企业 OIDC 开关;后续 OIDC 开关仅控制 OIDC Login Connection 的配置、测试和激活等操作,匿名登录另由数据面开关控制。现有成员范围的连接摘要读取仍按组织权限校验,不因关闭 OIDC 配置操作而失效。本批不实现这些开关。

控制器做请求校验和平台角色校验;应用服务编排现有聚合及仓储。保存 Organization、Membership、Role Binding 和审计均位于同一事务。slug 使用现有模型校验和数据库唯一约束;并发重名由现有 JPA adapter 映射为 409。

审计只持久化组织 ID、操作者、动作、成功结果和 requestId,不记录用户邮箱、密钥或请求体。初始 owner 的 Membership 来源为 MANUAL;创建成功后 Organization authority version 为 1。回滚无需删除数据:本批只有新 API,停用入口或回退应用版本即可,已创建组织保留。

本批只用现有 schema,不新增 Flyway migration。后续成员/角色管理和 Login Connection 控制面分别另行交付、验证与合并。
20 changes: 20 additions & 0 deletions openspec/changes/enterprise-identity-r1b-org-create/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Why

R1-B 的只读 Organization API 已合入,但平台管理员还不能创建测试 Organization 并指定初始负责人。没有这一入口,后续成员管理与 Login Connection 控制面无法独立验收。

## What Changes

- 仅 `SUPER_ADMIN` 可创建 Organization,并指定一个已存在、ACTIVE、非系统、未合并的平台账号为初始 owner。
- 在一个事务中创建 Organization、ACTIVE Membership、`ORG_OWNER` role binding 和组织审计记录。
- 返回新组织的基本标识和状态;平台管理员不会自动取得该组织成员身份。
- Organization 创建与成员权限属于协议无关的租户基础能力,不受未来的企业 OIDC 连接管理或登录开关控制。

## Non-goals

- 不增加成员、角色、域名或 Login Connection 的管理接口或页面。
- 不开放企业登录、自动识别组织、SCIM、目录同步或 Namespace 授权。
- 不修改现有数据库 schema、默认登录模式或公开 Provider 行为。

## Impact

新增 `POST /api/v1/admin/organizations` 与相应 OpenAPI 类型;使用现有 Organization、Membership、Role Binding 和 audit_log 表。参见 [总体分批计划](../enterprise-identity-platform/rollout-plan.md)。
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## ADDED Requirements

### Requirement: Platform administrator creates an organization with an initial owner

系统 SHALL 只允许 `SUPER_ADMIN` 使用唯一 slug、显示名和一个已有且可用的平台账号创建 Organization。创建 SHALL 在单个事务中生成 ACTIVE Organization、该账号的 ACTIVE Membership、`ORG_OWNER` role binding 和组织审计记录。创建者不得因其平台角色自动成为组织成员。

Organization 创建 SHALL 不依赖企业 OIDC 连接管理或登录开关;即使未启用企业 OIDC,已获授权的管理员仍可创建组织,成员仍可按组织权限读取组织信息。

#### Scenario: Successful creation

- **WHEN** 平台管理员指定有效 slug、显示名和 ACTIVE 的初始 owner
- **THEN** 系统创建以上四类记录,返回组织 ID 与状态,并使初始 owner 可以访问组织详情

#### Scenario: Invalid owner or duplicate slug

- **WHEN** 初始 owner 不存在、禁用、为系统账号、已合并,或 slug 已被占用
- **THEN** 系统返回对应的 404 或 409,且不留下部分 Organization、Membership、Role Binding 或成功审计

#### Scenario: Platform role is not tenant membership

- **WHEN** 创建者仅持有 `SUPER_ADMIN` 平台角色而不是新组织成员
- **THEN** 其访问新组织的租户详情接口被拒绝

#### Scenario: Enterprise OIDC is not enabled

- **WHEN** 企业 OIDC 连接管理和登录均未启用,平台管理员创建 Organization
- **THEN** 创建和初始 owner 的组织读取仍按各自权限正常工作,不开放 OIDC 连接配置或匿名登录
11 changes: 11 additions & 0 deletions openspec/changes/enterprise-identity-r1b-org-create/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Implementation

- [x] 新增平台级 Organization 创建 API,验证初始 owner,原子写入组织、成员、角色和审计。
- [x] 保持平台角色与租户成员权限分离,不开放企业登录与组织同步。
- [x] 从当前分支应用运行结果生成并提交 OpenAPI 类型。

## Verification

- [x] 集成测试覆盖成功、仅 owner 可进入组织详情、非管理员、禁用/不存在 owner、重复 slug,以及审计失败时四类写入的事务回滚。
- [x] 完整后端测试、Web typecheck/lint 和 OpenSpec strict validation 通过。
- [ ] 核对 PR 最终 diff、敏感信息、精确 SHA 与预览环境;完成评审和合并前验收。
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.iflytek.skillhub.controller.admin;

import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.OrganizationCreateRequest;
import com.iflytek.skillhub.dto.OrganizationCreateResponse;
import com.iflytek.skillhub.service.PlatformOrganizationCreateAppService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/admin/organizations")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public class PlatformOrganizationController extends BaseApiController {

private final PlatformOrganizationCreateAppService appService;

public PlatformOrganizationController(
PlatformOrganizationCreateAppService appService,
ApiResponseFactory responseFactory
) {
super(responseFactory);
this.appService = appService;
}

@PostMapping
@Operation(operationId = "createOrganization", summary = "Create an organization with an initial owner")
public ApiResponse<OrganizationCreateResponse> create(
@Valid @RequestBody OrganizationCreateRequest request,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.created", appService.create(request, principal.userId()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record OrganizationCreateRequest(
@NotBlank @Size(max = 64) String slug,
@NotBlank @Size(max = 128) String displayName,
@NotBlank @Size(max = 128) String initialOwnerUserId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.iflytek.skillhub.dto;

import com.iflytek.skillhub.domain.organization.Organization;
import com.iflytek.skillhub.domain.organization.OrganizationStatus;

public record OrganizationCreateResponse(
String id,
String slug,
String displayName,
OrganizationStatus status,
long authorityVersion
) {
public static OrganizationCreateResponse from(Organization organization) {
return new OrganizationCreateResponse(
organization.getId(),
organization.getSlug(),
organization.getDisplayName(),
organization.getStatus(),
organization.getAuthorityVersion());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.iflytek.skillhub.service;

import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.organization.MembershipSourceType;
import com.iflytek.skillhub.domain.organization.Organization;
import com.iflytek.skillhub.domain.organization.OrganizationMembership;
import com.iflytek.skillhub.domain.organization.OrganizationMembershipRepository;
import com.iflytek.skillhub.domain.organization.OrganizationRepository;
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.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 com.iflytek.skillhub.dto.OrganizationCreateRequest;
import com.iflytek.skillhub.dto.OrganizationCreateResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/** Creates the tenant boundary and its first owner as one transaction. */
@Service
public class PlatformOrganizationCreateAppService {

private final OrganizationRepository organizationRepository;
private final OrganizationMembershipRepository membershipRepository;
private final OrganizationRoleBindingRepository roleBindingRepository;
private final UserAccountRepository userAccountRepository;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
private final Clock clock;

public PlatformOrganizationCreateAppService(
OrganizationRepository organizationRepository,
OrganizationMembershipRepository membershipRepository,
OrganizationRoleBindingRepository roleBindingRepository,
UserAccountRepository userAccountRepository,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor,
Clock clock
) {
this.organizationRepository = organizationRepository;
this.membershipRepository = membershipRepository;
this.roleBindingRepository = roleBindingRepository;
this.userAccountRepository = userAccountRepository;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
this.clock = clock;
}

@Transactional
public OrganizationCreateResponse create(OrganizationCreateRequest request, String actorUserId) {
UserAccount owner = userAccountRepository.findById(request.initialOwnerUserId())
.orElseThrow(() -> new DomainNotFoundException("error.organization.owner.not-found"));
if (!owner.isActive() || owner.isSystemAccount() || owner.getMergedToUserId() != null) {
throw new DomainConflictException("error.organization.owner.not-eligible");
}
organizationRepository.findBySlug(request.slug()).ifPresent(existing -> {
throw new DomainConflictException("error.organization.slug.conflict");
});

Instant now = clock.instant();
Organization organization = organizationRepository.save(Organization.create(
request.slug(), request.displayName(), actorUserId, now));
OrganizationMembership membership = OrganizationMembership.provisioned(
organization.getId(), MembershipSourceType.MANUAL, owner.getId(),
owner.getDisplayName(), owner.getEmail(), now);
membership.activate(owner.getId(), now);
membershipRepository.save(membership);
roleBindingRepository.save(OrganizationRoleBinding.grant(
organization.getId(), owner.getId(), OrganizationRole.ORG_OWNER, actorUserId, now));
organization.recordRoleBindingChange(now);
organizationRepository.save(organization);
auditLogService.recordOrganizationCreated(actorUserId, organization.getId(), requestIdAccessor.current());
return OrganizationCreateResponse.from(organization);
}
}
5 changes: 5 additions & 0 deletions server/skillhub-app/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ response.success.deleted=Deleted successfully
response.success.published=Published successfully
response.success.revoked=Revoked successfully
response.success.health=Service is up
error.organization.field.required=Organization {0} is required
error.organization.slug.invalid=Organization slug must be 2-64 lowercase letters, numbers, or hyphens
error.organization.slug.conflict=An organization with this slug already exists
error.organization.owner.not-found=Initial organization owner was not found
error.organization.owner.not-eligible=Initial organization owner must be an active user account

validation.namespace.slug.notBlank=Slug cannot be blank
validation.namespace.slug.size=Slug must be between 2 and 64 characters
Expand Down
5 changes: 5 additions & 0 deletions server/skillhub-app/src/main/resources/messages_ru.properties
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ response.success.deleted=Успешно удалено
response.success.published=Успешно опубликовано
response.success.revoked=Успешно отозвано
response.success.health=Сервис работает
error.organization.field.required=Поле организации {0} обязательно
error.organization.slug.invalid=Идентификатор организации: 2–64 строчные буквы, цифры или дефисы
error.organization.slug.conflict=Организация с таким идентификатором уже существует
error.organization.owner.not-found=Первоначальный владелец организации не найден
error.organization.owner.not-eligible=Первоначальный владелец должен иметь активную учётную запись
validation.namespace.slug.notBlank=Slug не может быть пустым
validation.namespace.slug.size=Slug должен содержать от 2 до 64 символов
validation.namespace.displayName.notBlank=Отображаемое имя не может быть пустым
Expand Down
Loading
Loading