mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
feat(identity): add enterprise connection management
This commit is contained in:
parent
43e895af17
commit
cceb8ce0d3
37 changed files with 2523 additions and 2 deletions
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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 + "]";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginConnectionHealthResponse(
|
||||
String status,
|
||||
String failureReason,
|
||||
Instant checkedAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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 + "]";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginConnectionSecretSummaryResponse(
|
||||
boolean configured,
|
||||
Instant updatedAt,
|
||||
Instant previousValidUntil
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginConnectionTestResponse(
|
||||
boolean success,
|
||||
String revisionId,
|
||||
String failureReason,
|
||||
Instant checkedAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>]";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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") + "]";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.auth.operation;
|
||||
|
||||
public enum IdentityOperationStatus {
|
||||
PENDING,
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
CONFLICT
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Persistent, tenant-scoped correlation records for enterprise identity operations. */
|
||||
package com.iflytek.skillhub.auth.operation;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue