mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat(auth): add organization creation control plane slice
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
39a7081ff4
commit
52e4e052a1
15 changed files with 544 additions and 0 deletions
|
|
@ -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 控制面分别另行交付、验证与合并。
|
||||
|
|
@ -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)。
|
||||
|
|
@ -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** 其访问新组织的租户详情接口被拒绝
|
||||
11
openspec/changes/enterprise-identity-r1b-org-create/tasks.md
Normal file
11
openspec/changes/enterprise-identity-r1b-org-create/tasks.md
Normal 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 通过。
|
||||
- [ ] 检查最终 diff、敏感信息、精确 SHA 与预览环境;经人工评审后再提 PR/合并。
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=Отображаемое имя не может быть пустым
|
||||
|
|
|
|||
|
|
@ -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 个字符之间
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
web/src/api/generated/schema.d.ts
vendored
64
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue