mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
feat(identity): finalize enterprise login control plane
This commit is contained in:
parent
cceb8ce0d3
commit
f27974efa6
28 changed files with 1971 additions and 375 deletions
|
|
@ -9,9 +9,7 @@ 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
|
||||
@Size(min = 1, max = 4096) String clientSecret
|
||||
) {
|
||||
|
||||
@Override
|
||||
|
|
@ -19,8 +17,6 @@ public record LoginConnectionCreateRequest(
|
|||
return "LoginConnectionCreateRequest[displayName=" + displayName
|
||||
+ ", adapterKey=" + adapterKey
|
||||
+ ", configuration=<redacted>, clientSecret="
|
||||
+ (clientSecret == null ? "none" : "<redacted>")
|
||||
+ ", verifiedEmailCorrelationEnabled=" + verifiedEmailCorrelationEnabled
|
||||
+ ", jitProvisioningEnabled=" + jitProvisioningEnabled + "]";
|
||||
+ (clientSecret == null ? "none" : "<redacted>") + "]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,12 @@ import java.util.Map;
|
|||
|
||||
public record LoginConnectionRevisionCreateRequest(
|
||||
@NotEmpty Map<String, Object> configuration,
|
||||
@Size(min = 1, max = 4096) String clientSecret,
|
||||
boolean verifiedEmailCorrelationEnabled,
|
||||
boolean jitProvisioningEnabled
|
||||
@Size(min = 1, max = 4096) String clientSecret
|
||||
) {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "LoginConnectionRevisionCreateRequest[configuration=<redacted>"
|
||||
+ ", clientSecret=" + (clientSecret == null ? "unchanged" : "<redacted>")
|
||||
+ ", verifiedEmailCorrelationEnabled=" + verifiedEmailCorrelationEnabled
|
||||
+ ", jitProvisioningEnabled=" + jitProvisioningEnabled + "]";
|
||||
+ ", clientSecret=" + (clientSecret == null ? "unchanged" : "<redacted>") + "]";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ public record LoginConnectionRevisionResponse(
|
|||
String adapterContractVersion,
|
||||
int configSchemaVersion,
|
||||
Map<String, Object> configuration,
|
||||
boolean verifiedEmailCorrelationEnabled,
|
||||
boolean jitProvisioningEnabled,
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,8 +147,6 @@ public class EnterpriseConnectionQueryRepository {
|
|||
revision.getAdapterContractVersion(),
|
||||
revision.getConfigSchemaVersion(),
|
||||
Map.copyOf(objectMapper.readValue(revision.getTypedConfig(), CONFIG_TYPE)),
|
||||
revision.correlationPolicy().verifiedEmailCorrelationEnabled(),
|
||||
revision.correlationPolicy().jitProvisioningEnabled(),
|
||||
revision.getCreatedAt()
|
||||
);
|
||||
} catch (Exception invalidStoredConfiguration) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ 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;
|
||||
|
|
@ -144,7 +143,6 @@ public class EnterpriseConnectionAppService {
|
|||
1,
|
||||
prepared,
|
||||
secretVersion,
|
||||
policy(request.verifiedEmailCorrelationEnabled(), request.jitProvisioningEnabled()),
|
||||
actorUserId,
|
||||
now
|
||||
));
|
||||
|
|
@ -207,7 +205,6 @@ public class EnterpriseConnectionAppService {
|
|||
previous.getRevision() + 1,
|
||||
prepared,
|
||||
secretVersion,
|
||||
policy(request.verifiedEmailCorrelationEnabled(), request.jitProvisioningEnabled()),
|
||||
actorUserId,
|
||||
now
|
||||
));
|
||||
|
|
@ -349,7 +346,6 @@ public class EnterpriseConnectionAppService {
|
|||
long revisionNumber,
|
||||
PreparedLoginConnectionRevision prepared,
|
||||
Long secretVersion,
|
||||
IdentityCorrelationPolicySettings correlationPolicy,
|
||||
String actorUserId,
|
||||
Instant now
|
||||
) {
|
||||
|
|
@ -365,7 +361,6 @@ public class EnterpriseConnectionAppService {
|
|||
prepared.capabilitiesJson(),
|
||||
prepared.typedConfigJson(),
|
||||
secretVersion,
|
||||
correlationPolicy,
|
||||
actorUserId,
|
||||
now
|
||||
);
|
||||
|
|
@ -401,17 +396,6 @@ public class EnterpriseConnectionAppService {
|
|||
));
|
||||
}
|
||||
|
||||
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"));
|
||||
|
|
|
|||
|
|
@ -195,8 +195,6 @@ class OrganizationAdminControllerTest {
|
|||
"clientId", "skillhub",
|
||||
"scopes", List.of("openid")
|
||||
),
|
||||
false,
|
||||
false,
|
||||
NOW
|
||||
),
|
||||
new LoginConnectionSecretSummaryResponse(true, NOW, null),
|
||||
|
|
@ -217,12 +215,10 @@ class OrganizationAdminControllerTest {
|
|||
"adapterKey": "oidc",
|
||||
"configuration": {
|
||||
"issuer": "https://id.example.com",
|
||||
"clientId": "skillhub",
|
||||
"scopes": ["openid"]
|
||||
"clientId": "skillhub",
|
||||
"scopes": ["openid"]
|
||||
},
|
||||
"clientSecret": "never-echo-this-secret",
|
||||
"verifiedEmailCorrelationEnabled": false,
|
||||
"jitProvisioningEnabled": false
|
||||
"clientSecret": "never-echo-this-secret"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
|
|
|
|||
|
|
@ -93,9 +93,6 @@ class LoginConnectionLifecyclePersistenceTest {
|
|||
@Autowired
|
||||
private LoginConnectionLifecycleService lifecycleService;
|
||||
|
||||
@Autowired
|
||||
private IdentityOperationRepository identityOperationRepository;
|
||||
|
||||
@Autowired
|
||||
private EntityManager entityManager;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|||
|
||||
import com.iflytek.skillhub.domain.organization.MembershipSourceType;
|
||||
import com.iflytek.skillhub.domain.organization.Organization;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationAccessGuard;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationAuthorizationPolicy;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationAuthorizationService;
|
||||
import com.iflytek.skillhub.domain.organization.OrganizationDomain;
|
||||
|
|
@ -63,6 +64,7 @@ import org.testcontainers.junit.jupiter.Testcontainers;
|
|||
JpaOrganizationMembershipRepositoryAdapter.class,
|
||||
JpaOrganizationDomainRepositoryAdapter.class,
|
||||
JpaOrganizationRoleBindingRepositoryAdapter.class,
|
||||
OrganizationAccessGuard.class,
|
||||
OrganizationAuthorizationPolicy.class,
|
||||
OrganizationAuthorizationService.class,
|
||||
OrganizationRoleBindingService.class
|
||||
|
|
@ -362,7 +364,7 @@ class OrganizationPersistenceTest {
|
|||
|
||||
assertThat(membershipRepository.findCurrentByOrganizationIdAndSource(
|
||||
organization.getId(),
|
||||
MembershipSourceType.DIRECTORY,
|
||||
MembershipSourceType.JIT,
|
||||
"directory-user-replacement"
|
||||
)).get().extracting(OrganizationMembership::getId).isEqualTo(replacement.getId());
|
||||
assertThat(entityManager.find(OrganizationMembership.class, history.getId()).getStatus())
|
||||
|
|
@ -630,7 +632,7 @@ class OrganizationPersistenceTest {
|
|||
) {
|
||||
OrganizationMembership membership = OrganizationMembership.provisioned(
|
||||
organizationId,
|
||||
MembershipSourceType.DIRECTORY,
|
||||
MembershipSourceType.JIT,
|
||||
sourceId,
|
||||
userId,
|
||||
userId + "@example.com",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
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 java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Dispatches persisted revisions to reviewed, contract-versioned Adapter decoders. */
|
||||
public final class BuiltInLoginConnectionRuntimeSnapshotMaterializer
|
||||
implements LoginConnectionRuntimeSnapshotMaterializer {
|
||||
|
||||
private final Map<DecoderKey, LoginConnectionRuntimeSnapshotDecoder> decoders;
|
||||
|
||||
public BuiltInLoginConnectionRuntimeSnapshotMaterializer(
|
||||
Collection<? extends LoginConnectionRuntimeSnapshotDecoder> decoders
|
||||
) {
|
||||
Objects.requireNonNull(decoders, "decoders");
|
||||
Map<DecoderKey, LoginConnectionRuntimeSnapshotDecoder> indexed = new LinkedHashMap<>();
|
||||
for (LoginConnectionRuntimeSnapshotDecoder decoder : decoders) {
|
||||
Objects.requireNonNull(decoder, "decoder");
|
||||
if (decoder.contractMajor() < 1) {
|
||||
throw new IllegalArgumentException("Decoder contract major must be positive");
|
||||
}
|
||||
DecoderKey key = new DecoderKey(decoder.adapterKey(), decoder.contractMajor());
|
||||
if (indexed.putIfAbsent(key, decoder) != null) {
|
||||
throw new IllegalArgumentException("Duplicate Login Connection runtime decoder");
|
||||
}
|
||||
}
|
||||
this.decoders = Map.copyOf(indexed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginConnectionRuntimeSnapshot<?> materialize(
|
||||
LoginConnectionRevisionSnapshot revision
|
||||
) {
|
||||
Objects.requireNonNull(revision, "revision");
|
||||
LoginConnectionRuntimeSnapshotDecoder decoder = decoders.get(
|
||||
new DecoderKey(revision.adapterKey(), parseContractMajor(revision.adapterContractVersion()))
|
||||
);
|
||||
if (decoder == null) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
return decoder.decode(revision);
|
||||
}
|
||||
|
||||
private static int parseContractMajor(String contractVersion) {
|
||||
int separator = contractVersion.indexOf('.');
|
||||
if (separator < 1 || separator != contractVersion.lastIndexOf('.')) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(contractVersion.substring(0, separator));
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private record DecoderKey(AdapterKey adapterKey, int contractMajor) {
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionHandle;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
|
@ -16,6 +17,14 @@ public class JpaLoginConnectionRepositoryAdapter implements LoginConnectionRepos
|
|||
this.delegate = Objects.requireNonNull(delegate, "delegate");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<LoginConnection> findAllByOrganizationId(String organizationId) {
|
||||
return delegate.findAllByOrganizationIdOrderByCreatedAtDescIdDesc(
|
||||
requireText(organizationId, "organizationId")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<LoginConnection> findByOrganizationIdAndId(String organizationId, String id) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ public class JpaLoginConnectionRevisionRepositoryAdapter
|
|||
this.delegate = Objects.requireNonNull(delegate, "delegate");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<StoredLoginConnectionRevision> findLatestByConnectionId(String connectionId) {
|
||||
return delegate.findFirstByConnectionIdOrderByRevisionDescIdDesc(
|
||||
requireText(connectionId, "connectionId")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<StoredLoginConnectionRevision> findByConnectionIdAndId(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionHandle;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Organization-scoped persistence port for Login Connection lifecycle state. */
|
||||
public interface LoginConnectionRepository {
|
||||
|
||||
List<LoginConnection> findAllByOrganizationId(String organizationId);
|
||||
|
||||
Optional<LoginConnection> findByOrganizationIdAndId(String organizationId, String id);
|
||||
|
||||
/** Serializes high-risk control-plane mutations such as Secret rotation. */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import java.util.Optional;
|
|||
/** Connection-scoped persistence port for immutable Login Connection revisions. */
|
||||
public interface LoginConnectionRevisionRepository {
|
||||
|
||||
Optional<StoredLoginConnectionRevision> findLatestByConnectionId(String connectionId);
|
||||
|
||||
Optional<StoredLoginConnectionRevision> findByConnectionIdAndId(
|
||||
String connectionId,
|
||||
String revisionId
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||
interface LoginConnectionRevisionSpringDataRepository
|
||||
extends JpaRepository<StoredLoginConnectionRevision, String> {
|
||||
|
||||
Optional<StoredLoginConnectionRevision> findFirstByConnectionIdOrderByRevisionDescIdDesc(
|
||||
String connectionId
|
||||
);
|
||||
|
||||
Optional<StoredLoginConnectionRevision> findByConnectionIdAndId(
|
||||
String connectionId,
|
||||
String id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterKey;
|
||||
import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot;
|
||||
|
||||
/** Adapter-owned decoder for one persisted Login Connection contract major. */
|
||||
public interface LoginConnectionRuntimeSnapshotDecoder {
|
||||
|
||||
AdapterKey adapterKey();
|
||||
|
||||
int contractMajor();
|
||||
|
||||
LoginConnectionRuntimeSnapshot<?> decode(LoginConnectionRevisionSnapshot revision);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.connection.control;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
|
@ -10,6 +11,10 @@ import org.springframework.data.repository.query.Param;
|
|||
/** Internal Spring Data delegate; callers use the tenant-scoped repository port. */
|
||||
interface LoginConnectionSpringDataRepository extends JpaRepository<LoginConnection, String> {
|
||||
|
||||
List<LoginConnection> findAllByOrganizationIdOrderByCreatedAtDescIdDesc(
|
||||
String organizationId
|
||||
);
|
||||
|
||||
Optional<LoginConnection> findByOrganizationIdAndId(String organizationId, String id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ 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.AdapterContractVersion;
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionKind;
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException;
|
||||
import com.iflytek.skillhub.auth.connection.core.InteractionModel;
|
||||
import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretMaterial;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretMaterialResolver;
|
||||
|
|
@ -77,7 +80,7 @@ public final class OidcLoginConnectionControlAdapter implements LoginConnectionC
|
|||
OidcIssuer issuer = new OidcIssuer(requiredString(values, "issuer"));
|
||||
String clientId = requiredString(values, "clientId");
|
||||
List<String> scopes = scopes(values.get("scopes"));
|
||||
AdapterDescriptor descriptor = OidcRedirectAuthenticationAdapter.supportedDescriptor();
|
||||
AdapterDescriptor descriptor = supportedDescriptor();
|
||||
Map<String, Object> persisted = new LinkedHashMap<>();
|
||||
persisted.put("issuer", issuer.value());
|
||||
persisted.put("clientId", clientId);
|
||||
|
|
@ -104,7 +107,7 @@ public final class OidcLoginConnectionControlAdapter implements LoginConnectionC
|
|||
return candidate -> {
|
||||
LoginConnectionRuntimeSnapshot<OidcLoginConnectionRuntimeConfig> runtime =
|
||||
requireOidcRuntime(candidate);
|
||||
var reference = runtime.secretReference().orElseThrow(() -> failure(
|
||||
var reference = candidate.secretReference().orElseThrow(() -> failure(
|
||||
LoginConnectionTestFailureReason.CREDENTIAL
|
||||
));
|
||||
try (SecretMaterial ignored = secrets.resolve(
|
||||
|
|
@ -206,4 +209,19 @@ public final class OidcLoginConnectionControlAdapter implements LoginConnectionC
|
|||
) {
|
||||
return new LoginConnectionTestException(reason);
|
||||
}
|
||||
|
||||
private static AdapterDescriptor supportedDescriptor() {
|
||||
return new AdapterDescriptor(
|
||||
OidcLoginConnectionRuntimeConfig.ADAPTER_KEY,
|
||||
new AdapterContractVersion(1, 0),
|
||||
ConnectionKind.LOGIN,
|
||||
1,
|
||||
Optional.of(InteractionModel.REDIRECT),
|
||||
Set.of(
|
||||
com.iflytek.skillhub.auth.connection.core.AdapterCapability.IDENTITY_ASSERTION,
|
||||
com.iflytek.skillhub.auth.connection.core.AdapterCapability.VERIFIED_EMAIL_ASSERTION,
|
||||
com.iflytek.skillhub.auth.connection.core.AdapterCapability.PROFILE_ATTRIBUTE_ASSERTION
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package com.iflytek.skillhub.auth.connection.oidc;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterKey;
|
||||
import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeConfig;
|
||||
import com.iflytek.skillhub.auth.federation.core.SubjectType;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** Immutable non-secret OIDC client configuration pinned to one connection revision. */
|
||||
public record OidcLoginConnectionRuntimeConfig(
|
||||
OidcIssuer issuer,
|
||||
String clientId,
|
||||
Set<String> scopes
|
||||
) implements LoginConnectionRuntimeConfig {
|
||||
|
||||
public static final AdapterKey ADAPTER_KEY = new AdapterKey("oidc-standard");
|
||||
public static final SubjectType SUBJECT_TYPE = new SubjectType("oidc-sub");
|
||||
private static final int MAX_SCOPES = 16;
|
||||
private static final int MAX_SCOPE_LENGTH = 128;
|
||||
|
||||
public OidcLoginConnectionRuntimeConfig {
|
||||
issuer = Objects.requireNonNull(issuer, "issuer");
|
||||
clientId = requireClientId(clientId);
|
||||
TreeSet<String> normalizedScopes = new TreeSet<>();
|
||||
normalizedScopes.add("openid");
|
||||
for (String scope : Objects.requireNonNull(scopes, "scopes")) {
|
||||
normalizedScopes.add(requireScope(scope));
|
||||
}
|
||||
if (normalizedScopes.size() > MAX_SCOPES) {
|
||||
throw new IllegalArgumentException("OIDC scope count exceeds the safe limit");
|
||||
}
|
||||
scopes = Collections.unmodifiableSet(normalizedScopes);
|
||||
}
|
||||
|
||||
String scopeValue() {
|
||||
return String.join(" ", scopes);
|
||||
}
|
||||
|
||||
private static String requireClientId(String value) {
|
||||
Objects.requireNonNull(value, "clientId");
|
||||
if (value.isBlank()
|
||||
|| !value.equals(value.trim())
|
||||
|| value.length() > 256
|
||||
|| value.codePoints().anyMatch(Character::isISOControl)) {
|
||||
throw new IllegalArgumentException("OIDC client id is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String requireScope(String value) {
|
||||
Objects.requireNonNull(value, "scope");
|
||||
if (value.isEmpty() || value.length() > MAX_SCOPE_LENGTH) {
|
||||
throw new IllegalArgumentException("OIDC scope is invalid");
|
||||
}
|
||||
boolean invalid = value.chars().anyMatch(character ->
|
||||
character < 0x21 || character > 0x7e || character == '"' || character == '\\'
|
||||
);
|
||||
if (invalid) {
|
||||
throw new IllegalArgumentException("OIDC scope is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package com.iflytek.skillhub.auth.connection.oidc;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.StreamReadConstraints;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.auth.connection.control.LoginConnectionRevisionSnapshot;
|
||||
import com.iflytek.skillhub.auth.connection.control.LoginConnectionRuntimeSnapshotDecoder;
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterCapability;
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterContractVersion;
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterDescriptor;
|
||||
import com.iflytek.skillhub.auth.connection.core.AdapterKey;
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionKind;
|
||||
import com.iflytek.skillhub.auth.connection.core.ConnectionUnavailableException;
|
||||
import com.iflytek.skillhub.auth.connection.core.InteractionModel;
|
||||
import com.iflytek.skillhub.auth.connection.core.LoginConnectionRuntimeSnapshot;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** Strictly decodes immutable OIDC revisions; unknown or incompatible fields fail closed. */
|
||||
public final class OidcLoginConnectionRuntimeSnapshotMaterializer
|
||||
implements LoginConnectionRuntimeSnapshotDecoder {
|
||||
|
||||
private static final int MAX_JSON_BYTES = 16 * 1024;
|
||||
private static final Set<String> CONFIG_FIELDS = Set.of("issuer", "clientId", "scopes");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public OidcLoginConnectionRuntimeSnapshotMaterializer() {
|
||||
JsonFactory factory = JsonFactory.builder()
|
||||
.streamReadConstraints(StreamReadConstraints.builder()
|
||||
.maxNestingDepth(8)
|
||||
.maxStringLength(8 * 1024)
|
||||
.maxNumberLength(32)
|
||||
.build())
|
||||
.build();
|
||||
factory.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
|
||||
this.objectMapper = new ObjectMapper(factory);
|
||||
}
|
||||
|
||||
static AdapterDescriptor supportedDescriptor() {
|
||||
return new AdapterDescriptor(
|
||||
OidcLoginConnectionRuntimeConfig.ADAPTER_KEY,
|
||||
new AdapterContractVersion(1, 0),
|
||||
ConnectionKind.LOGIN,
|
||||
1,
|
||||
Optional.of(InteractionModel.REDIRECT),
|
||||
Set.of(
|
||||
AdapterCapability.IDENTITY_ASSERTION,
|
||||
AdapterCapability.VERIFIED_EMAIL_ASSERTION,
|
||||
AdapterCapability.PROFILE_ATTRIBUTE_ASSERTION
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdapterKey adapterKey() {
|
||||
return OidcLoginConnectionRuntimeConfig.ADAPTER_KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int contractMajor() {
|
||||
return supportedDescriptor().contractVersion().major();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginConnectionRuntimeSnapshot<?> decode(
|
||||
LoginConnectionRevisionSnapshot revision
|
||||
) {
|
||||
try {
|
||||
AdapterDescriptor descriptor = supportedDescriptor();
|
||||
requireCompatible(revision, descriptor);
|
||||
JsonNode config = parseObject(revision.typedConfigJson());
|
||||
if (!fieldNames(config).equals(CONFIG_FIELDS)) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
Set<String> scopes = new LinkedHashSet<>();
|
||||
JsonNode scopeValues = config.get("scopes");
|
||||
if (scopeValues == null || !scopeValues.isArray()) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
scopeValues.forEach(scope -> {
|
||||
if (!scope.isTextual()) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
scopes.add(scope.textValue());
|
||||
});
|
||||
return new LoginConnectionRuntimeSnapshot<>(
|
||||
revision.organizationId(),
|
||||
revision.connectionId(),
|
||||
revision.publicHandle(),
|
||||
revision.revision(),
|
||||
descriptor,
|
||||
new OidcLoginConnectionRuntimeConfig(
|
||||
new OidcIssuer(requiredText(config, "issuer")),
|
||||
requiredText(config, "clientId"),
|
||||
scopes
|
||||
)
|
||||
);
|
||||
} catch (ConnectionUnavailableException unavailable) {
|
||||
throw unavailable;
|
||||
} catch (RuntimeException invalidRevision) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCompatible(
|
||||
LoginConnectionRevisionSnapshot revision,
|
||||
AdapterDescriptor descriptor
|
||||
) {
|
||||
AdapterContractVersion contract = parseContract(revision.adapterContractVersion());
|
||||
Set<AdapterCapability> capabilities = parseCapabilities(revision.capabilitiesJson());
|
||||
if (!revision.adapterKey().equals(descriptor.adapterKey())
|
||||
|| contract.major() != descriptor.contractVersion().major()
|
||||
|| contract.minor() > descriptor.contractVersion().minor()
|
||||
|| revision.configSchemaVersion() != descriptor.configSchemaVersion()
|
||||
|| !capabilities.equals(descriptor.capabilities())) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private Set<AdapterCapability> parseCapabilities(String json) {
|
||||
JsonNode value = parse(json);
|
||||
if (!value.isArray()) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
Set<AdapterCapability> capabilities = new LinkedHashSet<>();
|
||||
value.forEach(capability -> {
|
||||
if (!capability.isTextual()) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
capabilities.add(AdapterCapability.valueOf(capability.textValue()));
|
||||
});
|
||||
return Set.copyOf(capabilities);
|
||||
}
|
||||
|
||||
private JsonNode parseObject(String json) {
|
||||
JsonNode value = parse(json);
|
||||
if (!value.isObject()) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private JsonNode parse(String json) {
|
||||
if (json.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > MAX_JSON_BYTES) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(json);
|
||||
} catch (java.io.IOException invalidJson) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> fieldNames(JsonNode object) {
|
||||
Set<String> fields = new LinkedHashSet<>();
|
||||
object.fieldNames().forEachRemaining(fields::add);
|
||||
return Set.copyOf(fields);
|
||||
}
|
||||
|
||||
private static String requiredText(JsonNode object, String field) {
|
||||
JsonNode value = object.get(field);
|
||||
if (value == null || !value.isTextual()) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
return value.textValue();
|
||||
}
|
||||
|
||||
private static AdapterContractVersion parseContract(String value) {
|
||||
String[] parts = value.split("\\.", -1);
|
||||
if (parts.length != 2) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
return new AdapterContractVersion(
|
||||
Integer.parseInt(parts[0]),
|
||||
Integer.parseInt(parts[1])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,9 @@ import org.springframework.context.annotation.Configuration;
|
|||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties({
|
||||
IdentityCoreProperties.class,
|
||||
EnterpriseIdentityRolloutProperties.class
|
||||
EnterpriseIdentityRolloutProperties.class,
|
||||
EnterpriseOidcProperties.class,
|
||||
EnterpriseSecretEnvelopeProperties.class
|
||||
})
|
||||
public class EnterpriseIdentityCoreConfiguration {
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package com.iflytek.skillhub.auth.federation.config;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Conservative transport and outbound-network controls for dynamic enterprise OIDC. */
|
||||
@ConfigurationProperties(prefix = "skillhub.enterprise.oidc")
|
||||
public class EnterpriseOidcProperties {
|
||||
|
||||
private boolean enabled;
|
||||
private String publicBaseUri = "";
|
||||
private Set<String> privateHostAllowlist = Set.of();
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getPublicBaseUri() {
|
||||
return publicBaseUri;
|
||||
}
|
||||
|
||||
public void setPublicBaseUri(String publicBaseUri) {
|
||||
this.publicBaseUri = Objects.requireNonNull(publicBaseUri, "publicBaseUri").trim();
|
||||
}
|
||||
|
||||
public Set<String> getPrivateHostAllowlist() {
|
||||
return privateHostAllowlist;
|
||||
}
|
||||
|
||||
public void setPrivateHostAllowlist(Set<String> privateHostAllowlist) {
|
||||
Objects.requireNonNull(privateHostAllowlist, "privateHostAllowlist");
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
for (String host : privateHostAllowlist) {
|
||||
Objects.requireNonNull(host, "privateHostAllowlist entry");
|
||||
String value = host.trim().toLowerCase(Locale.ROOT);
|
||||
if (value.isEmpty()) {
|
||||
throw new IllegalArgumentException("privateHostAllowlist must not contain blanks");
|
||||
}
|
||||
normalized.add(value);
|
||||
}
|
||||
this.privateHostAllowlist = Set.copyOf(normalized);
|
||||
}
|
||||
|
||||
public void validateEnabledConfiguration() {
|
||||
URI base;
|
||||
try {
|
||||
base = URI.create(publicBaseUri);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IllegalStateException("Enterprise OIDC public base URI is invalid");
|
||||
}
|
||||
if (!"https".equals(base.getScheme())
|
||||
|| base.getHost() == null
|
||||
|| base.getRawUserInfo() != null
|
||||
|| base.getRawQuery() != null
|
||||
|| base.getRawFragment() != null) {
|
||||
throw new IllegalStateException("Enterprise OIDC public base URI must use HTTPS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +1,121 @@
|
|||
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.OidcLoginConnectionRuntimeSnapshotMaterializer;
|
||||
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. */
|
||||
/** R1-B composition root for enterprise OIDC control-plane management only. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(name = "skillhub.enterprise.oidc.enabled", havingValue = "true")
|
||||
public class EnterpriseOidcRuntimeConfiguration {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
InMemorySecretEnvelopeKeyring enterpriseSecretEnvelopeKeyring(
|
||||
EnterpriseSecretEnvelopeProperties properties
|
||||
) {
|
||||
return properties.createKeyring();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "skillhub.enterprise.oidc.enabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true
|
||||
)
|
||||
EnterpriseRedirectLoginGateway disabledEnterpriseRedirectLoginGateway() {
|
||||
return new DisabledEnterpriseRedirectLoginGateway();
|
||||
LoginConnectionSecretService loginConnectionSecretService(
|
||||
com.iflytek.skillhub.auth.connection.control.LoginConnectionRepository connections,
|
||||
LoginConnectionSecretRepository secrets,
|
||||
InMemorySecretEnvelopeKeyring keyring
|
||||
) {
|
||||
return new LoginConnectionSecretService(
|
||||
connections,
|
||||
secrets,
|
||||
new AesGcmSecretEnvelopeCipher(keyring)
|
||||
);
|
||||
}
|
||||
|
||||
@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
|
||||
);
|
||||
}
|
||||
@Bean
|
||||
OidcOutboundTargetPolicy oidcOutboundTargetPolicy(EnterpriseOidcProperties properties) {
|
||||
properties.validateEnabledConfiguration();
|
||||
return new OidcOutboundTargetPolicy(
|
||||
OidcHostAddressResolver.system(),
|
||||
properties.getPrivateHostAllowlist()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class DisabledEnterpriseRedirectLoginGateway
|
||||
implements EnterpriseRedirectLoginGateway {
|
||||
@Bean
|
||||
JdkOidcSingleHopHttpTransport oidcHttpTransport() {
|
||||
return new JdkOidcSingleHopHttpTransport();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable(ConnectionHandle handle) {
|
||||
return false;
|
||||
}
|
||||
@Bean
|
||||
OidcMetadataManager oidcMetadataManager(
|
||||
OidcOutboundTargetPolicy targetPolicy,
|
||||
JdkOidcSingleHopHttpTransport transport,
|
||||
RemoteIdentityIoExecutor remoteIdentityIo
|
||||
) {
|
||||
SecureOidcDocumentClient documents = new SecureOidcDocumentClient(
|
||||
targetPolicy,
|
||||
transport
|
||||
);
|
||||
return new OidcMetadataManager(
|
||||
new DefaultOidcMetadataSource(documents, targetPolicy),
|
||||
remoteIdentityIo
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.iflytek.skillhub.auth.federation.core.RedirectStartResult start(
|
||||
ConnectionHandle handle,
|
||||
RedirectStartRequest request
|
||||
) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
@Bean
|
||||
OidcLoginConnectionRuntimeSnapshotMaterializer oidcRuntimeSnapshotDecoder() {
|
||||
return new OidcLoginConnectionRuntimeSnapshotMaterializer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginCompletion complete(
|
||||
ConnectionHandle handle,
|
||||
RedirectCompleteRequest request,
|
||||
HttpServletRequest httpRequest
|
||||
) {
|
||||
throw new ConnectionUnavailableException();
|
||||
}
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
package com.iflytek.skillhub.auth.federation.config;
|
||||
|
||||
import com.iflytek.skillhub.auth.connection.secret.InMemorySecretEnvelopeKeyring;
|
||||
import com.iflytek.skillhub.auth.connection.secret.SecretEnvelopeKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Operator-owned keyring configuration; key material is never rendered by this type. */
|
||||
@ConfigurationProperties(prefix = "skillhub.enterprise.secret-envelope")
|
||||
public class EnterpriseSecretEnvelopeProperties {
|
||||
|
||||
private String activeKeyId = "";
|
||||
private List<String> keys = List.of();
|
||||
|
||||
public String getActiveKeyId() {
|
||||
return activeKeyId;
|
||||
}
|
||||
|
||||
public void setActiveKeyId(String activeKeyId) {
|
||||
this.activeKeyId = activeKeyId == null ? "" : activeKeyId.trim();
|
||||
}
|
||||
|
||||
public List<String> getKeys() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
public void setKeys(List<String> keys) {
|
||||
this.keys = keys == null ? List.of() : List.copyOf(keys);
|
||||
}
|
||||
|
||||
InMemorySecretEnvelopeKeyring createKeyring() {
|
||||
if (activeKeyId.isBlank()) {
|
||||
throw new IllegalStateException("Enterprise Secret envelope active key id is required");
|
||||
}
|
||||
Map<String, byte[]> decoded = new LinkedHashMap<>();
|
||||
byte[] activeBytes = null;
|
||||
try {
|
||||
for (String entry : keys) {
|
||||
int separator = entry == null ? -1 : entry.indexOf(':');
|
||||
if (separator < 3 || separator == entry.length() - 1) {
|
||||
throw invalidKeyring();
|
||||
}
|
||||
String keyId = entry.substring(0, separator).trim();
|
||||
byte[] key = Base64.getDecoder().decode(entry.substring(separator + 1).trim());
|
||||
if (decoded.putIfAbsent(keyId, key) != null) {
|
||||
Arrays.fill(key, (byte) 0);
|
||||
throw invalidKeyring();
|
||||
}
|
||||
}
|
||||
activeBytes = decoded.remove(activeKeyId);
|
||||
if (activeBytes == null) {
|
||||
throw invalidKeyring();
|
||||
}
|
||||
SecretEnvelopeKey active = null;
|
||||
List<SecretEnvelopeKey> previous = new ArrayList<>();
|
||||
try {
|
||||
active = new SecretEnvelopeKey(activeKeyId, activeBytes);
|
||||
decoded.forEach((keyId, bytes) ->
|
||||
previous.add(new SecretEnvelopeKey(keyId, bytes)));
|
||||
return new InMemorySecretEnvelopeKeyring(
|
||||
active,
|
||||
previous.toArray(SecretEnvelopeKey[]::new)
|
||||
);
|
||||
} finally {
|
||||
if (active != null) {
|
||||
active.close();
|
||||
}
|
||||
previous.forEach(SecretEnvelopeKey::close);
|
||||
}
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw invalidKeyring();
|
||||
} finally {
|
||||
if (activeBytes != null) {
|
||||
Arrays.fill(activeBytes, (byte) 0);
|
||||
}
|
||||
decoded.values().forEach(bytes -> Arrays.fill(bytes, (byte) 0));
|
||||
}
|
||||
}
|
||||
|
||||
private static IllegalStateException invalidKeyring() {
|
||||
return new IllegalStateException("Enterprise Secret envelope keyring is invalid");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "EnterpriseSecretEnvelopeProperties[activeKeyId=<redacted>, keys=<redacted>]";
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ class OidcLoginConnectionControlAdapterTest {
|
|||
);
|
||||
|
||||
assertThat(prepared.adapterDescriptor()).isEqualTo(
|
||||
OidcRedirectAuthenticationAdapter.supportedDescriptor()
|
||||
OidcLoginConnectionRuntimeSnapshotMaterializer.supportedDescriptor()
|
||||
);
|
||||
assertThat(prepared.typedConfigJson()).contains(
|
||||
"https://id.example.com", "skillhub", "openid", "email"
|
||||
|
|
@ -82,7 +82,7 @@ class OidcLoginConnectionControlAdapterTest {
|
|||
LoginConnectionRuntimeSnapshot<OidcLoginConnectionRuntimeConfig> runtime = runtime();
|
||||
doReturn(runtime).when(materializer).materialize(revision);
|
||||
given(secrets.resolve(
|
||||
runtime.secretReference().orElseThrow(),
|
||||
revision.secretReference().orElseThrow(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
NOW
|
||||
)).willReturn(SecretMaterial.copyOf("top-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
|
|
@ -101,7 +101,7 @@ class OidcLoginConnectionControlAdapterTest {
|
|||
LoginConnectionRuntimeSnapshot<OidcLoginConnectionRuntimeConfig> runtime = runtime();
|
||||
doReturn(runtime).when(materializer).materialize(revision);
|
||||
given(secrets.resolve(
|
||||
runtime.secretReference().orElseThrow(),
|
||||
revision.secretReference().orElseThrow(),
|
||||
SecretPurpose.LOGIN_CLIENT_SECRET,
|
||||
NOW
|
||||
)).willReturn(SecretMaterial.copyOf("top-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
|
|
@ -137,10 +137,7 @@ class OidcLoginConnectionControlAdapterTest {
|
|||
"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
|
||||
)),
|
||||
OidcLoginConnectionRuntimeSnapshotMaterializer.supportedDescriptor(),
|
||||
new OidcLoginConnectionRuntimeConfig(
|
||||
new OidcIssuer("https://id.example.com"),
|
||||
"skillhub",
|
||||
|
|
@ -163,8 +160,7 @@ class OidcLoginConnectionControlAdapterTest {
|
|||
"{\"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()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ public class OrganizationAuthorizationPolicy {
|
|||
Set.of(
|
||||
OrganizationRole.ORG_OWNER,
|
||||
OrganizationRole.IDENTITY_ADMIN,
|
||||
OrganizationRole.LOGIN_SECRET_ADMIN,
|
||||
OrganizationRole.MEMBER_ADMIN,
|
||||
OrganizationRole.ORG_AUDITOR
|
||||
)
|
||||
),
|
||||
|
|
@ -44,15 +42,6 @@ public class OrganizationAuthorizationPolicy {
|
|||
OrganizationRole.ORG_OWNER,
|
||||
OrganizationRole.IDENTITY_ADMIN,
|
||||
OrganizationRole.LOGIN_SECRET_ADMIN,
|
||||
OrganizationRole.MEMBER_ADMIN,
|
||||
OrganizationRole.ORG_AUDITOR
|
||||
)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.VIEW_LOGIN_CONNECTIONS,
|
||||
Set.of(
|
||||
OrganizationRole.ORG_OWNER,
|
||||
OrganizationRole.IDENTITY_ADMIN,
|
||||
OrganizationRole.ORG_AUDITOR
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class OrganizationAuthorizationPolicyTest {
|
|||
OrganizationAdministrativeAction.VIEW_MEMBERS,
|
||||
Set.of(
|
||||
OrganizationRole.ORG_OWNER,
|
||||
OrganizationRole.DIRECTORY_ADMIN,
|
||||
OrganizationRole.MEMBER_ADMIN,
|
||||
OrganizationRole.ORG_AUDITOR
|
||||
)
|
||||
),
|
||||
|
|
@ -44,6 +44,7 @@ class OrganizationAuthorizationPolicyTest {
|
|||
Set.of(
|
||||
OrganizationRole.ORG_OWNER,
|
||||
OrganizationRole.IDENTITY_ADMIN,
|
||||
OrganizationRole.LOGIN_SECRET_ADMIN,
|
||||
OrganizationRole.ORG_AUDITOR
|
||||
)
|
||||
),
|
||||
|
|
@ -65,35 +66,15 @@ class OrganizationAuthorizationPolicyTest {
|
|||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.ROTATE_LOGIN_SECRETS,
|
||||
Set.of(OrganizationRole.IDENTITY_ADMIN)
|
||||
Set.of(OrganizationRole.LOGIN_SECRET_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.RESOLVE_IDENTITY_CONFLICTS,
|
||||
Set.of(OrganizationRole.IDENTITY_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.MANAGE_DIRECTORY_CONNECTIONS,
|
||||
Set.of(OrganizationRole.DIRECTORY_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.ROTATE_DIRECTORY_CREDENTIALS,
|
||||
Set.of(OrganizationRole.DIRECTORY_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.MANAGE_MEMBERS,
|
||||
Set.of(OrganizationRole.DIRECTORY_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.RUN_DIRECTORY_SYNCHRONIZATION,
|
||||
Set.of(OrganizationRole.DIRECTORY_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.MANAGE_ENTITLEMENT_MAPPINGS,
|
||||
Set.of(OrganizationRole.ENTITLEMENT_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.VIEW_MAPPING_IMPACT,
|
||||
Set.of(OrganizationRole.ENTITLEMENT_ADMIN)
|
||||
Set.of(OrganizationRole.MEMBER_ADMIN)
|
||||
),
|
||||
Map.entry(
|
||||
OrganizationAdministrativeAction.VIEW_AUDIT,
|
||||
|
|
@ -119,7 +100,7 @@ class OrganizationAuthorizationPolicyTest {
|
|||
void combinedRolesReceiveOnlyTheUnionOfTheirExplicitActions() {
|
||||
Set<OrganizationRole> roles = Set.of(
|
||||
OrganizationRole.IDENTITY_ADMIN,
|
||||
OrganizationRole.ENTITLEMENT_ADMIN
|
||||
OrganizationRole.LOGIN_SECRET_ADMIN
|
||||
);
|
||||
|
||||
assertThat(policy.isAllowed(
|
||||
|
|
@ -128,7 +109,7 @@ class OrganizationAuthorizationPolicyTest {
|
|||
)).isTrue();
|
||||
assertThat(policy.isAllowed(
|
||||
roles,
|
||||
OrganizationAdministrativeAction.MANAGE_ENTITLEMENT_MAPPINGS
|
||||
OrganizationAdministrativeAction.ROTATE_LOGIN_SECRETS
|
||||
)).isTrue();
|
||||
assertThat(policy.isAllowed(
|
||||
roles,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ class OrganizationMembershipServiceTest {
|
|||
@Mock
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Mock
|
||||
private OrganizationAccessGuard accessGuard;
|
||||
|
||||
private OrganizationMembershipService service;
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -49,7 +52,7 @@ class OrganizationMembershipServiceTest {
|
|||
roleBindingRepository,
|
||||
userAccountRepository,
|
||||
new OrganizationAuthorizationService(
|
||||
membershipRepository,
|
||||
accessGuard,
|
||||
roleBindingRepository,
|
||||
new OrganizationAuthorizationPolicy()
|
||||
)
|
||||
|
|
@ -175,14 +178,10 @@ class OrganizationMembershipServiceTest {
|
|||
}
|
||||
|
||||
private void givenDirectoryAdministrator() {
|
||||
given(membershipRepository.findCurrentByOrganizationIdAndUserId(
|
||||
ORGANIZATION_ID,
|
||||
ACTOR_ID
|
||||
)).willReturn(Optional.of(activeMembership(ACTOR_ID)));
|
||||
given(roleBindingRepository.findActiveByOrganizationIdAndUserId(
|
||||
ORGANIZATION_ID,
|
||||
ACTOR_ID
|
||||
)).willReturn(List.of(binding(ACTOR_ID, OrganizationRole.DIRECTORY_ADMIN)));
|
||||
)).willReturn(List.of(binding(ACTOR_ID, OrganizationRole.MEMBER_ADMIN)));
|
||||
}
|
||||
|
||||
private Organization organization() {
|
||||
|
|
|
|||
1330
web/src/api/generated/schema.d.ts
vendored
1330
web/src/api/generated/schema.d.ts
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue