feat(identity): add enterprise connection management

This commit is contained in:
XiaoSeS 2026-09-08 17:23:28 +08:00
parent 43e895af17
commit cceb8ce0d3
37 changed files with 2523 additions and 2 deletions

View file

@ -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<LoginConnectionResponse>> list(
@PathVariable String organizationId,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.read", appService.list(organizationId, principal.userId()));
}
@GetMapping("/{connectionId}")
public ApiResponse<LoginConnectionResponse> get(
@PathVariable String organizationId,
@PathVariable String connectionId,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok(
"response.success.read",
appService.get(organizationId, connectionId, principal.userId())
);
}
@PostMapping
public ApiResponse<LoginConnectionResponse> 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<LoginConnectionResponse> 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<LoginConnectionTestResponse> 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<LoginConnectionResponse> 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<LoginConnectionResponse> 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<LoginConnectionResponse> 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())
);
}
}

View file

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

View file

@ -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<String, Object> configuration,
@Size(min = 1, max = 4096) String clientSecret,
boolean verifiedEmailCorrelationEnabled,
boolean jitProvisioningEnabled
) {
@Override
public String toString() {
return "LoginConnectionCreateRequest[displayName=" + displayName
+ ", adapterKey=" + adapterKey
+ ", configuration=<redacted>, clientSecret="
+ (clientSecret == null ? "none" : "<redacted>")
+ ", verifiedEmailCorrelationEnabled=" + verifiedEmailCorrelationEnabled
+ ", jitProvisioningEnabled=" + jitProvisioningEnabled + "]";
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record LoginConnectionHealthResponse(
String status,
String failureReason,
Instant checkedAt
) {
}

View file

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

View file

@ -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<String, Object> configuration,
@Size(min = 1, max = 4096) String clientSecret,
boolean verifiedEmailCorrelationEnabled,
boolean jitProvisioningEnabled
) {
@Override
public String toString() {
return "LoginConnectionRevisionCreateRequest[configuration=<redacted>"
+ ", clientSecret=" + (clientSecret == null ? "unchanged" : "<redacted>")
+ ", verifiedEmailCorrelationEnabled=" + verifiedEmailCorrelationEnabled
+ ", jitProvisioningEnabled=" + jitProvisioningEnabled + "]";
}
}

View file

@ -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<String, Object> configuration,
boolean verifiedEmailCorrelationEnabled,
boolean jitProvisioningEnabled,
Instant createdAt
) {
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record LoginConnectionSecretSummaryResponse(
boolean configured,
Instant updatedAt,
Instant previousValidUntil
) {
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
public record LoginConnectionTestResponse(
boolean success,
String revisionId,
String failureReason,
Instant checkedAt
) {
}

View file

@ -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<Map<String, Object>> CONFIG_TYPE = new TypeReference<>() { };
private final LoginConnectionRepository connections;
private final LoginConnectionRevisionRepository revisions;
private final IdentityOperationRepository operations;
private final ObjectProvider<LoginConnectionControlAdapterRegistry> adapterRegistry;
private final ObjectProvider<LoginConnectionSecretService> secretService;
private final ObjectMapper objectMapper;
public EnterpriseConnectionQueryRepository(
LoginConnectionRepository connections,
LoginConnectionRevisionRepository revisions,
IdentityOperationRepository operations,
ObjectProvider<LoginConnectionControlAdapterRegistry> adapterRegistry,
ObjectProvider<LoginConnectionSecretService> 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<LoginConnectionResponse> 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");
}
}

View file

@ -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<LoginConnectionControlAdapterRegistry> adapterRegistry;
private final ObjectProvider<LoginConnectionSecretService> 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<LoginConnectionControlAdapterRegistry> adapterRegistry,
ObjectProvider<LoginConnectionSecretService> 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<LoginConnectionResponse> 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);
}
}

View file

@ -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<OrganizationRole> roles) {
return new OrganizationResponse(
"organization-a",

View file

@ -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 <T> ObjectProvider<T> provider(Class<T> type, T bean) {
StaticListableBeanFactory factory = new StaticListableBeanFactory();
factory.addBean(type.getName(), bean);
return factory.getBeanProvider(type);
}
}

View file

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

View file

@ -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> 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<IdentityOperation> 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<OrganizationAuditEvent> 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 <T> ObjectProvider<T> provider(Class<T> type, T bean) {
StaticListableBeanFactory factory = new StaticListableBeanFactory();
factory.addBean(type.getName(), bean);
return factory.getBeanProvider(type);
}
}

