From cceb8ce0d30ced6b5d7508c73eaaae9ebffa64f7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:23:28 +0800 Subject: [PATCH] feat(identity): add enterprise connection management --- .../EnterpriseConnectionController.java | 150 ++++++ .../dto/ConfirmedConnectionActionRequest.java | 9 + .../dto/LoginConnectionCreateRequest.java | 26 + .../dto/LoginConnectionHealthResponse.java | 10 + .../skillhub/dto/LoginConnectionResponse.java | 19 + .../LoginConnectionRevisionCreateRequest.java | 21 + .../dto/LoginConnectionRevisionResponse.java | 16 + .../LoginConnectionSecretSummaryResponse.java | 10 + .../dto/LoginConnectionTestResponse.java | 11 + .../EnterpriseConnectionQueryRepository.java | 179 +++++++ .../EnterpriseConnectionAppService.java | 492 ++++++++++++++++++ .../OrganizationAdminControllerTest.java | 92 ++++ ...terpriseConnectionQueryRepositoryTest.java | 107 ++++ ...ginConnectionLifecyclePersistenceTest.java | 4 + .../EnterpriseConnectionAppServiceTest.java | 161 ++++++ .../LoginConnectionConfigurationInput.java | 19 + .../LoginConnectionControlAdapter.java | 18 + ...LoginConnectionControlAdapterRegistry.java | 39 ++ .../control/LoginConnectionTestException.java | 18 + .../LoginConnectionTestFailureReason.java | 14 + .../PreparedLoginConnectionRevision.java | 41 ++ .../oidc/OidcConnectionMetadataProbe.java | 10 + .../OidcLoginConnectionControlAdapter.java | 209 ++++++++ .../EnterpriseOidcRuntimeConfiguration.java | 310 +++++++++++ .../auth/operation/IdentityOperation.java | 180 +++++++ .../IdentityOperationRepository.java | 13 + ...IdentityOperationSpringDataRepository.java | 13 + .../operation/IdentityOperationStatus.java | 8 + ...JpaIdentityOperationRepositoryAdapter.java | 44 ++ .../skillhub/auth/operation/package-info.java | 2 + ...nConnectionControlAdapterRegistryTest.java | 60 +++ ...OidcLoginConnectionControlAdapterTest.java | 170 ++++++ .../domain/audit/OrganizationAuditAction.java | 9 +- .../domain/audit/OrganizationAuditEvent.java | 21 + .../audit/OrganizationAuditTargetType.java | 4 +- .../OrganizationAuthorizationPolicy.java | 8 + .../OrganizationAuthorizationPolicyTest.java | 8 + 37 files changed, 2523 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/EnterpriseConnectionController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmedConnectionActionRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionCreateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionHealthResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionCreateRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSecretSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionTestResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/EnterpriseConnectionAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepositoryTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/EnterpriseConnectionAppServiceTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionConfigurationInput.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapter.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistry.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestException.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestFailureReason.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/PreparedLoginConnectionRevision.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcConnectionMetadataProbe.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapter.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseOidcRuntimeConfiguration.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperation.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationSpringDataRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationStatus.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/JpaIdentityOperationRepositoryAdapter.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/package-info.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistryTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapterTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/EnterpriseConnectionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/EnterpriseConnectionController.java new file mode 100644 index 00000000..f6401a8e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/EnterpriseConnectionController.java @@ -0,0 +1,150 @@ +package com.iflytek.skillhub.controller.portal; + +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.ConfirmedConnectionActionRequest; +import com.iflytek.skillhub.dto.LoginConnectionCreateRequest; +import com.iflytek.skillhub.dto.LoginConnectionResponse; +import com.iflytek.skillhub.dto.LoginConnectionRevisionCreateRequest; +import com.iflytek.skillhub.dto.LoginConnectionTestResponse; +import com.iflytek.skillhub.service.EnterpriseConnectionAppService; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +/** Transport-only Organization API for the Login Connection control plane. */ +@RestController +@RequestMapping("/api/v1/organizations/{organizationId}/login-connections") +public class EnterpriseConnectionController extends BaseApiController { + + private final EnterpriseConnectionAppService appService; + + public EnterpriseConnectionController( + EnterpriseConnectionAppService appService, + ApiResponseFactory responseFactory + ) { + super(responseFactory); + this.appService = appService; + } + + @GetMapping + public ApiResponse> list( + @PathVariable String organizationId, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok("response.success.read", appService.list(organizationId, principal.userId())); + } + + @GetMapping("/{connectionId}") + public ApiResponse get( + @PathVariable String organizationId, + @PathVariable String connectionId, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.read", + appService.get(organizationId, connectionId, principal.userId()) + ); + } + + @PostMapping + public ApiResponse create( + @PathVariable String organizationId, + @Valid @RequestBody LoginConnectionCreateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.created", + appService.create(organizationId, request, principal.userId()) + ); + } + + @PostMapping("/{connectionId}/revisions") + public ApiResponse createRevision( + @PathVariable String organizationId, + @PathVariable String connectionId, + @Valid @RequestBody LoginConnectionRevisionCreateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.created", + appService.createRevision( + organizationId, + connectionId, + request, + principal.userId() + ) + ); + } + + @PostMapping("/{connectionId}/revisions/{revisionId}/test") + public ApiResponse testRevision( + @PathVariable String organizationId, + @PathVariable String connectionId, + @PathVariable String revisionId, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.updated", + appService.testRevision( + organizationId, + connectionId, + revisionId, + principal.userId() + ) + ); + } + + @PostMapping("/{connectionId}/revisions/{revisionId}/activate") + public ApiResponse activate( + @PathVariable String organizationId, + @PathVariable String connectionId, + @PathVariable String revisionId, + @Valid @RequestBody ConfirmedConnectionActionRequest ignoredConfirmation, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.updated", + appService.activate( + organizationId, + connectionId, + revisionId, + principal.userId() + ) + ); + } + + @PostMapping("/{connectionId}/suspend") + public ApiResponse suspend( + @PathVariable String organizationId, + @PathVariable String connectionId, + @Valid @RequestBody ConfirmedConnectionActionRequest ignoredConfirmation, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.updated", + appService.suspend(organizationId, connectionId, principal.userId()) + ); + } + + @PostMapping("/{connectionId}/disable") + public ApiResponse disable( + @PathVariable String organizationId, + @PathVariable String connectionId, + @Valid @RequestBody ConfirmedConnectionActionRequest ignoredConfirmation, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok( + "response.success.updated", + appService.disable(organizationId, connectionId, principal.userId()) + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmedConnectionActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmedConnectionActionRequest.java new file mode 100644 index 00000000..7ecfc133 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmedConnectionActionRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.AssertTrue; + +/** Explicit acknowledgement for an action that changes the authentication data plane. */ +public record ConfirmedConnectionActionRequest( + @AssertTrue(message = "{error.loginConnection.confirmation.required}") boolean confirmed +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionCreateRequest.java new file mode 100644 index 00000000..ffe89973 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionCreateRequest.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.Map; + +public record LoginConnectionCreateRequest( + @NotBlank @Size(max = 128) String displayName, + @NotBlank @Size(max = 64) String adapterKey, + @NotEmpty Map configuration, + @Size(min = 1, max = 4096) String clientSecret, + boolean verifiedEmailCorrelationEnabled, + boolean jitProvisioningEnabled +) { + + @Override + public String toString() { + return "LoginConnectionCreateRequest[displayName=" + displayName + + ", adapterKey=" + adapterKey + + ", configuration=, clientSecret=" + + (clientSecret == null ? "none" : "") + + ", verifiedEmailCorrelationEnabled=" + verifiedEmailCorrelationEnabled + + ", jitProvisioningEnabled=" + jitProvisioningEnabled + "]"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionHealthResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionHealthResponse.java new file mode 100644 index 00000000..50aae1da --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionHealthResponse.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record LoginConnectionHealthResponse( + String status, + String failureReason, + Instant checkedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionResponse.java new file mode 100644 index 00000000..beb23c6d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record LoginConnectionResponse( + String id, + String publicHandle, + String displayName, + String adapterKey, + String status, + String activeRevisionId, + String lastTestedRevisionId, + LoginConnectionRevisionResponse latestRevision, + LoginConnectionSecretSummaryResponse secret, + LoginConnectionHealthResponse health, + Instant createdAt, + Instant updatedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionCreateRequest.java new file mode 100644 index 00000000..36cf2aba --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionCreateRequest.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.Map; + +public record LoginConnectionRevisionCreateRequest( + @NotEmpty Map configuration, + @Size(min = 1, max = 4096) String clientSecret, + boolean verifiedEmailCorrelationEnabled, + boolean jitProvisioningEnabled +) { + + @Override + public String toString() { + return "LoginConnectionRevisionCreateRequest[configuration=" + + ", clientSecret=" + (clientSecret == null ? "unchanged" : "") + + ", verifiedEmailCorrelationEnabled=" + verifiedEmailCorrelationEnabled + + ", jitProvisioningEnabled=" + jitProvisioningEnabled + "]"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionResponse.java new file mode 100644 index 00000000..9479c0ad --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionRevisionResponse.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; +import java.util.Map; + +public record LoginConnectionRevisionResponse( + String id, + long revision, + String adapterContractVersion, + int configSchemaVersion, + Map configuration, + boolean verifiedEmailCorrelationEnabled, + boolean jitProvisioningEnabled, + Instant createdAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSecretSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSecretSummaryResponse.java new file mode 100644 index 00000000..fd21efbf --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionSecretSummaryResponse.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record LoginConnectionSecretSummaryResponse( + boolean configured, + Instant updatedAt, + Instant previousValidUntil +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionTestResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionTestResponse.java new file mode 100644 index 00000000..ae9b9b58 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LoginConnectionTestResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record LoginConnectionTestResponse( + boolean success, + String revisionId, + String failureReason, + Instant checkedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepository.java new file mode 100644 index 00000000..49e4cbca --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepository.java @@ -0,0 +1,179 @@ +package com.iflytek.skillhub.repository; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.connection.control.LoginConnection; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapterRegistry; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionStatus; +import com.iflytek.skillhub.auth.connection.control.StoredLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretService; +import com.iflytek.skillhub.auth.connection.secret.SecretConfigurationSummary; +import com.iflytek.skillhub.auth.operation.IdentityOperation; +import com.iflytek.skillhub.auth.operation.IdentityOperationRepository; +import com.iflytek.skillhub.auth.operation.IdentityOperationStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.dto.LoginConnectionHealthResponse; +import com.iflytek.skillhub.dto.LoginConnectionResponse; +import com.iflytek.skillhub.dto.LoginConnectionRevisionResponse; +import com.iflytek.skillhub.dto.LoginConnectionSecretSummaryResponse; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** Joins connection state, immutable revision, safe secret summary and latest test health. */ +@Repository +public class EnterpriseConnectionQueryRepository { + + private static final TypeReference> CONFIG_TYPE = new TypeReference<>() { }; + + private final LoginConnectionRepository connections; + private final LoginConnectionRevisionRepository revisions; + private final IdentityOperationRepository operations; + private final ObjectProvider adapterRegistry; + private final ObjectProvider secretService; + private final ObjectMapper objectMapper; + + public EnterpriseConnectionQueryRepository( + LoginConnectionRepository connections, + LoginConnectionRevisionRepository revisions, + IdentityOperationRepository operations, + ObjectProvider adapterRegistry, + ObjectProvider secretService, + ObjectMapper objectMapper + ) { + this.connections = Objects.requireNonNull(connections, "connections"); + this.revisions = Objects.requireNonNull(revisions, "revisions"); + this.operations = Objects.requireNonNull(operations, "operations"); + this.adapterRegistry = Objects.requireNonNull(adapterRegistry, "adapterRegistry"); + this.secretService = Objects.requireNonNull(secretService, "secretService"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper"); + } + + @Transactional(readOnly = true) + public List findAll(String organizationId) { + requireControlPlane(); + return connections.findAllByOrganizationId(organizationId).stream() + .map(connection -> response(organizationId, connection)) + .toList(); + } + + @Transactional(readOnly = true) + public LoginConnectionResponse find(String organizationId, String connectionId) { + requireControlPlane(); + LoginConnection connection = connections.findByOrganizationIdAndId( + organizationId, + connectionId + ).orElseThrow(() -> new DomainNotFoundException("error.loginConnection.notFound")); + return response(organizationId, connection); + } + + private LoginConnectionResponse response(String organizationId, LoginConnection connection) { + StoredLoginConnectionRevision latest = revisions.findLatestByConnectionId(connection.getId()) + .orElse(null); + IdentityOperation latestTest = operations.findLatestLoginConnectionTest( + organizationId, + connection.getId() + ).orElse(null); + SecretConfigurationSummary secret = requireRegistry().require(connection.getAdapterKey()) + .secretPurpose() + .map(purpose -> requireSecrets().describe( + organizationId, + connection.getId(), + purpose + )) + .orElseGet(SecretConfigurationSummary::notConfigured); + return new LoginConnectionResponse( + connection.getId(), + connection.getPublicHandle().value(), + connection.getDisplayName(), + connection.getAdapterKey().value(), + connection.getStatus().name(), + connection.getActiveRevisionId().orElse(null), + connection.getLastTestedRevisionId().orElse(null), + latest == null ? null : revisionResponse(latest), + new LoginConnectionSecretSummaryResponse( + secret.configured(), + secret.updatedAt(), + secret.previousValidUntil() + ), + health(connection, latest, latestTest), + connection.getCreatedAt(), + connection.getUpdatedAt() + ); + } + + private LoginConnectionHealthResponse health( + LoginConnection connection, + StoredLoginConnectionRevision latest, + IdentityOperation latestTest + ) { + if (connection.getStatus() == LoginConnectionStatus.DISABLED) { + return new LoginConnectionHealthResponse("DISABLED", null, null); + } + if (connection.getStatus() == LoginConnectionStatus.SUSPENDED) { + return new LoginConnectionHealthResponse("SUSPENDED", null, null); + } + if (latest == null || latestTest == null + || !latest.getId().equals(latestTest.getRevisionId())) { + return new LoginConnectionHealthResponse("UNTESTED", null, null); + } + if (latestTest.getStatus() == IdentityOperationStatus.FAILED) { + return new LoginConnectionHealthResponse( + "ERROR", + latestTest.getErrorCode().orElse("UNEXPECTED"), + latestTest.getCompletedAt() + ); + } + String health = connection.getActiveRevisionId() + .filter(latest.getId()::equals) + .map(ignored -> "HEALTHY") + .orElse("READY_TO_ACTIVATE"); + return new LoginConnectionHealthResponse(health, null, latestTest.getCompletedAt()); + } + + private LoginConnectionRevisionResponse revisionResponse( + StoredLoginConnectionRevision revision + ) { + try { + return new LoginConnectionRevisionResponse( + revision.getId(), + revision.getRevision(), + revision.getAdapterContractVersion(), + revision.getConfigSchemaVersion(), + Map.copyOf(objectMapper.readValue(revision.getTypedConfig(), CONFIG_TYPE)), + revision.correlationPolicy().verifiedEmailCorrelationEnabled(), + revision.correlationPolicy().jitProvisioningEnabled(), + revision.getCreatedAt() + ); + } catch (Exception invalidStoredConfiguration) { + throw new DomainBadRequestException("error.loginConnection.configuration.unreadable"); + } + } + + private LoginConnectionControlAdapterRegistry requireRegistry() { + return adapterRegistry.getIfAvailable(() -> { + throw unavailable(); + }); + } + + private LoginConnectionSecretService requireSecrets() { + return secretService.getIfAvailable(() -> { + throw unavailable(); + }); + } + + private void requireControlPlane() { + requireRegistry(); + requireSecrets(); + } + + private static DomainBadRequestException unavailable() { + return new DomainBadRequestException("error.loginConnection.controlPlane.unavailable"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/EnterpriseConnectionAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/EnterpriseConnectionAppService.java new file mode 100644 index 00000000..53daad90 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/EnterpriseConnectionAppService.java @@ -0,0 +1,492 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.auth.connection.control.LoginConnection; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionConfigurationInput; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapter; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapterRegistry; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionLifecycleService; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionStatus; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestException; +import com.iflytek.skillhub.auth.connection.control.PreparedLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.control.StoredLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretService; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterial; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import com.iflytek.skillhub.auth.federation.core.IdentityCorrelationPolicySettings; +import com.iflytek.skillhub.auth.operation.IdentityOperation; +import com.iflytek.skillhub.auth.operation.IdentityOperationRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.audit.OrganizationAuditAction; +import com.iflytek.skillhub.domain.audit.OrganizationAuditDetail; +import com.iflytek.skillhub.domain.audit.OrganizationAuditEvent; +import com.iflytek.skillhub.domain.audit.OrganizationAuditTargetType; +import com.iflytek.skillhub.domain.organization.OrganizationAdministrativeAction; +import com.iflytek.skillhub.domain.organization.OrganizationAuthorizationService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.dto.LoginConnectionCreateRequest; +import com.iflytek.skillhub.dto.LoginConnectionResponse; +import com.iflytek.skillhub.dto.LoginConnectionRevisionCreateRequest; +import com.iflytek.skillhub.dto.LoginConnectionTestResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.repository.EnterpriseConnectionQueryRepository; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Protocol-neutral Organization control plane for versioned enterprise login connections. */ +@Service +public class EnterpriseConnectionAppService { + + private final OrganizationAuthorizationService authorization; + private final LoginConnectionRepository connections; + private final LoginConnectionRevisionRepository revisions; + private final LoginConnectionLifecycleService lifecycle; + private final IdentityOperationRepository operations; + private final AuditLogService auditLogService; + private final EnterpriseConnectionQueryRepository queryRepository; + private final ObjectProvider adapterRegistry; + private final ObjectProvider secretService; + private final RequestIdAccessor requestIds; + private final Clock clock; + + public EnterpriseConnectionAppService( + OrganizationAuthorizationService authorization, + LoginConnectionRepository connections, + LoginConnectionRevisionRepository revisions, + LoginConnectionLifecycleService lifecycle, + IdentityOperationRepository operations, + AuditLogService auditLogService, + EnterpriseConnectionQueryRepository queryRepository, + ObjectProvider adapterRegistry, + ObjectProvider secretService, + RequestIdAccessor requestIds, + Clock clock + ) { + this.authorization = Objects.requireNonNull(authorization, "authorization"); + this.connections = Objects.requireNonNull(connections, "connections"); + this.revisions = Objects.requireNonNull(revisions, "revisions"); + this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle"); + this.operations = Objects.requireNonNull(operations, "operations"); + this.auditLogService = Objects.requireNonNull(auditLogService, "auditLogService"); + this.queryRepository = Objects.requireNonNull(queryRepository, "queryRepository"); + this.adapterRegistry = Objects.requireNonNull(adapterRegistry, "adapterRegistry"); + this.secretService = Objects.requireNonNull(secretService, "secretService"); + this.requestIds = Objects.requireNonNull(requestIds, "requestIds"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Transactional(readOnly = true) + public List list(String organizationId, String actorUserId) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS); + return queryRepository.findAll(organizationId); + } + + @Transactional(readOnly = true) + public LoginConnectionResponse get( + String organizationId, + String connectionId, + String actorUserId + ) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS); + return queryRepository.find(organizationId, connectionId); + } + + @Transactional + public LoginConnectionResponse create( + String organizationId, + LoginConnectionCreateRequest request, + String actorUserId + ) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS); + LoginConnectionControlAdapter adapter = requireRegistry().require( + new AdapterKey(request.adapterKey()) + ); + PreparedLoginConnectionRevision prepared = adapter.prepare( + new LoginConnectionConfigurationInput(request.configuration()) + ); + Instant now = clock.instant(); + LoginConnection connection = connections.save(LoginConnection.createOrganization( + organizationId, + request.displayName(), + adapter.adapterKey(), + actorUserId, + now + )); + Long secretVersion = null; + if (prepared.secretPurpose().isPresent()) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.ROTATE_LOGIN_SECRETS); + if (request.clientSecret() == null) { + throw new DomainBadRequestException("error.loginConnection.secret.required"); + } + secretVersion = rotateSecret( + organizationId, + connection.getId(), + prepared.secretPurpose().orElseThrow(), + request.clientSecret(), + actorUserId, + now + ); + } else if (request.clientSecret() != null) { + throw new DomainBadRequestException("error.loginConnection.secret.unsupported"); + } + StoredLoginConnectionRevision revision = revisions.save(createRevision( + connection, + 1, + prepared, + secretVersion, + policy(request.verifiedEmailCorrelationEnabled(), request.jitProvisioningEnabled()), + actorUserId, + now + )); + recordAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_CREATED, + OrganizationAuditTargetType.LOGIN_CONNECTION, + connection.getId(), + OrganizationAuditDetail.transition(null, LoginConnectionStatus.DRAFT.name()), + false + ); + recordAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_REVISION_CREATED, + OrganizationAuditTargetType.LOGIN_CONNECTION_REVISION, + revision.getId(), + OrganizationAuditDetail.transition(null, Long.toString(revision.getRevision())), + false + ); + return queryRepository.find(organizationId, connection.getId()); + } + + @Transactional + public LoginConnectionResponse createRevision( + String organizationId, + String connectionId, + LoginConnectionRevisionCreateRequest request, + String actorUserId + ) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS); + LoginConnection connection = requireConnection(organizationId, connectionId); + LoginConnectionControlAdapter adapter = requireRegistry().require(connection.getAdapterKey()); + PreparedLoginConnectionRevision prepared = adapter.prepare( + new LoginConnectionConfigurationInput(request.configuration()) + ); + StoredLoginConnectionRevision previous = revisions.findLatestByConnectionId(connectionId) + .orElseThrow(() -> new DomainNotFoundException( + "error.loginConnection.revision.notFound" + )); + Instant now = clock.instant(); + Long secretVersion = previous.getSecretBindingVersion().orElse(null); + if (request.clientSecret() != null) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.ROTATE_LOGIN_SECRETS); + secretVersion = rotateSecret( + organizationId, + connectionId, + requiredSecretPurpose(prepared), + request.clientSecret(), + actorUserId, + now + ); + } + if (prepared.secretPurpose().isPresent() && secretVersion == null) { + throw new DomainBadRequestException("error.loginConnection.secret.required"); + } + StoredLoginConnectionRevision revision = revisions.save(createRevision( + connection, + previous.getRevision() + 1, + prepared, + secretVersion, + policy(request.verifiedEmailCorrelationEnabled(), request.jitProvisioningEnabled()), + actorUserId, + now + )); + recordAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_REVISION_CREATED, + OrganizationAuditTargetType.LOGIN_CONNECTION_REVISION, + revision.getId(), + OrganizationAuditDetail.transition( + Long.toString(previous.getRevision()), + Long.toString(revision.getRevision()) + ), + false + ); + return queryRepository.find(organizationId, connection.getId()); + } + + public LoginConnectionTestResponse testRevision( + String organizationId, + String connectionId, + String revisionId, + String actorUserId + ) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS); + LoginConnection connection = requireConnection(organizationId, connectionId); + LoginConnectionControlAdapter adapter = requireRegistry().require(connection.getAdapterKey()); + Instant now = clock.instant(); + try { + lifecycle.testRevision( + organizationId, + connectionId, + revisionId, + adapter.testProbe(), + now + ); + operations.save(IdentityOperation.successfulLoginConnectionTest( + organizationId, + connectionId, + revisionId, + requestIds.current(), + now + )); + recordAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_TEST_SUCCEEDED, + OrganizationAuditTargetType.LOGIN_CONNECTION_REVISION, + revisionId, + OrganizationAuditDetail.transition("UNTESTED", "SUCCEEDED"), + false + ); + return new LoginConnectionTestResponse(true, revisionId, null, now); + } catch (LoginConnectionTestException failed) { + operations.save(IdentityOperation.failedLoginConnectionTest( + organizationId, + connectionId, + revisionId, + requestIds.current(), + failed.reason(), + now + )); + recordAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_TEST_FAILED, + OrganizationAuditTargetType.LOGIN_CONNECTION_REVISION, + revisionId, + OrganizationAuditDetail.transition("UNTESTED", failed.reason().name()), + true + ); + return new LoginConnectionTestResponse(false, revisionId, failed.reason().name(), now); + } + } + + @Transactional + public LoginConnectionResponse activate( + String organizationId, + String connectionId, + String revisionId, + String actorUserId + ) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS); + String previousStatus = requireConnection(organizationId, connectionId).getStatus().name(); + lifecycle.activate(organizationId, connectionId, revisionId, clock.instant()); + LoginConnection connection = requireConnection(organizationId, connectionId); + recordConnectionTransitionAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_ACTIVATED, + previousStatus, + connection + ); + return queryRepository.find(organizationId, connection.getId()); + } + + @Transactional + public LoginConnectionResponse suspend( + String organizationId, + String connectionId, + String actorUserId + ) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS); + String previousStatus = requireConnection(organizationId, connectionId).getStatus().name(); + lifecycle.suspend(organizationId, connectionId, clock.instant()); + LoginConnection connection = requireConnection(organizationId, connectionId); + recordConnectionTransitionAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_SUSPENDED, + previousStatus, + connection + ); + return queryRepository.find(organizationId, connection.getId()); + } + + @Transactional + public LoginConnectionResponse disable( + String organizationId, + String connectionId, + String actorUserId + ) { + authorize(organizationId, actorUserId, OrganizationAdministrativeAction.MANAGE_LOGIN_CONNECTIONS); + String previousStatus = requireConnection(organizationId, connectionId).getStatus().name(); + lifecycle.disable(organizationId, connectionId, clock.instant()); + LoginConnection connection = requireConnection(organizationId, connectionId); + recordConnectionTransitionAudit( + organizationId, + actorUserId, + OrganizationAuditAction.LOGIN_CONNECTION_DISABLED, + previousStatus, + connection + ); + return queryRepository.find(organizationId, connection.getId()); + } + + private StoredLoginConnectionRevision createRevision( + LoginConnection connection, + long revisionNumber, + PreparedLoginConnectionRevision prepared, + Long secretVersion, + IdentityCorrelationPolicySettings correlationPolicy, + String actorUserId, + Instant now + ) { + if (!connection.getAdapterKey().equals(prepared.adapterDescriptor().adapterKey())) { + throw new DomainBadRequestException("error.loginConnection.adapter.immutable"); + } + var contract = prepared.adapterDescriptor().contractVersion(); + return StoredLoginConnectionRevision.create( + connection.getId(), + revisionNumber, + contract.major() + "." + contract.minor(), + prepared.adapterDescriptor().configSchemaVersion(), + prepared.capabilitiesJson(), + prepared.typedConfigJson(), + secretVersion, + correlationPolicy, + actorUserId, + now + ); + } + + private long rotateSecret( + String organizationId, + String connectionId, + SecretPurpose purpose, + String secret, + String actorUserId, + Instant now + ) { + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + try (SecretMaterial material = SecretMaterial.copyOf(bytes)) { + return requireSecrets().rotate( + organizationId, + connectionId, + purpose, + material, + Duration.ZERO, + actorUserId, + now + ).bindingVersion(); + } finally { + java.util.Arrays.fill(bytes, (byte) 0); + } + } + + private static SecretPurpose requiredSecretPurpose(PreparedLoginConnectionRevision prepared) { + return prepared.secretPurpose().orElseThrow(() -> new DomainBadRequestException( + "error.loginConnection.secret.unsupported" + )); + } + + private static IdentityCorrelationPolicySettings policy( + boolean verifiedEmail, + boolean jit + ) { + try { + return new IdentityCorrelationPolicySettings(verifiedEmail, jit); + } catch (IllegalArgumentException invalid) { + throw new DomainBadRequestException("error.loginConnection.correlation.invalid"); + } + } + + private LoginConnection requireConnection(String organizationId, String connectionId) { + return connections.findByOrganizationIdAndId(organizationId, connectionId) + .orElseThrow(() -> new DomainNotFoundException("error.loginConnection.notFound")); + } + + private LoginConnectionControlAdapterRegistry requireRegistry() { + return adapterRegistry.getIfAvailable(() -> { + throw unavailable(); + }); + } + + private LoginConnectionSecretService requireSecrets() { + return secretService.getIfAvailable(() -> { + throw unavailable(); + }); + } + + private static DomainBadRequestException unavailable() { + return new DomainBadRequestException("error.loginConnection.controlPlane.unavailable"); + } + + private void authorize( + String organizationId, + String actorUserId, + OrganizationAdministrativeAction action + ) { + authorization.requireAllowed(organizationId, actorUserId, action); + } + + private void recordConnectionTransitionAudit( + String organizationId, + String actorUserId, + OrganizationAuditAction action, + String previousStatus, + LoginConnection after + ) { + recordAudit( + organizationId, + actorUserId, + action, + OrganizationAuditTargetType.LOGIN_CONNECTION, + after.getId(), + OrganizationAuditDetail.transition(previousStatus, after.getStatus().name()), + false + ); + } + + private void recordAudit( + String organizationId, + String actorUserId, + OrganizationAuditAction action, + OrganizationAuditTargetType targetType, + String targetReference, + OrganizationAuditDetail detail, + boolean failed + ) { + OrganizationAuditEvent event = failed + ? OrganizationAuditEvent.failed( + actorUserId, + organizationId, + action, + targetType, + targetReference, + requestIds.current(), + detail + ) + : OrganizationAuditEvent.success( + actorUserId, + organizationId, + action, + targetType, + targetReference, + requestIds.current(), + detail + ); + auditLogService.record(event); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/OrganizationAdminControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/OrganizationAdminControllerTest.java index fa569c00..600a05fb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/OrganizationAdminControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/OrganizationAdminControllerTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; 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; @@ -17,12 +18,18 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.organization.OrganizationRole; import com.iflytek.skillhub.domain.organization.OrganizationStatus; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.dto.LoginConnectionHealthResponse; +import com.iflytek.skillhub.dto.LoginConnectionResponse; +import com.iflytek.skillhub.dto.LoginConnectionRevisionResponse; +import com.iflytek.skillhub.dto.LoginConnectionSecretSummaryResponse; import com.iflytek.skillhub.dto.OrganizationDomainChallengeResponse; import com.iflytek.skillhub.dto.OrganizationResponse; +import com.iflytek.skillhub.service.EnterpriseConnectionAppService; import com.iflytek.skillhub.service.OrganizationAdminAppService; import com.iflytek.skillhub.service.PlatformOrganizationAdminAppService; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -50,6 +57,9 @@ class OrganizationAdminControllerTest { @MockBean private OrganizationAdminAppService organizationAppService; + @MockBean + private EnterpriseConnectionAppService enterpriseConnectionAppService; + @MockBean private NamespaceMemberRepository namespaceMemberRepository; @@ -161,6 +171,88 @@ class OrganizationAdminControllerTest { .value("skillhub-verification=one-time-token")); } + @Test + void loginConnectionCreationNeverEchoesTheSubmittedSecret() throws Exception { + given(enterpriseConnectionAppService.create( + eq("organization-a"), + any(), + eq("identity-admin") + )).willReturn(new LoginConnectionResponse( + "connection-1", + "login-safe-handle", + "Corporate SSO", + "oidc", + "DRAFT", + null, + null, + new LoginConnectionRevisionResponse( + "revision-1", + 1, + "1.0", + 1, + Map.of( + "issuer", "https://id.example.com", + "clientId", "skillhub", + "scopes", List.of("openid") + ), + false, + false, + NOW + ), + new LoginConnectionSecretSummaryResponse(true, NOW, null), + new LoginConnectionHealthResponse("UNTESTED", null, null), + NOW, + NOW + )); + + String body = mockMvc.perform(post( + "/api/v1/organizations/organization-a/login-connections" + ) + .with(authentication(authToken("identity-admin"))) + .with(csrf()) + .contentType("application/json") + .content(""" + { + "displayName": "Corporate SSO", + "adapterKey": "oidc", + "configuration": { + "issuer": "https://id.example.com", + "clientId": "skillhub", + "scopes": ["openid"] + }, + "clientSecret": "never-echo-this-secret", + "verifiedEmailCorrelationEnabled": false, + "jitProvisioningEnabled": false + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.secret.configured").value(true)) + .andExpect(jsonPath("$.data.latestRevision.configuration.clientId") + .value("skillhub")) + .andReturn() + .getResponse() + .getContentAsString(); + + org.assertj.core.api.Assertions.assertThat(body) + .doesNotContain("never-echo-this-secret") + .doesNotContain("clientSecret"); + } + + @Test + void activationRequiresExplicitConfirmationBeforeCallingTheService() throws Exception { + mockMvc.perform(post( + "/api/v1/organizations/organization-a/login-connections/connection-1" + + "/revisions/revision-1/activate" + ) + .with(authentication(authToken("identity-admin"))) + .with(csrf()) + .contentType("application/json") + .content("{\"confirmed\":false}")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(enterpriseConnectionAppService); + } + private OrganizationResponse organizationResponse(List roles) { return new OrganizationResponse( "organization-a", diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepositoryTest.java new file mode 100644 index 00000000..56f06105 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/EnterpriseConnectionQueryRepositoryTest.java @@ -0,0 +1,107 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.connection.control.LoginConnection; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapter; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapterRegistry; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestFailureReason; +import com.iflytek.skillhub.auth.connection.control.StoredLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretService; +import com.iflytek.skillhub.auth.connection.secret.SecretConfigurationSummary; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import com.iflytek.skillhub.auth.operation.IdentityOperation; +import com.iflytek.skillhub.auth.operation.IdentityOperationRepository; +import com.iflytek.skillhub.dto.LoginConnectionResponse; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.StaticListableBeanFactory; + +class EnterpriseConnectionQueryRepositoryTest { + + private static final Instant NOW = Instant.parse("2026-09-08T12:00:00Z"); + + @Test + void joinsLatestRevisionSafeSecretSummaryAndBoundedFailureHealth() { + LoginConnectionRepository connections = mock(LoginConnectionRepository.class); + LoginConnectionRevisionRepository revisions = mock(LoginConnectionRevisionRepository.class); + IdentityOperationRepository operations = mock(IdentityOperationRepository.class); + LoginConnectionSecretService secrets = mock(LoginConnectionSecretService.class); + LoginConnection connection = LoginConnection.createOrganization( + "org-1", + "Corporate SSO", + new AdapterKey("oidc"), + "identity-admin", + NOW.minusSeconds(30) + ); + StoredLoginConnectionRevision revision = StoredLoginConnectionRevision.create( + connection.getId(), + 1, + "1.0", + 1, + "[\"IDENTITY_ASSERTION\"]", + "{\"issuer\":\"https://id.example.com\",\"clientId\":\"skillhub\"," + + "\"scopes\":[\"openid\"]}", + 1L, + "identity-admin", + NOW.minusSeconds(20) + ); + IdentityOperation failed = IdentityOperation.failedLoginConnectionTest( + "org-1", + connection.getId(), + revision.getId(), + "request-1", + LoginConnectionTestFailureReason.METADATA, + NOW + ); + LoginConnectionControlAdapter adapter = mock(LoginConnectionControlAdapter.class); + given(adapter.adapterKey()).willReturn(new AdapterKey("oidc")); + given(adapter.secretPurpose()).willReturn(Optional.of(SecretPurpose.LOGIN_CLIENT_SECRET)); + given(connections.findByOrganizationIdAndId("org-1", connection.getId())) + .willReturn(Optional.of(connection)); + given(revisions.findLatestByConnectionId(connection.getId())) + .willReturn(Optional.of(revision)); + given(operations.findLatestLoginConnectionTest("org-1", connection.getId())) + .willReturn(Optional.of(failed)); + given(secrets.describe( + "org-1", + connection.getId(), + SecretPurpose.LOGIN_CLIENT_SECRET + )).willReturn(SecretConfigurationSummary.configured(NOW.minusSeconds(10), null)); + EnterpriseConnectionQueryRepository query = new EnterpriseConnectionQueryRepository( + connections, + revisions, + operations, + provider( + LoginConnectionControlAdapterRegistry.class, + new LoginConnectionControlAdapterRegistry(List.of(adapter)) + ), + provider(LoginConnectionSecretService.class, secrets), + new ObjectMapper() + ); + + LoginConnectionResponse response = query.find("org-1", connection.getId()); + + assertThat(response.latestRevision().configuration()) + .containsEntry("issuer", "https://id.example.com") + .doesNotContainKey("clientSecret"); + assertThat(response.secret().configured()).isTrue(); + assertThat(response.health().status()).isEqualTo("ERROR"); + assertThat(response.health().failureReason()).isEqualTo("METADATA"); + } + + private static ObjectProvider provider(Class type, T bean) { + StaticListableBeanFactory factory = new StaticListableBeanFactory(); + factory.addBean(type.getName(), bean); + return factory.getBeanProvider(type); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionLifecyclePersistenceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionLifecyclePersistenceTest.java index 16dab9b7..5e6f8278 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionLifecyclePersistenceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/LoginConnectionLifecyclePersistenceTest.java @@ -12,6 +12,7 @@ import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionRepos import com.iflytek.skillhub.auth.connection.control.LoginConnectionStatus; import com.iflytek.skillhub.auth.connection.control.PersistentEnterpriseConnectionRegistry; import com.iflytek.skillhub.auth.connection.control.StoredLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestFailureReason; import com.iflytek.skillhub.auth.connection.core.AdapterCapability; import com.iflytek.skillhub.auth.connection.core.AdapterContractVersion; import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor; @@ -92,6 +93,9 @@ class LoginConnectionLifecyclePersistenceTest { @Autowired private LoginConnectionLifecycleService lifecycleService; + @Autowired + private IdentityOperationRepository identityOperationRepository; + @Autowired private EntityManager entityManager; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/EnterpriseConnectionAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/EnterpriseConnectionAppServiceTest.java new file mode 100644 index 00000000..96eb3e08 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/EnterpriseConnectionAppServiceTest.java @@ -0,0 +1,161 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.iflytek.skillhub.auth.connection.control.LoginConnection; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionConfigurationInput; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapter; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapterRegistry; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionLifecycleService; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestException; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestFailureReason; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestProbe; +import com.iflytek.skillhub.auth.connection.control.PreparedLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretService; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import com.iflytek.skillhub.auth.operation.IdentityOperation; +import com.iflytek.skillhub.auth.operation.IdentityOperationRepository; +import com.iflytek.skillhub.auth.operation.IdentityOperationStatus; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.audit.OrganizationAuditAction; +import com.iflytek.skillhub.domain.audit.OrganizationAuditEvent; +import com.iflytek.skillhub.domain.audit.OrganizationAuditResult; +import com.iflytek.skillhub.domain.organization.OrganizationAuthorizationService; +import com.iflytek.skillhub.dto.LoginConnectionTestResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.repository.EnterpriseConnectionQueryRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.StaticListableBeanFactory; + +class EnterpriseConnectionAppServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-08T12:00:00Z"); + + @Test + void failedDraftTestIsPersistedAsSafeHealthWithoutChangingLifecycle() { + OrganizationAuthorizationService authorization = mock(OrganizationAuthorizationService.class); + LoginConnectionRepository connections = mock(LoginConnectionRepository.class); + LoginConnectionRevisionRepository revisions = mock(LoginConnectionRevisionRepository.class); + LoginConnectionLifecycleService lifecycle = mock(LoginConnectionLifecycleService.class); + IdentityOperationRepository operations = mock(IdentityOperationRepository.class); + AuditLogService auditLogService = mock(AuditLogService.class); + LoginConnectionSecretService secrets = mock(LoginConnectionSecretService.class); + LoginConnection connection = LoginConnection.createOrganization( + "org-1", + "Corporate SSO", + new AdapterKey("oidc"), + "identity-admin", + NOW.minusSeconds(60) + ); + given(connections.findByOrganizationIdAndId("org-1", connection.getId())) + .willReturn(Optional.of(connection)); + LoginConnectionControlAdapter adapter = new LoginConnectionControlAdapter() { + @Override + public AdapterKey adapterKey() { + return new AdapterKey("oidc"); + } + + @Override + public Optional secretPurpose() { + return Optional.of(SecretPurpose.LOGIN_CLIENT_SECRET); + } + + @Override + public PreparedLoginConnectionRevision prepare( + LoginConnectionConfigurationInput configuration + ) { + throw new UnsupportedOperationException(); + } + + @Override + public LoginConnectionTestProbe testProbe() { + return ignored -> { + throw new LoginConnectionTestException( + LoginConnectionTestFailureReason.METADATA + ); + }; + } + }; + given(lifecycle.testRevision( + org.mockito.ArgumentMatchers.eq("org-1"), + org.mockito.ArgumentMatchers.eq(connection.getId()), + org.mockito.ArgumentMatchers.eq("revision-2"), + any(), + org.mockito.ArgumentMatchers.eq(NOW) + )).willAnswer(invocation -> { + LoginConnectionTestProbe probe = invocation.getArgument(3); + probe.verify(null); + return null; + }); + RequestIdAccessor requestIds = new RequestIdAccessor(); + EnterpriseConnectionAppService service = new EnterpriseConnectionAppService( + authorization, + connections, + revisions, + lifecycle, + operations, + auditLogService, + mock(EnterpriseConnectionQueryRepository.class), + provider(LoginConnectionControlAdapterRegistry.class, + new LoginConnectionControlAdapterRegistry(List.of(adapter))), + provider(LoginConnectionSecretService.class, secrets), + requestIds, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + + LoginConnectionTestResponse response; + try (RequestIdAccessor.Scope ignored = requestIds.open("request-123")) { + response = service.testRevision( + "org-1", + connection.getId(), + "revision-2", + "identity-admin" + ); + } + + assertThat(response.success()).isFalse(); + assertThat(response.failureReason()).isEqualTo("METADATA"); + assertThat(connection.getStatus().name()).isEqualTo("DRAFT"); + assertThat(connection.getActiveRevisionId()).isEmpty(); + assertThat(connection.getLastTestedRevisionId()).isEmpty(); + ArgumentCaptor operation = ArgumentCaptor.forClass( + IdentityOperation.class + ); + verify(operations).save(operation.capture()); + assertThat(operation.getValue().getStatus()).isEqualTo(IdentityOperationStatus.FAILED); + assertThat(operation.getValue().getRevisionId()).isEqualTo("revision-2"); + assertThat(operation.getValue().getRequestId()).contains("request-123"); + assertThat(operation.getValue().getErrorCode()).contains("METADATA"); + ArgumentCaptor audit = ArgumentCaptor.forClass( + OrganizationAuditEvent.class + ); + verify(auditLogService).record(audit.capture()); + assertThat(audit.getValue().action()) + .isEqualTo(OrganizationAuditAction.LOGIN_CONNECTION_TEST_FAILED); + assertThat(audit.getValue().result()).isEqualTo(OrganizationAuditResult.FAILED); + assertThat(audit.getValue().requestId()).isEqualTo("request-123"); + assertThat(audit.getValue().detail().toJson()) + .contains("METADATA") + .doesNotContainIgnoringCase("secret", "token", "exception"); + } + + private static ObjectProvider provider(Class type, T bean) { + StaticListableBeanFactory factory = new StaticListableBeanFactory(); + factory.addBean(type.getName(), bean); + return factory.getBeanProvider(type); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionConfigurationInput.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionConfigurationInput.java new file mode 100644 index 00000000..eb34f391 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionConfigurationInput.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.auth.connection.control; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Untrusted, protocol-neutral draft configuration accepted by the control-plane registry. */ +public record LoginConnectionConfigurationInput(Map values) { + + public LoginConnectionConfigurationInput { + Objects.requireNonNull(values, "values"); + values = Map.copyOf(new LinkedHashMap<>(values)); + } + + @Override + public String toString() { + return "LoginConnectionConfigurationInput[values=]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapter.java new file mode 100644 index 00000000..2a797571 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapter.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.connection.control; + +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import java.util.Optional; + +/** Reviewed control-plane companion for one versioned login Adapter family. */ +public interface LoginConnectionControlAdapter { + + AdapterKey adapterKey(); + + /** Secret slot described by this adapter, if the protocol needs one. */ + Optional secretPurpose(); + + PreparedLoginConnectionRevision prepare(LoginConnectionConfigurationInput configuration); + + LoginConnectionTestProbe testProbe(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistry.java new file mode 100644 index 00000000..38d99712 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistry.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.auth.connection.control; + +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Closed runtime registry: database values select only reviewed, dependency-injected code. */ +public final class LoginConnectionControlAdapterRegistry { + + private final Map adapters; + + public LoginConnectionControlAdapterRegistry(List adapters) { + Objects.requireNonNull(adapters, "adapters"); + Map indexed = new LinkedHashMap<>(); + for (LoginConnectionControlAdapter adapter : adapters) { + LoginConnectionControlAdapter required = Objects.requireNonNull(adapter, "adapter"); + if (indexed.putIfAbsent(required.adapterKey(), required) != null) { + throw new IllegalStateException( + "Duplicate login connection control Adapter: " + + required.adapterKey().value() + ); + } + } + this.adapters = Map.copyOf(indexed); + } + + public LoginConnectionControlAdapter require(AdapterKey adapterKey) { + LoginConnectionControlAdapter adapter = adapters.get( + Objects.requireNonNull(adapterKey, "adapterKey") + ); + if (adapter == null) { + throw new DomainBadRequestException("error.loginConnection.adapter.unsupported"); + } + return adapter; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestException.java new file mode 100644 index 00000000..47995a7f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestException.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.connection.control; + +import java.util.Objects; + +/** Safe Adapter boundary failure: the original upstream exception is intentionally not exposed. */ +public final class LoginConnectionTestException extends RuntimeException { + + private final LoginConnectionTestFailureReason reason; + + public LoginConnectionTestException(LoginConnectionTestFailureReason reason) { + super("Login connection test failed: " + Objects.requireNonNull(reason, "reason")); + this.reason = reason; + } + + public LoginConnectionTestFailureReason reason() { + return reason; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestFailureReason.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestFailureReason.java new file mode 100644 index 00000000..6b5d32f4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionTestFailureReason.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.auth.connection.control; + +/** Bounded, non-sensitive categories safe for persistence, APIs, metrics and UI copy. */ +public enum LoginConnectionTestFailureReason { + CONFIGURATION, + NETWORK, + TLS, + METADATA, + CREDENTIAL, + CLAIM_SCHEMA, + PERMISSION, + UPSTREAM, + UNEXPECTED +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/PreparedLoginConnectionRevision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/PreparedLoginConnectionRevision.java new file mode 100644 index 00000000..6864a467 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/control/PreparedLoginConnectionRevision.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.auth.connection.control; + +import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import java.util.Objects; +import java.util.Optional; + +/** Validated, versioned and persistence-ready configuration emitted by a reviewed Adapter. */ +public record PreparedLoginConnectionRevision( + AdapterDescriptor adapterDescriptor, + String capabilitiesJson, + String typedConfigJson, + Optional secretPurpose +) { + + public PreparedLoginConnectionRevision { + Objects.requireNonNull(adapterDescriptor, "adapterDescriptor"); + capabilitiesJson = requireJson(capabilitiesJson, '[', ']', "capabilitiesJson"); + typedConfigJson = requireJson(typedConfigJson, '{', '}', "typedConfigJson"); + secretPurpose = Objects.requireNonNull(secretPurpose, "secretPurpose"); + } + + private static String requireJson(String value, char open, char close, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.length() < 2 + || normalized.charAt(0) != open + || normalized.charAt(normalized.length() - 1) != close) { + throw new IllegalArgumentException(field + " has an invalid JSON shape"); + } + return normalized; + } + + @Override + public String toString() { + return "PreparedLoginConnectionRevision[adapter=" + + adapterDescriptor.adapterKey().value() + + ", capabilitiesJson=, typedConfigJson=, secretPurpose=" + + secretPurpose.map(SecretPurpose::value).orElse("none") + "]"; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcConnectionMetadataProbe.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcConnectionMetadataProbe.java new file mode 100644 index 00000000..e1ff3756 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcConnectionMetadataProbe.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.connection.oidc; + +import java.time.Instant; + +/** Narrow preflight port that keeps control-plane tests independent of the metadata cache. */ +@FunctionalInterface +public interface OidcConnectionMetadataProbe { + + void verify(OidcMetadataCacheKey key, OidcIssuer issuer, Instant occurredAt); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapter.java new file mode 100644 index 00000000..724af256 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapter.java @@ -0,0 +1,209 @@ +package com.iflytek.skillhub.auth.connection.oidc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionConfigurationInput; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapter; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRuntimeSnapshotMaterializer; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestException; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestFailureReason; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestProbe; +import com.iflytek.skillhub.auth.connection.control.PreparedLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor; +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException; +import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterial; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterialResolver; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterialUnavailableException; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** OIDC control-plane edge: validates drafts and performs a non-mutating server preflight. */ +public final class OidcLoginConnectionControlAdapter implements LoginConnectionControlAdapter { + + private static final Set FIELDS = Set.of("issuer", "clientId", "scopes"); + private static final int MAX_SCOPES = 16; + + private final LoginConnectionRuntimeSnapshotMaterializer materializer; + private final SecretMaterialResolver secrets; + private final OidcConnectionMetadataProbe metadata; + private final Clock clock; + private final ObjectMapper objectMapper; + + public OidcLoginConnectionControlAdapter( + LoginConnectionRuntimeSnapshotMaterializer materializer, + SecretMaterialResolver secrets, + OidcConnectionMetadataProbe metadata, + Clock clock + ) { + this.materializer = Objects.requireNonNull(materializer, "materializer"); + this.secrets = Objects.requireNonNull(secrets, "secrets"); + this.metadata = Objects.requireNonNull(metadata, "metadata"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.objectMapper = new ObjectMapper(); + } + + @Override + public AdapterKey adapterKey() { + return OidcLoginConnectionRuntimeConfig.ADAPTER_KEY; + } + + @Override + public Optional secretPurpose() { + return Optional.of(SecretPurpose.LOGIN_CLIENT_SECRET); + } + + @Override + public PreparedLoginConnectionRevision prepare( + LoginConnectionConfigurationInput configuration + ) { + try { + Map values = Objects.requireNonNull(configuration, "configuration") + .values(); + if (!values.keySet().equals(FIELDS)) { + throw invalid(); + } + OidcIssuer issuer = new OidcIssuer(requiredString(values, "issuer")); + String clientId = requiredString(values, "clientId"); + List scopes = scopes(values.get("scopes")); + AdapterDescriptor descriptor = OidcRedirectAuthenticationAdapter.supportedDescriptor(); + Map persisted = new LinkedHashMap<>(); + persisted.put("issuer", issuer.value()); + persisted.put("clientId", clientId); + persisted.put("scopes", scopes); + List capabilities = descriptor.capabilities().stream() + .map(Enum::name) + .sorted() + .toList(); + return new PreparedLoginConnectionRevision( + descriptor, + objectMapper.writeValueAsString(capabilities), + objectMapper.writeValueAsString(persisted), + Optional.of(SecretPurpose.LOGIN_CLIENT_SECRET) + ); + } catch (DomainBadRequestException invalid) { + throw invalid; + } catch (JsonProcessingException | RuntimeException invalid) { + throw invalid(); + } + } + + @Override + public LoginConnectionTestProbe testProbe() { + return candidate -> { + LoginConnectionRuntimeSnapshot runtime = + requireOidcRuntime(candidate); + var reference = runtime.secretReference().orElseThrow(() -> failure( + LoginConnectionTestFailureReason.CREDENTIAL + )); + try (SecretMaterial ignored = secrets.resolve( + reference, + SecretPurpose.LOGIN_CLIENT_SECRET, + clock.instant() + )) { + // Successful decryption proves that the configured binding is usable locally. + } catch (SecretMaterialUnavailableException unavailable) { + throw failure(LoginConnectionTestFailureReason.CREDENTIAL); + } + try { + metadata.verify( + new OidcMetadataCacheKey( + runtime.organizationId().orElse("@platform"), + runtime.connectionId(), + runtime.revision() + ), + runtime.config().issuer(), + clock.instant() + ); + } catch (OidcMetadataUnavailableException unavailable) { + throw failure(LoginConnectionTestFailureReason.METADATA); + } catch (RuntimeException unavailable) { + throw failure(LoginConnectionTestFailureReason.UPSTREAM); + } + }; + } + + @SuppressWarnings("unchecked") + private LoginConnectionRuntimeSnapshot requireOidcRuntime( + com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionSnapshot candidate + ) { + try { + LoginConnectionRuntimeSnapshot runtime = materializer.materialize(candidate); + if (!(runtime.config() instanceof OidcLoginConnectionRuntimeConfig) + || !runtime.adapterKey().equals(adapterKey())) { + throw new ConnectionUnavailableException(); + } + return (LoginConnectionRuntimeSnapshot) runtime; + } catch (ConnectionUnavailableException invalid) { + throw failure(LoginConnectionTestFailureReason.CONFIGURATION); + } catch (RuntimeException invalid) { + throw failure(LoginConnectionTestFailureReason.CONFIGURATION); + } + } + + private static List scopes(Object raw) { + if (!(raw instanceof java.util.Collection values) + || values.isEmpty() + || values.size() > MAX_SCOPES) { + throw invalid(); + } + Set normalized = new LinkedHashSet<>(); + for (Object value : values) { + if (!(value instanceof String text)) { + throw invalid(); + } + String scope = requireText(text); + if (scope.length() > 128) { + throw invalid(); + } + normalized.add(scope); + } + if (!normalized.contains("openid")) { + throw invalid(); + } + List ordered = new ArrayList<>(normalized); + ordered.sort(Comparator.naturalOrder()); + return List.copyOf(ordered); + } + + private static String requiredString(Map values, String field) { + Object value = values.get(field); + if (!(value instanceof String text)) { + throw invalid(); + } + String normalized = requireText(text); + if (normalized.length() > 512) { + throw invalid(); + } + return normalized; + } + + private static String requireText(String value) { + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw invalid(); + } + return normalized; + } + + private static DomainBadRequestException invalid() { + return new DomainBadRequestException("error.loginConnection.configuration.invalid"); + } + + private static LoginConnectionTestException failure( + LoginConnectionTestFailureReason reason + ) { + return new LoginConnectionTestException(reason); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseOidcRuntimeConfiguration.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseOidcRuntimeConfiguration.java new file mode 100644 index 00000000..5999fe85 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/federation/config/EnterpriseOidcRuntimeConfiguration.java @@ -0,0 +1,310 @@ +package com.iflytek.skillhub.auth.federation.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.auth.connection.control.BuiltInLoginConnectionRuntimeSnapshotMaterializer; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionRepository; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapter; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionControlAdapterRegistry; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRuntimeSnapshotDecoder; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRuntimeSnapshotMaterializer; +import com.iflytek.skillhub.auth.connection.control.PersistentEnterpriseConnectionRegistry; +import com.iflytek.skillhub.auth.connection.core.ConnectionHandle; +import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException; +import com.iflytek.skillhub.auth.connection.core.EnterpriseConnectionContinuationRegistry; +import com.iflytek.skillhub.auth.connection.oidc.DefaultOidcMetadataSource; +import com.iflytek.skillhub.auth.connection.oidc.JdkOidcSingleHopHttpTransport; +import com.iflytek.skillhub.auth.connection.oidc.OidcAuthorizationTransactionService; +import com.iflytek.skillhub.auth.connection.oidc.OidcConnectionMetadataProbe; +import com.iflytek.skillhub.auth.connection.oidc.OidcAuthorizationTransactionStore; +import com.iflytek.skillhub.auth.connection.oidc.OidcHostAddressResolver; +import com.iflytek.skillhub.auth.connection.oidc.OidcIdTokenValidator; +import com.iflytek.skillhub.auth.connection.oidc.OidcLoginConnectionRuntimeSnapshotMaterializer; +import com.iflytek.skillhub.auth.connection.oidc.OidcLoginConnectionControlAdapter; +import com.iflytek.skillhub.auth.connection.oidc.OidcMetadataManager; +import com.iflytek.skillhub.auth.connection.oidc.OidcOutboundTargetPolicy; +import com.iflytek.skillhub.auth.connection.oidc.OidcRedirectAuthenticationAdapter; +import com.iflytek.skillhub.auth.connection.oidc.OidcTokenEndpointClient; +import com.iflytek.skillhub.auth.connection.oidc.RedisOidcAuthorizationTransactionStore; +import com.iflytek.skillhub.auth.connection.oidc.SecureOidcDocumentClient; +import com.iflytek.skillhub.auth.connection.secret.AesGcmSecretEnvelopeCipher; +import com.iflytek.skillhub.auth.connection.secret.InMemorySecretEnvelopeKeyring; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretRepository; +import com.iflytek.skillhub.auth.connection.secret.LoginConnectionSecretService; +import com.iflytek.skillhub.auth.federation.EnterpriseRedirectLoginGateway; +import com.iflytek.skillhub.auth.federation.EnterpriseRedirectLoginService; +import com.iflytek.skillhub.auth.federation.adapter.BuiltInRedirectAuthenticationAdapterRegistry; +import com.iflytek.skillhub.auth.federation.adapter.RemoteIdentityIoExecutor; +import com.iflytek.skillhub.auth.federation.association.EnterpriseIdentityAssociationService; +import com.iflytek.skillhub.auth.federation.association.ExternalIdentityRepository; +import com.iflytek.skillhub.auth.federation.association.PreProvisionedLoginSubjectRepository; +import com.iflytek.skillhub.auth.federation.core.IdentityCoreActivation; +import com.iflytek.skillhub.auth.federation.core.RedirectAuthenticationAdapter; +import com.iflytek.skillhub.auth.federation.core.RedirectCompleteRequest; +import com.iflytek.skillhub.auth.federation.core.RedirectStartRequest; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.auth.session.EnterpriseBrowserSessionService; +import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.organization.OrganizationDomainRepository; +import com.iflytek.skillhub.domain.organization.OrganizationMembershipRepository; +import com.iflytek.skillhub.domain.organization.OrganizationRepository; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Clock; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +/** Production composition root for the first built-in dynamic enterprise login Adapter. */ +@Configuration(proxyBeanMethods = false) +public class EnterpriseOidcRuntimeConfiguration { + + @Bean + @ConditionalOnProperty( + name = "skillhub.enterprise.oidc.enabled", + havingValue = "false", + matchIfMissing = true + ) + EnterpriseRedirectLoginGateway disabledEnterpriseRedirectLoginGateway() { + return new DisabledEnterpriseRedirectLoginGateway(); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "skillhub.enterprise.oidc.enabled", havingValue = "true") + static class EnabledRuntime { + + @Bean(destroyMethod = "close") + InMemorySecretEnvelopeKeyring enterpriseSecretEnvelopeKeyring( + EnterpriseSecretEnvelopeProperties properties + ) { + return properties.createKeyring(); + } + + @Bean + LoginConnectionSecretService loginConnectionSecretService( + LoginConnectionRepository connections, + LoginConnectionSecretRepository secrets, + InMemorySecretEnvelopeKeyring keyring + ) { + return new LoginConnectionSecretService( + connections, + secrets, + new AesGcmSecretEnvelopeCipher(keyring) + ); + } + + @Bean + OidcOutboundTargetPolicy oidcOutboundTargetPolicy(EnterpriseOidcProperties properties) { + properties.validateEnabledConfiguration(); + return new OidcOutboundTargetPolicy( + OidcHostAddressResolver.system(), + properties.getPrivateHostAllowlist() + ); + } + + @Bean + JdkOidcSingleHopHttpTransport oidcHttpTransport() { + return new JdkOidcSingleHopHttpTransport(); + } + + @Bean + OidcMetadataManager oidcMetadataManager( + OidcOutboundTargetPolicy targetPolicy, + JdkOidcSingleHopHttpTransport transport, + RemoteIdentityIoExecutor remoteIdentityIo + ) { + SecureOidcDocumentClient documents = new SecureOidcDocumentClient( + targetPolicy, + transport + ); + return new OidcMetadataManager( + new DefaultOidcMetadataSource(documents, targetPolicy), + remoteIdentityIo + ); + } + + @Bean + OidcAuthorizationTransactionStore oidcAuthorizationTransactionStore( + StringRedisTemplate redis, + ObjectMapper objectMapper + ) { + return new RedisOidcAuthorizationTransactionStore(redis, objectMapper); + } + + @Bean + OidcAuthorizationTransactionService oidcAuthorizationTransactionService( + OidcMetadataManager metadata, + OidcAuthorizationTransactionStore transactions + ) { + return new OidcAuthorizationTransactionService(metadata, transactions); + } + + @Bean + OidcLoginConnectionRuntimeSnapshotMaterializer oidcRuntimeSnapshotDecoder() { + return new OidcLoginConnectionRuntimeSnapshotMaterializer(); + } + + @Bean + LoginConnectionRuntimeSnapshotMaterializer loginConnectionRuntimeSnapshotMaterializer( + List decoders + ) { + return new BuiltInLoginConnectionRuntimeSnapshotMaterializer(decoders); + } + + @Bean + OidcConnectionMetadataProbe oidcConnectionMetadataProbe(OidcMetadataManager metadata) { + return (key, issuer, occurredAt) -> metadata.get(key, issuer, occurredAt); + } + + @Bean + OidcLoginConnectionControlAdapter oidcLoginConnectionControlAdapter( + LoginConnectionRuntimeSnapshotMaterializer materializer, + LoginConnectionSecretService secrets, + OidcConnectionMetadataProbe metadata, + Clock clock + ) { + return new OidcLoginConnectionControlAdapter( + materializer, + secrets, + metadata, + clock + ); + } + + @Bean + LoginConnectionControlAdapterRegistry loginConnectionControlAdapterRegistry( + List adapters + ) { + return new LoginConnectionControlAdapterRegistry(adapters); + } + + @Bean + EnterpriseConnectionContinuationRegistry enterpriseConnectionRegistry( + LoginConnectionRepository connections, + LoginConnectionRevisionRepository revisions, + LoginConnectionRuntimeSnapshotMaterializer materializer, + OrganizationRepository organizations + ) { + return new PersistentEnterpriseConnectionRegistry( + connections, + revisions, + materializer, + organizations + ); + } + + @Bean + OidcRedirectAuthenticationAdapter oidcRedirectAuthenticationAdapter( + OidcAuthorizationTransactionService transactions, + OidcMetadataManager metadata, + OidcOutboundTargetPolicy targetPolicy, + JdkOidcSingleHopHttpTransport transport, + LoginConnectionSecretService secrets, + RemoteIdentityIoExecutor remoteIdentityIo, + EnterpriseConnectionContinuationRegistry connections, + Clock clock + ) { + OidcTokenEndpointClient tokenEndpoint = new OidcTokenEndpointClient( + targetPolicy, + transport, + secrets, + remoteIdentityIo + ); + return new OidcRedirectAuthenticationAdapter( + transactions, + metadata, + tokenEndpoint, + new OidcIdTokenValidator(metadata), + connections, + clock + ); + } + + @Bean + BuiltInRedirectAuthenticationAdapterRegistry redirectAuthenticationAdapterRegistry( + List> adapters + ) { + return new BuiltInRedirectAuthenticationAdapterRegistry(adapters); + } + + @Bean + EnterpriseIdentityAssociationService enterpriseIdentityAssociationService( + IdentityCoreActivation activation, + ExternalIdentityRepository externalIdentities, + PreProvisionedLoginSubjectRepository subjects, + OrganizationDomainRepository domains, + OrganizationMembershipRepository memberships, + OrganizationRepository organizations, + UserAccountRepository accounts, + GlobalNamespaceMembershipService globalMemberships, + ApplicationEventPublisher events, + PlatformTransactionManager transactionManager, + Clock clock + ) { + return new EnterpriseIdentityAssociationService( + activation, + externalIdentities, + subjects, + domains, + memberships, + organizations, + accounts, + globalMemberships, + events, + transactionManager, + clock + ); + } + + @Bean + EnterpriseRedirectLoginGateway enterpriseRedirectLoginGateway( + EnterpriseConnectionContinuationRegistry connections, + BuiltInRedirectAuthenticationAdapterRegistry adapters, + EnterpriseIdentityAssociationService associations, + UserAccountRepository accounts, + RbacService rbac, + EnterpriseBrowserSessionService sessions, + EnterpriseIdentityRolloutProperties rollout + ) { + return new EnterpriseRedirectLoginService( + connections, + adapters, + associations, + accounts, + rbac, + sessions, + rollout + ); + } + } + + private static final class DisabledEnterpriseRedirectLoginGateway + implements EnterpriseRedirectLoginGateway { + + @Override + public boolean isAvailable(ConnectionHandle handle) { + return false; + } + + @Override + public com.iflytek.skillhub.auth.federation.core.RedirectStartResult start( + ConnectionHandle handle, + RedirectStartRequest request + ) { + throw new ConnectionUnavailableException(); + } + + @Override + public LoginCompletion complete( + ConnectionHandle handle, + RedirectCompleteRequest request, + HttpServletRequest httpRequest + ) { + throw new ConnectionUnavailableException(); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperation.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperation.java new file mode 100644 index 00000000..25ddef05 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperation.java @@ -0,0 +1,180 @@ +package com.iflytek.skillhub.auth.operation; + +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestFailureReason; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** Correlated identity control-plane operation containing only bounded, non-secret diagnostics. */ +@Entity +@Table(name = "identity_operation") +public class IdentityOperation { + + public static final String LOGIN_CONNECTION_TEST = "LOGIN_CONNECTION_TEST"; + + @Id + @Column(length = 64) + private String id; + + @Column(name = "organization_id", length = 64) + private String organizationId; + + @Column(name = "connection_id", length = 64) + private String connectionId; + + @Column(name = "operation_type", nullable = false, length = 128) + private String operationType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private IdentityOperationStatus status; + + @Column(name = "request_id", length = 128) + private String requestId; + + @Column(name = "external_request_id", length = 256) + private String externalRequestId; + + @Column(name = "error_code", length = 128) + private String errorCode; + + @Column(name = "started_at", nullable = false) + private Instant startedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(nullable = false) + private long version; + + protected IdentityOperation() { + } + + private IdentityOperation( + String organizationId, + String connectionId, + String revisionId, + IdentityOperationStatus status, + String requestId, + String errorCode, + Instant occurredAt + ) { + this.id = UUID.randomUUID().toString(); + this.organizationId = requireText(organizationId, "organizationId"); + this.connectionId = requireText(connectionId, "connectionId"); + this.operationType = LOGIN_CONNECTION_TEST; + this.externalRequestId = requireText(revisionId, "revisionId"); + this.status = Objects.requireNonNull(status, "status"); + this.requestId = normalizeNullable(requestId); + this.errorCode = normalizeNullable(errorCode); + this.startedAt = Objects.requireNonNull(occurredAt, "occurredAt"); + this.completedAt = occurredAt; + this.createdAt = occurredAt; + this.updatedAt = occurredAt; + } + + public static IdentityOperation successfulLoginConnectionTest( + String organizationId, + String connectionId, + String revisionId, + String requestId, + Instant occurredAt + ) { + return new IdentityOperation( + organizationId, + connectionId, + revisionId, + IdentityOperationStatus.SUCCEEDED, + requestId, + null, + occurredAt + ); + } + + public static IdentityOperation failedLoginConnectionTest( + String organizationId, + String connectionId, + String revisionId, + String requestId, + LoginConnectionTestFailureReason reason, + Instant occurredAt + ) { + return new IdentityOperation( + organizationId, + connectionId, + revisionId, + IdentityOperationStatus.FAILED, + requestId, + Objects.requireNonNull(reason, "reason").name(), + occurredAt + ); + } + + private static String normalizeNullable(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } + + public String getId() { + return id; + } + + public String getOrganizationId() { + return organizationId; + } + + public String getConnectionId() { + return connectionId; + } + + public String getOperationType() { + return operationType; + } + + public IdentityOperationStatus getStatus() { + return status; + } + + public Optional getRequestId() { + return Optional.ofNullable(requestId); + } + + public String getRevisionId() { + return externalRequestId; + } + + public Optional getErrorCode() { + return Optional.ofNullable(errorCode); + } + + public Instant getStartedAt() { + return startedAt; + } + + public Instant getCompletedAt() { + return completedAt; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationRepository.java new file mode 100644 index 00000000..1bcde8a3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.operation; + +import java.util.Optional; + +public interface IdentityOperationRepository { + + IdentityOperation save(IdentityOperation operation); + + Optional findLatestLoginConnectionTest( + String organizationId, + String connectionId + ); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationSpringDataRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationSpringDataRepository.java new file mode 100644 index 00000000..9022fe4d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationSpringDataRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.operation; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +interface IdentityOperationSpringDataRepository extends JpaRepository { + + Optional findFirstByOrganizationIdAndConnectionIdAndOperationTypeOrderByStartedAtDescIdDesc( + String organizationId, + String connectionId, + String operationType + ); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationStatus.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationStatus.java new file mode 100644 index 00000000..8a044665 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/IdentityOperationStatus.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.operation; + +public enum IdentityOperationStatus { + PENDING, + SUCCEEDED, + FAILED, + CONFLICT +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/JpaIdentityOperationRepositoryAdapter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/JpaIdentityOperationRepositoryAdapter.java new file mode 100644 index 00000000..9f35a2cb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/JpaIdentityOperationRepositoryAdapter.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.auth.operation; + +import java.util.Objects; +import java.util.Optional; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class JpaIdentityOperationRepositoryAdapter implements IdentityOperationRepository { + + private final IdentityOperationSpringDataRepository delegate; + + public JpaIdentityOperationRepositoryAdapter(IdentityOperationSpringDataRepository delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + @Transactional + public IdentityOperation save(IdentityOperation operation) { + return delegate.saveAndFlush(Objects.requireNonNull(operation, "operation")); + } + + @Override + @Transactional(readOnly = true) + public Optional findLatestLoginConnectionTest( + String organizationId, + String connectionId + ) { + return delegate.findFirstByOrganizationIdAndConnectionIdAndOperationTypeOrderByStartedAtDescIdDesc( + requireText(organizationId, "organizationId"), + requireText(connectionId, "connectionId"), + IdentityOperation.LOGIN_CONNECTION_TEST + ); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return normalized; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/package-info.java new file mode 100644 index 00000000..60b3dd41 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/operation/package-info.java @@ -0,0 +1,2 @@ +/** Persistent, tenant-scoped correlation records for enterprise identity operations. */ +package com.iflytek.skillhub.auth.operation; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistryTest.java new file mode 100644 index 00000000..2e152465 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/control/LoginConnectionControlAdapterRegistryTest.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.auth.connection.control; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.auth.connection.core.AdapterKey; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class LoginConnectionControlAdapterRegistryTest { + + @Test + void resolvesAReviewedBuiltInAdapterWithoutProviderBranching() { + LoginConnectionControlAdapter oidc = new StubAdapter("oidc"); + LoginConnectionControlAdapterRegistry registry = + new LoginConnectionControlAdapterRegistry(List.of(oidc)); + + assertThat(registry.require(new AdapterKey("oidc"))).isSameAs(oidc); + } + + @Test + void rejectsDuplicateAdapterKeysAndUnknownRuntimeValues() { + assertThatThrownBy(() -> new LoginConnectionControlAdapterRegistry(List.of( + new StubAdapter("oidc"), + new StubAdapter("oidc") + ))).isInstanceOf(IllegalStateException.class); + + LoginConnectionControlAdapterRegistry registry = + new LoginConnectionControlAdapterRegistry(List.of(new StubAdapter("oidc"))); + assertThatThrownBy(() -> registry.require(new AdapterKey("database-class-name"))) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class) + .hasMessage("error.loginConnection.adapter.unsupported"); + } + + private record StubAdapter(AdapterKey adapterKey) implements LoginConnectionControlAdapter { + + StubAdapter(String adapterKey) { + this(new AdapterKey(adapterKey)); + } + + @Override + public Optional secretPurpose() { + return Optional.empty(); + } + + @Override + public PreparedLoginConnectionRevision prepare( + LoginConnectionConfigurationInput configuration + ) { + throw new UnsupportedOperationException(); + } + + @Override + public LoginConnectionTestProbe testProbe() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapterTest.java new file mode 100644 index 00000000..7926ebda --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/connection/oidc/OidcLoginConnectionControlAdapterTest.java @@ -0,0 +1,170 @@ +package com.iflytek.skillhub.auth.connection.oidc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.auth.connection.control.LoginConnectionConfigurationInput; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionSnapshot; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionRuntimeSnapshotMaterializer; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestException; +import com.iflytek.skillhub.auth.connection.control.LoginConnectionTestFailureReason; +import com.iflytek.skillhub.auth.connection.control.PreparedLoginConnectionRevision; +import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot; +import com.iflytek.skillhub.auth.connection.core.ConnectionHandle; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterial; +import com.iflytek.skillhub.auth.connection.secret.SecretMaterialResolver; +import com.iflytek.skillhub.auth.connection.secret.SecretPurpose; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class OidcLoginConnectionControlAdapterTest { + + private static final Instant NOW = Instant.parse("2026-09-08T12:00:00Z"); + + @Test + void compilesOnlyTheVersionedNonSecretOidcConfiguration() { + OidcLoginConnectionControlAdapter adapter = adapter( + mock(LoginConnectionRuntimeSnapshotMaterializer.class), + mock(SecretMaterialResolver.class), + mock(OidcConnectionMetadataProbe.class) + ); + + PreparedLoginConnectionRevision prepared = adapter.prepare( + new LoginConnectionConfigurationInput(Map.of( + "issuer", "https://id.example.com", + "clientId", "skillhub", + "scopes", Set.of("openid", "email") + )) + ); + + assertThat(prepared.adapterDescriptor()).isEqualTo( + OidcRedirectAuthenticationAdapter.supportedDescriptor() + ); + assertThat(prepared.typedConfigJson()).contains( + "https://id.example.com", "skillhub", "openid", "email" + ); + assertThat(prepared.typedConfigJson()).doesNotContain("secret"); + assertThat(prepared.secretPurpose()).contains(SecretPurpose.LOGIN_CLIENT_SECRET); + } + + @Test + void rejectsUnknownConfigurationFields() { + OidcLoginConnectionControlAdapter adapter = adapter( + mock(LoginConnectionRuntimeSnapshotMaterializer.class), + mock(SecretMaterialResolver.class), + mock(OidcConnectionMetadataProbe.class) + ); + + assertThatThrownBy(() -> adapter.prepare(new LoginConnectionConfigurationInput(Map.of( + "issuer", "https://id.example.com", + "clientId", "skillhub", + "scopes", Set.of("openid"), + "clientSecret", "must-not-be-accepted-here" + )))).hasMessage("error.loginConnection.configuration.invalid"); + } + + @Test + @SuppressWarnings("unchecked") + void preflightVerifiesSecretAndMetadataButNeverReturnsSecretMaterial() { + LoginConnectionRuntimeSnapshotMaterializer materializer = + mock(LoginConnectionRuntimeSnapshotMaterializer.class); + SecretMaterialResolver secrets = mock(SecretMaterialResolver.class); + OidcConnectionMetadataProbe metadata = mock(OidcConnectionMetadataProbe.class); + LoginConnectionRevisionSnapshot revision = revision(); + LoginConnectionRuntimeSnapshot runtime = runtime(); + doReturn(runtime).when(materializer).materialize(revision); + given(secrets.resolve( + runtime.secretReference().orElseThrow(), + SecretPurpose.LOGIN_CLIENT_SECRET, + NOW + )).willReturn(SecretMaterial.copyOf("top-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + + adapter(materializer, secrets, metadata).testProbe().verify(revision); + } + + @Test + @SuppressWarnings("unchecked") + void mapsMetadataFailureToABoundedSafeCategory() { + LoginConnectionRuntimeSnapshotMaterializer materializer = + mock(LoginConnectionRuntimeSnapshotMaterializer.class); + SecretMaterialResolver secrets = mock(SecretMaterialResolver.class); + OidcConnectionMetadataProbe metadata = mock(OidcConnectionMetadataProbe.class); + LoginConnectionRevisionSnapshot revision = revision(); + LoginConnectionRuntimeSnapshot runtime = runtime(); + doReturn(runtime).when(materializer).materialize(revision); + given(secrets.resolve( + runtime.secretReference().orElseThrow(), + SecretPurpose.LOGIN_CLIENT_SECRET, + NOW + )).willReturn(SecretMaterial.copyOf("top-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + org.mockito.BDDMockito.willThrow(new OidcMetadataUnavailableException()).given(metadata).verify( + new OidcMetadataCacheKey("org-1", "connection-1", 1), + runtime.config().issuer(), + NOW + ); + + assertThatThrownBy(() -> adapter(materializer, secrets, metadata) + .testProbe().verify(revision)) + .isInstanceOf(LoginConnectionTestException.class) + .extracting(error -> ((LoginConnectionTestException) error).reason()) + .isEqualTo(LoginConnectionTestFailureReason.METADATA); + } + + private static OidcLoginConnectionControlAdapter adapter( + LoginConnectionRuntimeSnapshotMaterializer materializer, + SecretMaterialResolver secrets, + OidcConnectionMetadataProbe metadata + ) { + return new OidcLoginConnectionControlAdapter( + materializer, + secrets, + metadata, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + } + + private static LoginConnectionRuntimeSnapshot runtime() { + return new LoginConnectionRuntimeSnapshot<>( + Optional.of("org-1"), + "connection-1", + new ConnectionHandle("login-test-handle"), + 1, + OidcRedirectAuthenticationAdapter.supportedDescriptor(), + Optional.of(new com.iflytek.skillhub.auth.connection.secret.SecretReference( + "org-1", "connection-1", SecretPurpose.LOGIN_CLIENT_SECRET, 1 + )), + new OidcLoginConnectionRuntimeConfig( + new OidcIssuer("https://id.example.com"), + "skillhub", + Set.of("openid") + ) + ); + } + + private static LoginConnectionRevisionSnapshot revision() { + return new LoginConnectionRevisionSnapshot( + Optional.of("org-1"), + "connection-1", + new ConnectionHandle("login-test-handle"), + "revision-1", + 1, + OidcLoginConnectionRuntimeConfig.ADAPTER_KEY, + "1.0", + 1, + "[\"IDENTITY_ASSERTION\"]", + "{\"issuer\":\"https://id.example.com\",\"clientId\":\"skillhub\",\"scopes\":[\"openid\"]}", + Optional.of(new com.iflytek.skillhub.auth.connection.secret.SecretReference( + "org-1", "connection-1", SecretPurpose.LOGIN_CLIENT_SECRET, 1 + )), + com.iflytek.skillhub.auth.federation.core.IdentityCorrelationPolicySettings.disabled() + ); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditAction.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditAction.java index d7432257..f9a0a85f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditAction.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditAction.java @@ -14,5 +14,12 @@ public enum OrganizationAuditAction { MEMBER_REACTIVATED, MEMBER_DEPROVISIONED, ROLE_GRANTED, - ROLE_REVOKED + ROLE_REVOKED, + LOGIN_CONNECTION_CREATED, + LOGIN_CONNECTION_REVISION_CREATED, + LOGIN_CONNECTION_TEST_SUCCEEDED, + LOGIN_CONNECTION_TEST_FAILED, + LOGIN_CONNECTION_ACTIVATED, + LOGIN_CONNECTION_SUSPENDED, + LOGIN_CONNECTION_DISABLED } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditEvent.java index 73da7aed..fddfcd24 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditEvent.java @@ -46,6 +46,27 @@ public record OrganizationAuditEvent( ); } + public static OrganizationAuditEvent failed( + String actorUserId, + String organizationId, + OrganizationAuditAction action, + OrganizationAuditTargetType targetType, + String targetReference, + String requestId, + OrganizationAuditDetail detail + ) { + return new OrganizationAuditEvent( + actorUserId, + organizationId, + action, + targetType, + targetReference, + OrganizationAuditResult.FAILED, + requestId, + detail + ); + } + @Override public String toString() { return "OrganizationAuditEvent[actorUserId=%s, organizationId=%s, action=%s, " diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditTargetType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditTargetType.java index 9c457d40..bed94aa3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditTargetType.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/OrganizationAuditTargetType.java @@ -5,7 +5,9 @@ public enum OrganizationAuditTargetType { ORGANIZATION("ORGANIZATION"), DOMAIN("ORGANIZATION_DOMAIN"), MEMBERSHIP("ORGANIZATION_MEMBERSHIP"), - ROLE_BINDING("ORGANIZATION_ROLE_BINDING"); + ROLE_BINDING("ORGANIZATION_ROLE_BINDING"), + LOGIN_CONNECTION("LOGIN_CONNECTION"), + LOGIN_CONNECTION_REVISION("LOGIN_CONNECTION_REVISION"); private final String storageValue; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicy.java index 7c462073..0b6f5513 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicy.java @@ -48,6 +48,14 @@ public class OrganizationAuthorizationPolicy { OrganizationRole.ORG_AUDITOR ) ), + Map.entry( + OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS, + Set.of( + OrganizationRole.ORG_OWNER, + OrganizationRole.IDENTITY_ADMIN, + OrganizationRole.ORG_AUDITOR + ) + ), Map.entry( OrganizationAdministrativeAction.MANAGE_ORGANIZATION_LIFECYCLE, Set.of(OrganizationRole.ORG_OWNER) diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicyTest.java index 259f0519..4a08adcb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicyTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/organization/OrganizationAuthorizationPolicyTest.java @@ -39,6 +39,14 @@ class OrganizationAuthorizationPolicyTest { OrganizationRole.ORG_AUDITOR ) ), + Map.entry( + OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS, + Set.of( + OrganizationRole.ORG_OWNER, + OrganizationRole.IDENTITY_ADMIN, + OrganizationRole.ORG_AUDITOR + ) + ), Map.entry( OrganizationAdministrativeAction.MANAGE_ORGANIZATION_LIFECYCLE, Set.of(OrganizationRole.ORG_OWNER)