From 52e4e052a155be8849ac2a3ec2574f27d14ec061 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:43:17 +0800 Subject: [PATCH 1/3] feat(auth): add organization creation control plane slice Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../design.md | 9 + .../proposal.md | 19 ++ .../specs/enterprise-organizations/spec.md | 20 ++ .../tasks.md | 11 + .../admin/PlatformOrganizationController.java | 42 ++++ .../dto/OrganizationCreateRequest.java | 11 + .../dto/OrganizationCreateResponse.java | 21 ++ .../PlatformOrganizationCreateAppService.java | 80 +++++++ .../src/main/resources/messages.properties | 5 + .../src/main/resources/messages_ru.properties | 5 + .../src/main/resources/messages_zh.properties | 5 + .../PlatformOrganizationControllerTest.java | 215 ++++++++++++++++++ .../skillhub/domain/audit/AuditLog.java | 25 ++ .../domain/audit/AuditLogService.java | 12 + web/src/api/generated/schema.d.ts | 64 ++++++ 15 files changed, 544 insertions(+) create mode 100644 openspec/changes/enterprise-identity-r1b-org-create/design.md create mode 100644 openspec/changes/enterprise-identity-r1b-org-create/proposal.md create mode 100644 openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md create mode 100644 openspec/changes/enterprise-identity-r1b-org-create/tasks.md create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PlatformOrganizationCreateAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java diff --git a/openspec/changes/enterprise-identity-r1b-org-create/design.md b/openspec/changes/enterprise-identity-r1b-org-create/design.md new file mode 100644 index 00000000..a45cc694 --- /dev/null +++ b/openspec/changes/enterprise-identity-r1b-org-create/design.md @@ -0,0 +1,9 @@ +## Boundary + +平台管理 API 只处理创建。租户内读写继续由 Organization Membership 和组织角色授权,平台 `SUPER_ADMIN` 不在租户 API 获得隐式通行权。 + +控制器做请求校验和平台角色校验;应用服务编排现有聚合及仓储。保存 Organization、Membership、Role Binding 和审计均位于同一事务。slug 使用现有模型校验和数据库唯一约束;并发重名由现有 JPA adapter 映射为 409。 + +审计只持久化组织 ID、操作者、动作、成功结果和 requestId,不记录用户邮箱、密钥或请求体。初始 owner 的 Membership 来源为 MANUAL;创建成功后 Organization authority version 为 1。回滚无需删除数据:本批只有新 API,停用入口或回退应用版本即可,已创建组织保留。 + +本批只用现有 schema,不新增 Flyway migration。后续成员/角色管理和 Login Connection 控制面分别另行交付、验证与合并。 diff --git a/openspec/changes/enterprise-identity-r1b-org-create/proposal.md b/openspec/changes/enterprise-identity-r1b-org-create/proposal.md new file mode 100644 index 00000000..ea4fabaf --- /dev/null +++ b/openspec/changes/enterprise-identity-r1b-org-create/proposal.md @@ -0,0 +1,19 @@ +## Why + +R1-B 的只读 Organization API 已合入,但平台管理员还不能创建测试 Organization 并指定初始负责人。没有这一入口,后续成员管理与 Login Connection 控制面无法独立验收。 + +## What Changes + +- 仅 `SUPER_ADMIN` 可创建 Organization,并指定一个已存在、ACTIVE、非系统、未合并的平台账号为初始 owner。 +- 在一个事务中创建 Organization、ACTIVE Membership、`ORG_OWNER` role binding 和组织审计记录。 +- 返回新组织的基本标识和状态;平台管理员不会自动取得该组织成员身份。 + +## 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)。 diff --git a/openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md b/openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md new file mode 100644 index 00000000..eb9da420 --- /dev/null +++ b/openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md @@ -0,0 +1,20 @@ +## 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 和组织审计记录。创建者不得因其平台角色自动成为组织成员。 + +#### 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** 其访问新组织的租户详情接口被拒绝 diff --git a/openspec/changes/enterprise-identity-r1b-org-create/tasks.md b/openspec/changes/enterprise-identity-r1b-org-create/tasks.md new file mode 100644 index 00000000..03e00d80 --- /dev/null +++ b/openspec/changes/enterprise-identity-r1b-org-create/tasks.md @@ -0,0 +1,11 @@ +## Implementation + +- [x] 新增平台级 Organization 创建 API,验证初始 owner,原子写入组织、成员、角色和审计。 +- [x] 保持平台角色与租户成员权限分离,不开放企业登录与组织同步。 +- [x] 从当前分支应用运行结果生成并提交 OpenAPI 类型。 + +## Verification + +- [x] 集成测试覆盖成功、仅 owner 可进入组织详情、非管理员、禁用/不存在 owner 和重复 slug。 +- [x] 完整后端测试、Web typecheck/lint 和 OpenSpec strict validation 通过。 +- [ ] 检查最终 diff、敏感信息、精确 SHA 与预览环境;经人工评审后再提 PR/合并。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationController.java new file mode 100644 index 00000000..72ceb770 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationController.java @@ -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 create( + @Valid @RequestBody OrganizationCreateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok("response.success.created", appService.create(request, principal.userId())); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateRequest.java new file mode 100644 index 00000000..c7c4a5ea --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateRequest.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateResponse.java new file mode 100644 index 00000000..58041347 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/OrganizationCreateResponse.java @@ -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()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PlatformOrganizationCreateAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PlatformOrganizationCreateAppService.java new file mode 100644 index 00000000..1bfd2b7d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PlatformOrganizationCreateAppService.java @@ -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); + } +} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 43059acd..b71fda79 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index e72ed9ea..6baf1a55 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -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=Отображаемое имя не может быть пустым diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 1b867375..2923cf4f 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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 个字符之间 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java new file mode 100644 index 00000000..fcc96974 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java @@ -0,0 +1,215 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLog; +import com.iflytek.skillhub.domain.organization.Organization; +import com.iflytek.skillhub.domain.organization.OrganizationMembership; +import com.iflytek.skillhub.domain.organization.OrganizationRole; +import com.iflytek.skillhub.domain.organization.OrganizationRoleBinding; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserStatus; +import jakarta.persistence.EntityManager; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class PlatformOrganizationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private EntityManager entityManager; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void openApiIncludesCreateOrganization() throws Exception { + String specification = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(specification).contains("\"/api/v1/admin/organizations\""); + assertThat(specification).contains("\"createOrganization\""); + String exportPath = System.getProperty("skillhub.openapi.export.path"); + if (exportPath != null) { + Files.writeString(Path.of(exportPath), specification); + } + } + + @Test + void create_assignsOnlyTheChosenInitialOwnerAndRecordsAudit() throws Exception { + persistUser("platform-admin"); + persistUser("initial-owner"); + + mockMvc.perform(post("/api/v1/admin/organizations") + .with(csrf()) + .with(authentication(auth("platform-admin", "SUPER_ADMIN"))) + .contentType(MediaType.APPLICATION_JSON) + .content(request("team-one", "Team One", "initial-owner"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.slug").value("team-one")) + .andExpect(jsonPath("$.data.status").value("ACTIVE")) + .andExpect(jsonPath("$.data.authorityVersion").value(1)); + + entityManager.flush(); + entityManager.clear(); + Organization organization = entityManager.createQuery( + "select o from Organization o where o.slug = :slug", Organization.class) + .setParameter("slug", "team-one") + .getSingleResult(); + List memberships = entityManager.createQuery( + "select m from OrganizationMembership m where m.organizationId = :id", + OrganizationMembership.class) + .setParameter("id", organization.getId()) + .getResultList(); + assertThat(memberships).singleElement() + .satisfies(member -> { + assertThat(member.getUserId()).isEqualTo("initial-owner"); + assertThat(member.getStatus().name()).isEqualTo("ACTIVE"); + }); + List roles = entityManager.createQuery( + "select b from OrganizationRoleBinding b where b.organizationId = :id", + OrganizationRoleBinding.class) + .setParameter("id", organization.getId()) + .getResultList(); + assertThat(roles).singleElement() + .satisfies(binding -> { + assertThat(binding.getUserId()).isEqualTo("initial-owner"); + assertThat(binding.getRole()).isEqualTo(OrganizationRole.ORG_OWNER); + }); + List audits = entityManager.createQuery( + "select a from AuditLog a where a.organizationId = :id", AuditLog.class) + .setParameter("id", organization.getId()) + .getResultList(); + assertThat(audits).singleElement() + .satisfies(audit -> { + assertThat(audit.getActorUserId()).isEqualTo("platform-admin"); + assertThat(audit.getAction()).isEqualTo("ORGANIZATION_CREATED"); + assertThat(audit.getTargetRef()).isEqualTo(organization.getId()); + assertThat(audit.getResult()).isEqualTo("SUCCESS"); + }); + + mockMvc.perform(get("/api/v1/organizations/{id}", organization.getId()) + .with(authentication(auth("platform-admin", "SUPER_ADMIN")))) + .andExpect(status().isForbidden()); + mockMvc.perform(get("/api/v1/organizations/{id}", organization.getId()) + .with(authentication(auth("initial-owner")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roles[0]").value("ORG_OWNER")); + } + + @Test + void create_rejectsNonAdminBeforeWriting() throws Exception { + persistUser("initial-owner"); + mockMvc.perform(post("/api/v1/admin/organizations") + .with(csrf()) + .with(authentication(auth("member"))) + .contentType(MediaType.APPLICATION_JSON) + .content(request("no-access", "No Access", "initial-owner"))) + .andExpect(status().isForbidden()); + assertThat(organizationCount()).isZero(); + } + + @Test + void create_rejectsIneligibleOwnerAndDuplicateSlug() throws Exception { + persistUser("platform-admin"); + persistUser("initial-owner"); + UserAccount disabled = persistUser("disabled-owner"); + disabled.setStatus(UserStatus.DISABLED); + UserAccount merged = persistUser("merged-owner"); + merged.setMergedToUserId("initial-owner"); + entityManager.persist(UserAccount.systemAccount( + "system-owner", "System", null, "")); + entityManager.flush(); + + mockMvc.perform(post("/api/v1/admin/organizations") + .with(csrf()) + .with(authentication(auth("platform-admin", "SUPER_ADMIN"))) + .contentType(MediaType.APPLICATION_JSON) + .content(request("disabled-team", "Disabled Team", "disabled-owner"))) + .andExpect(status().isConflict()); + mockMvc.perform(post("/api/v1/admin/organizations") + .with(csrf()) + .with(authentication(auth("platform-admin", "SUPER_ADMIN"))) + .contentType(MediaType.APPLICATION_JSON) + .content(request("missing-team", "Missing Team", "missing-owner"))) + .andExpect(status().isNotFound()); + for (String ineligible : List.of("merged-owner", "system-owner")) { + mockMvc.perform(post("/api/v1/admin/organizations") + .with(csrf()) + .with(authentication(auth("platform-admin", "SUPER_ADMIN"))) + .contentType(MediaType.APPLICATION_JSON) + .content(request(ineligible, "Ineligible Team", ineligible))) + .andExpect(status().isConflict()); + } + assertThat(organizationCount()).isZero(); + + mockMvc.perform(post("/api/v1/admin/organizations") + .with(csrf()) + .with(authentication(auth("platform-admin", "SUPER_ADMIN"))) + .contentType(MediaType.APPLICATION_JSON) + .content(request("team-one", "Team One", "initial-owner"))) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/admin/organizations") + .with(csrf()) + .with(authentication(auth("platform-admin", "SUPER_ADMIN"))) + .contentType(MediaType.APPLICATION_JSON) + .content(request("team-one", "Other Name", "initial-owner"))) + .andExpect(status().isConflict()); + assertThat(organizationCount()).isEqualTo(1); + } + + private UserAccount persistUser(String userId) { + UserAccount user = new UserAccount(userId, userId, userId + "@example.com", ""); + entityManager.persist(user); + entityManager.flush(); + return user; + } + + private long organizationCount() { + entityManager.flush(); + return entityManager.createQuery("select count(o) from Organization o", Long.class) + .getSingleResult(); + } + + private String request(String slug, String displayName, String owner) { + return "{\"slug\":\"" + slug + "\",\"displayName\":\"" + displayName + + "\",\"initialOwnerUserId\":\"" + owner + "\"}"; + } + + private UsernamePasswordAuthenticationToken auth(String userId, String... roles) { + Set platformRoles = Set.of(roles); + PlatformPrincipal principal = new PlatformPrincipal( + userId, userId, userId + "@example.com", "", "github", platformRoles); + List authorities = platformRoles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java index da4eef04..0e45c76e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java @@ -30,6 +30,15 @@ public class AuditLog { @Column(name = "target_id") private Long targetId; + @Column(name = "organization_id", length = 64) + private String organizationId; + + @Column(name = "target_ref", length = 128) + private String targetRef; + + @Column(name = "result", length = 32) + private String result; + @Column(name = "request_id", length = 64) private String requestId; @@ -68,11 +77,27 @@ public class AuditLog { this.createdAt = createdAt; } + public static AuditLog organizationSuccess(String actorUserId, + String action, + String organizationId, + String requestId, + Instant createdAt) { + AuditLog log = new AuditLog(actorUserId, action, "ORGANIZATION", null, + requestId, null, null, null, createdAt); + log.organizationId = organizationId; + log.targetRef = organizationId; + log.result = "SUCCESS"; + return log; + } + public Long getId() { return id; } public String getActorUserId() { return actorUserId; } public String getAction() { return action; } public String getTargetType() { return targetType; } public Long getTargetId() { return targetId; } + public String getOrganizationId() { return organizationId; } + public String getTargetRef() { return targetRef; } + public String getResult() { return result; } public String getRequestId() { return requestId; } public String getClientIp() { return clientIp; } public String getUserAgent() { return userAgent; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java index be9ef5f9..4acf02ad 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java @@ -42,4 +42,16 @@ public class AuditLogService { createdAt )); } + + @Transactional + public AuditLog recordOrganizationCreated(String actorUserId, + String organizationId, + String requestId) { + return auditLogRepository.save(AuditLog.organizationSuccess( + actorUserId, + "ORGANIZATION_CREATED", + organizationId, + requestId, + Instant.now(clock))); + } } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 2ee22f04..4fcc42fb 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2380,6 +2380,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create an organization with an initial owner */ + post: operations["createOrganization"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/namespaces/{slug}/unfreeze": { parameters: { query?: never; @@ -5768,6 +5785,29 @@ export interface components { id?: number; status?: string; }; + OrganizationCreateRequest: { + slug: string; + displayName: string; + initialOwnerUserId: string; + }; + ApiResponseOrganizationCreateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["OrganizationCreateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + OrganizationCreateResponse: { + id?: string; + slug?: string; + displayName?: string; + /** @enum {string} */ + status?: "ACTIVE" | "SUSPENDED" | "DECOMMISSIONED"; + /** Format: int64 */ + authorityVersion?: number; + }; AdminNamespaceDetailResponse: { /** Format: int64 */ id?: number; @@ -12022,6 +12062,30 @@ export interface operations { }; }; }; + createOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OrganizationCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseOrganizationCreateResponse"]; + }; + }; + }; + }; unfreezeNamespace_2: { parameters: { query?: never; From 1e2de2798b9c04c01096adeddafddab4f7e3af32 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:14:16 +0800 Subject: [PATCH 2/3] test(auth): verify organization creation rollback Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../tasks.md | 4 +- .../PlatformOrganizationControllerTest.java | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/openspec/changes/enterprise-identity-r1b-org-create/tasks.md b/openspec/changes/enterprise-identity-r1b-org-create/tasks.md index 03e00d80..a06833c6 100644 --- a/openspec/changes/enterprise-identity-r1b-org-create/tasks.md +++ b/openspec/changes/enterprise-identity-r1b-org-create/tasks.md @@ -6,6 +6,6 @@ ## Verification -- [x] 集成测试覆盖成功、仅 owner 可进入组织详情、非管理员、禁用/不存在 owner 和重复 slug。 +- [x] 集成测试覆盖成功、仅 owner 可进入组织详情、非管理员、禁用/不存在 owner、重复 slug,以及审计失败时四类写入的事务回滚。 - [x] 完整后端测试、Web typecheck/lint 和 OpenSpec strict validation 通过。 -- [ ] 检查最终 diff、敏感信息、精确 SHA 与预览环境;经人工评审后再提 PR/合并。 +- [ ] 核对 PR 最终 diff、敏感信息、精确 SHA 与预览环境;完成评审和合并前验收。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java index fcc96974..8465929b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/PlatformOrganizationControllerTest.java @@ -1,6 +1,11 @@ package com.iflytek.skillhub.controller.admin; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -10,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.audit.AuditLog; import com.iflytek.skillhub.domain.organization.Organization; import com.iflytek.skillhub.domain.organization.OrganizationMembership; @@ -17,6 +23,8 @@ import com.iflytek.skillhub.domain.organization.OrganizationRole; import com.iflytek.skillhub.domain.organization.OrganizationRoleBinding; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.OrganizationCreateRequest; +import com.iflytek.skillhub.service.PlatformOrganizationCreateAppService; import jakarta.persistence.EntityManager; import java.nio.file.Files; import java.nio.file.Path; @@ -27,12 +35,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.support.TransactionTemplate; @SpringBootTest @AutoConfigureMockMvc @@ -46,6 +57,15 @@ class PlatformOrganizationControllerTest { @Autowired private EntityManager entityManager; + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private PlatformOrganizationCreateAppService appService; + + @SpyBean + private AuditLogService auditLogService; + @MockBean private DeviceAuthService deviceAuthService; @@ -185,6 +205,31 @@ class PlatformOrganizationControllerTest { assertThat(organizationCount()).isEqualTo(1); } + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void create_rollsBackAllWritesWhenAuditFails() { + transactionTemplate.executeWithoutResult(status -> persistUser("rollback-owner")); + long organizationsBefore = count("Organization"); + long membershipsBefore = count("OrganizationMembership"); + long rolesBefore = count("OrganizationRoleBinding"); + long auditsBefore = count("AuditLog"); + + doThrow(new IllegalStateException("forced audit failure")) + .when(auditLogService).recordOrganizationCreated( + eq("platform-admin"), anyString(), any()); + + assertThatThrownBy(() -> appService.create( + new OrganizationCreateRequest("rollback-team", "Rollback Team", "rollback-owner"), + "platform-admin")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("forced audit failure"); + + assertThat(count("Organization")).isEqualTo(organizationsBefore); + assertThat(count("OrganizationMembership")).isEqualTo(membershipsBefore); + assertThat(count("OrganizationRoleBinding")).isEqualTo(rolesBefore); + assertThat(count("AuditLog")).isEqualTo(auditsBefore); + } + private UserAccount persistUser(String userId) { UserAccount user = new UserAccount(userId, userId, userId + "@example.com", ""); entityManager.persist(user); @@ -198,6 +243,11 @@ class PlatformOrganizationControllerTest { .getSingleResult(); } + private long count(String entityName) { + return entityManager.createQuery("select count(e) from " + entityName + " e", Long.class) + .getSingleResult(); + } + private String request(String slug, String displayName, String owner) { return "{\"slug\":\"" + slug + "\",\"displayName\":\"" + displayName + "\",\"initialOwnerUserId\":\"" + owner + "\"}"; From 17273bb6ca3dff384e1d6b649c5e443b0e072ba1 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:44:56 +0800 Subject: [PATCH 3/3] docs(auth): decouple organization APIs from OIDC controls Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- openspec/changes/enterprise-identity-platform/design.md | 2 +- .../changes/enterprise-identity-platform/rollout-plan.md | 6 +++--- .../specs/enterprise-identity-governance/spec.md | 2 +- .../specs/federated-authentication/spec.md | 2 +- .../changes/enterprise-identity-r1b-org-create/design.md | 2 ++ .../changes/enterprise-identity-r1b-org-create/proposal.md | 1 + .../specs/enterprise-organizations/spec.md | 7 +++++++ 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/openspec/changes/enterprise-identity-platform/design.md b/openspec/changes/enterprise-identity-platform/design.md index 0b66d0ef..20c5ecd2 100644 --- a/openspec/changes/enterprise-identity-platform/design.md +++ b/openspec/changes/enterprise-identity-platform/design.md @@ -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 内闭合, diff --git a/openspec/changes/enterprise-identity-platform/rollout-plan.md b/openspec/changes/enterprise-identity-platform/rollout-plan.md index f18f9627..ebdfd857 100644 --- a/openspec/changes/enterprise-identity-platform/rollout-plan.md +++ b/openspec/changes/enterprise-identity-platform/rollout-plan.md @@ -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,配置、测试并激活测试连接。 @@ -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 登录数据面 @@ -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 或审计数据。 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 index 850d8fff..d1173c45 100644 --- a/openspec/changes/enterprise-identity-platform/specs/enterprise-identity-governance/spec.md +++ b/openspec/changes/enterprise-identity-platform/specs/enterprise-identity-governance/spec.md @@ -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** 新版本使用默认配置启动 diff --git a/openspec/changes/enterprise-identity-platform/specs/federated-authentication/spec.md b/openspec/changes/enterprise-identity-platform/specs/federated-authentication/spec.md index 73dc0f67..a0209856 100644 --- a/openspec/changes/enterprise-identity-platform/specs/federated-authentication/spec.md +++ b/openspec/changes/enterprise-identity-platform/specs/federated-authentication/spec.md @@ -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 diff --git a/openspec/changes/enterprise-identity-r1b-org-create/design.md b/openspec/changes/enterprise-identity-r1b-org-create/design.md index a45cc694..671e9e39 100644 --- a/openspec/changes/enterprise-identity-r1b-org-create/design.md +++ b/openspec/changes/enterprise-identity-r1b-org-create/design.md @@ -2,6 +2,8 @@ 平台管理 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,停用入口或回退应用版本即可,已创建组织保留。 diff --git a/openspec/changes/enterprise-identity-r1b-org-create/proposal.md b/openspec/changes/enterprise-identity-r1b-org-create/proposal.md index ea4fabaf..8026c6c0 100644 --- a/openspec/changes/enterprise-identity-r1b-org-create/proposal.md +++ b/openspec/changes/enterprise-identity-r1b-org-create/proposal.md @@ -7,6 +7,7 @@ R1-B 的只读 Organization API 已合入,但平台管理员还不能创建测 - 仅 `SUPER_ADMIN` 可创建 Organization,并指定一个已存在、ACTIVE、非系统、未合并的平台账号为初始 owner。 - 在一个事务中创建 Organization、ACTIVE Membership、`ORG_OWNER` role binding 和组织审计记录。 - 返回新组织的基本标识和状态;平台管理员不会自动取得该组织成员身份。 +- Organization 创建与成员权限属于协议无关的租户基础能力,不受未来的企业 OIDC 连接管理或登录开关控制。 ## Non-goals diff --git a/openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md b/openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md index eb9da420..b9b2c031 100644 --- a/openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md +++ b/openspec/changes/enterprise-identity-r1b-org-create/specs/enterprise-organizations/spec.md @@ -4,6 +4,8 @@ 系统 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 @@ -18,3 +20,8 @@ - **WHEN** 创建者仅持有 `SUPER_ADMIN` 平台角色而不是新组织成员 - **THEN** 其访问新组织的租户详情接口被拒绝 + +#### Scenario: Enterprise OIDC is not enabled + +- **WHEN** 企业 OIDC 连接管理和登录均未启用,平台管理员创建 Organization +- **THEN** 创建和初始 owner 的组织读取仍按各自权限正常工作,不开放 OIDC 连接配置或匿名登录