View file

@ -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<String, Object> values) {
public LoginConnectionConfigurationInput {
Objects.requireNonNull(values, "values");
values = Map.copyOf(new LinkedHashMap<>(values));
}
@Override
public String toString() {
return "LoginConnectionConfigurationInput[values=<redacted>]";
}
}

View file

@ -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> secretPurpose();
PreparedLoginConnectionRevision prepare(LoginConnectionConfigurationInput configuration);
LoginConnectionTestProbe testProbe();
}

View file

@ -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<AdapterKey, LoginConnectionControlAdapter> adapters;
public LoginConnectionControlAdapterRegistry(List<LoginConnectionControlAdapter> adapters) {
Objects.requireNonNull(adapters, "adapters");
Map<AdapterKey, LoginConnectionControlAdapter> 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;
}
}

View file

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

View file

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

View file

@ -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> 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=<redacted>, typedConfigJson=<redacted>, secretPurpose="
+ secretPurpose.map(SecretPurpose::value).orElse("none") + "]";
}
}

View file

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

View file

@ -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<String> 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> secretPurpose() {
return Optional.of(SecretPurpose.LOGIN_CLIENT_SECRET);
}
@Override
public PreparedLoginConnectionRevision prepare(
LoginConnectionConfigurationInput configuration
) {
try {
Map<String, Object> 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<String> scopes = scopes(values.get("scopes"));
AdapterDescriptor descriptor = OidcRedirectAuthenticationAdapter.supportedDescriptor();
Map<String, Object> persisted = new LinkedHashMap<>();
persisted.put("issuer", issuer.value());
persisted.put("clientId", clientId);
persisted.put("scopes", scopes);
List<String> 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<OidcLoginConnectionRuntimeConfig> 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<OidcLoginConnectionRuntimeConfig> 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<OidcLoginConnectionRuntimeConfig>) runtime;
} catch (ConnectionUnavailableException invalid) {
throw failure(LoginConnectionTestFailureReason.CONFIGURATION);
} catch (RuntimeException invalid) {
throw failure(LoginConnectionTestFailureReason.CONFIGURATION);
}
}
private static List<String> scopes(Object raw) {
if (!(raw instanceof java.util.Collection<?> values)
|| values.isEmpty()
|| values.size() > MAX_SCOPES) {
throw invalid();
}
Set<String> 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<String> ordered = new ArrayList<>(normalized);
ordered.sort(Comparator.naturalOrder());
return List.copyOf(ordered);
}
private static String requiredString(Map<String, Object> 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);
}
}

View file

@ -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<LoginConnectionRuntimeSnapshotDecoder> 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<LoginConnectionControlAdapter> 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<RedirectAuthenticationAdapter<?>> 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();
}
}
}

View file

@ -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<String> getRequestId() {
return Optional.ofNullable(requestId);
}
public String getRevisionId() {
return externalRequestId;
}
public Optional<String> getErrorCode() {
return Optional.ofNullable(errorCode);
}
public Instant getStartedAt() {
return startedAt;
}
public Instant getCompletedAt() {
return completedAt;
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.auth.operation;
import java.util.Optional;
public interface IdentityOperationRepository {
IdentityOperation save(IdentityOperation operation);
Optional<IdentityOperation> findLatestLoginConnectionTest(
String organizationId,
String connectionId
);
}

View file

@ -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<IdentityOperation, String> {
Optional<IdentityOperation> findFirstByOrganizationIdAndConnectionIdAndOperationTypeOrderByStartedAtDescIdDesc(
String organizationId,
String connectionId,
String operationType
);
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.auth.operation;
public enum IdentityOperationStatus {
PENDING,
SUCCEEDED,
FAILED,
CONFLICT
}

View file

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

View file

@ -0,0 +1,2 @@
/** Persistent, tenant-scoped correlation records for enterprise identity operations. */
package com.iflytek.skillhub.auth.operation;

View file

@ -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> secretPurpose() {
return Optional.empty();
}
@Override
public PreparedLoginConnectionRevision prepare(
LoginConnectionConfigurationInput configuration
) {
throw new UnsupportedOperationException();
}
@Override
public LoginConnectionTestProbe testProbe() {
throw new UnsupportedOperationException();
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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