Merge pull request #902 from iflytek/feature/enterprise-login-r1b2-org-admin

feat(auth): add organization creation control plane slice
This commit is contained in:
XiaoSeS 2026-09-24 15:58:57 +08:00 • committed by GitHub
commit 455cbb5ba5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 610 additions and 6 deletions

View file

@ -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 内闭合,

View file

@ -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 或审计数据。

View file

@ -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** 新版本使用默认配置启动

View file

@ -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

View file

@ -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 控制面分别另行交付、验证与合并。

View file

@ -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)。

View file

@ -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 连接配置或匿名登录

View file

@ -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 与预览环境;完成评审和合并前验收。

View file

@ -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()));
}
}

View file

@ -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
) {
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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=Отображаемое имя не может быть пустым

View file

@ -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 个字符之间

View file

@ -0,0 +1,265 @@
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;
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.AuditLogService;
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 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;
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.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
@ActiveProfiles("test")
@Transactional
class PlatformOrganizationControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private EntityManager entityManager;
@Autowired
private TransactionTemplate transactionTemplate;
@Autowired
private PlatformOrganizationCreateAppService appService;
@SpyBean
private AuditLogService auditLogService;
@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<OrganizationMembership> 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<OrganizationRoleBinding> 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<AuditLog> 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);
}
@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);
entityManager.flush();
return user;
}
private long organizationCount() {
entityManager.flush();
return entityManager.createQuery("select count(o) from Organization o", Long.class)
.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 + "\"}";
}
private UsernamePasswordAuthenticationToken auth(String userId, String... roles) {
Set<String> platformRoles = Set.of(roles);
PlatformPrincipal principal = new PlatformPrincipal(
userId, userId, userId + "@example.com", "", "github", platformRoles);
List<SimpleGrantedAuthority> authorities = platformRoles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
}
}

View file

@ -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; }

View file

@ -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)));
}
}

View file

@ -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;