refs #744: add AD OIDC MCP identity bridge
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# 환경/비밀 — 절대 commit 금지
|
# 환경/비밀 — 절대 commit 금지
|
||||||
.env
|
.env
|
||||||
|
.runtime/
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
|||||||
159
database/adb/47_dds_ad_identity_test_setup.sql
Normal file
159
database/adb/47_dds_ad_identity_test_setup.sql
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 47_dds_ad_identity_test_setup.sql
|
||||||
|
--
|
||||||
|
-- Test-only bridge for the isolated dds.test Active Directory domain.
|
||||||
|
-- Maps immutable AD objectGUID values to the existing HMM application users
|
||||||
|
-- 1 and 2, then publishes only their passwordless local DDS END USERs.
|
||||||
|
--
|
||||||
|
-- This does NOT validate an AD/OIDC JWT. A verified issuer + subject must be
|
||||||
|
-- resolved by the MCP application before it uses this bridge.
|
||||||
|
-- ============================================================
|
||||||
|
WHENEVER SQLERROR EXIT SQL.SQLCODE
|
||||||
|
SET ECHO OFF
|
||||||
|
SET FEEDBACK ON
|
||||||
|
SET DEFINE OFF
|
||||||
|
|
||||||
|
PROMPT === 1. Creating external identity bridge ===
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE cb_external_identity_binding (
|
||||||
|
issuer VARCHAR2(512) NOT NULL,
|
||||||
|
subject VARCHAR2(512) NOT NULL,
|
||||||
|
application_user_id NUMBER NOT NULL,
|
||||||
|
display_name VARCHAR2(256),
|
||||||
|
active CHAR(1) DEFAULT 'Y' NOT NULL
|
||||||
|
CHECK (active IN ('Y', 'N')),
|
||||||
|
created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT cb_external_identity_binding_pk PRIMARY KEY (issuer, subject)
|
||||||
|
)]';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE 'CREATE INDEX cb_external_identity_binding_user_ix '
|
||||||
|
|| 'ON cb_external_identity_binding (application_user_id)';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 2. Creating DDS END USER map ===
|
||||||
|
BEGIN
|
||||||
|
EXECUTE IMMEDIATE q'[
|
||||||
|
CREATE TABLE cb_dds_end_user_map (
|
||||||
|
application_user_id NUMBER PRIMARY KEY,
|
||||||
|
end_user_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||||
|
data_role_name VARCHAR2(128) NOT NULL UNIQUE,
|
||||||
|
lookup_key_ref VARCHAR2(128) NOT NULL,
|
||||||
|
grant_name VARCHAR2(128) NOT NULL,
|
||||||
|
publish_status VARCHAR2(20) NOT NULL
|
||||||
|
CHECK (publish_status IN ('PENDING', 'PUBLISHED', 'REVOKED', 'FAILED')),
|
||||||
|
published_at TIMESTAMP,
|
||||||
|
last_error VARCHAR2(1000)
|
||||||
|
)]';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
IF SQLCODE <> -955 THEN RAISE; END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
v_active_users NUMBER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO v_active_users
|
||||||
|
FROM cb_app_user
|
||||||
|
WHERE user_id IN (1, 2) AND active = 'Y';
|
||||||
|
IF v_active_users <> 2 THEN
|
||||||
|
RAISE_APPLICATION_ERROR(-20947, 'Expected active HMM application users 1 and 2.');
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 3. Binding dds.test immutable subjects to HMM users ===
|
||||||
|
MERGE INTO cb_external_identity_binding target
|
||||||
|
USING (
|
||||||
|
SELECT 'urn:dds-ad:test' AS issuer,
|
||||||
|
'fa7ebe4b-aca8-4049-a436-7f3da2d27a9f' AS subject,
|
||||||
|
1 AS application_user_id,
|
||||||
|
'dds-alice' AS display_name
|
||||||
|
FROM dual
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'urn:dds-ad:test',
|
||||||
|
'128546cf-ec0a-4e99-8f51-dfce29280c91',
|
||||||
|
2,
|
||||||
|
'dds-bob'
|
||||||
|
FROM dual
|
||||||
|
) source
|
||||||
|
ON (target.issuer = source.issuer AND target.subject = source.subject)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
target.application_user_id = source.application_user_id,
|
||||||
|
target.display_name = source.display_name,
|
||||||
|
target.active = 'Y',
|
||||||
|
target.updated_at = SYSTIMESTAMP
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
issuer, subject, application_user_id, display_name, active
|
||||||
|
) VALUES (
|
||||||
|
source.issuer, source.subject, source.application_user_id, source.display_name, 'Y'
|
||||||
|
);
|
||||||
|
|
||||||
|
MERGE INTO cb_dds_end_user_map target
|
||||||
|
USING (
|
||||||
|
SELECT user_id AS application_user_id,
|
||||||
|
'DDS_U_' || TO_CHAR(user_id) AS end_user_name,
|
||||||
|
'DDS_U_' || TO_CHAR(user_id) || '_ROLE' AS data_role_name,
|
||||||
|
'DDS_OCI_IAM_CLIENT_SECRET_DERIVED_V1' AS lookup_key_ref,
|
||||||
|
'DDS_MCP_U_' || TO_CHAR(user_id) || '_VECTOR_GRANT' AS grant_name
|
||||||
|
FROM cb_app_user
|
||||||
|
WHERE user_id IN (1, 2)
|
||||||
|
) source
|
||||||
|
ON (target.application_user_id = source.application_user_id)
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
target.end_user_name = source.end_user_name,
|
||||||
|
target.data_role_name = source.data_role_name,
|
||||||
|
target.lookup_key_ref = source.lookup_key_ref,
|
||||||
|
target.grant_name = source.grant_name,
|
||||||
|
target.publish_status = 'PENDING',
|
||||||
|
target.last_error = NULL
|
||||||
|
WHEN NOT MATCHED THEN INSERT (
|
||||||
|
application_user_id, end_user_name, data_role_name, lookup_key_ref,
|
||||||
|
grant_name, publish_status
|
||||||
|
) VALUES (
|
||||||
|
source.application_user_id, source.end_user_name, source.data_role_name,
|
||||||
|
source.lookup_key_ref, source.grant_name, 'PENDING'
|
||||||
|
);
|
||||||
|
|
||||||
|
PROMPT === 4. Publishing DDS identities only (default deny until data grants exist) ===
|
||||||
|
BEGIN
|
||||||
|
FOR mapped_user IN (
|
||||||
|
SELECT application_user_id, end_user_name, data_role_name
|
||||||
|
FROM cb_dds_end_user_map
|
||||||
|
WHERE application_user_id IN (1, 2)
|
||||||
|
ORDER BY application_user_id
|
||||||
|
) LOOP
|
||||||
|
EXECUTE IMMEDIATE 'CREATE END USER IF NOT EXISTS "' || mapped_user.end_user_name || '"';
|
||||||
|
EXECUTE IMMEDIATE 'CREATE DATA ROLE IF NOT EXISTS ' || mapped_user.data_role_name;
|
||||||
|
EXECUTE IMMEDIATE 'GRANT DATA ROLE ' || mapped_user.data_role_name
|
||||||
|
|| ' TO "' || mapped_user.end_user_name || '"';
|
||||||
|
UPDATE cb_dds_end_user_map
|
||||||
|
SET publish_status = 'PUBLISHED', published_at = SYSTIMESTAMP, last_error = NULL
|
||||||
|
WHERE application_user_id = mapped_user.application_user_id;
|
||||||
|
END LOOP;
|
||||||
|
COMMIT;
|
||||||
|
END;
|
||||||
|
/
|
||||||
|
|
||||||
|
PROMPT === 5. Identity bridge inventory ===
|
||||||
|
SELECT b.issuer, b.subject, b.display_name, b.application_user_id,
|
||||||
|
m.end_user_name, m.data_role_name, m.publish_status
|
||||||
|
FROM cb_external_identity_binding b
|
||||||
|
JOIN cb_dds_end_user_map m ON m.application_user_id = b.application_user_id
|
||||||
|
WHERE b.issuer = 'urn:dds-ad:test'
|
||||||
|
ORDER BY b.application_user_id;
|
||||||
|
|
||||||
|
PROMPT === AD identity to DDS END USER test setup complete ===
|
||||||
|
EXIT;
|
||||||
@@ -42,6 +42,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-oauth2-jose</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.oracle.database.jdbc</groupId>
|
<groupId>com.oracle.database.jdbc</groupId>
|
||||||
<artifactId>ojdbc11</artifactId>
|
<artifactId>ojdbc11</artifactId>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice;
|
|||||||
|
|
||||||
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
import com.cloudhandson.ddsbackoffice.config.DdsProperties;
|
||||||
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
|
import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties;
|
||||||
|
import com.cloudhandson.ddsbackoffice.config.DdsMcpOidcProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication;
|
||||||
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
import com.cloudhandson.vpdbackoffice.web.DashboardController;
|
||||||
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
import com.cloudhandson.vpdbackoffice.web.LoginController;
|
||||||
@@ -15,7 +16,7 @@ import org.springframework.context.annotation.ComponentScan;
|
|||||||
import org.springframework.context.annotation.FilterType;
|
import org.springframework.context.annotation.FilterType;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class})
|
@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class, DdsMcpOidcProperties.class})
|
||||||
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
||||||
@ComponentScan(
|
@ComponentScan(
|
||||||
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"},
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||||
|
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
|
||||||
|
public class DdsMcpOidcConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
JwtDecoder ddsMcpJwtDecoder(DdsMcpOidcProperties properties) {
|
||||||
|
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(properties.issuerUri()).build();
|
||||||
|
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> jwt.getAudience().contains(properties.audience())
|
||||||
|
? OAuth2TokenValidatorResult.success()
|
||||||
|
: OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
|
||||||
|
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||||
|
JwtValidators.createDefaultWithIssuer(properties.issuerUri()), audienceValidator));
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.context.properties.bind.ConstructorBinding;
|
||||||
|
|
||||||
|
/** OIDC resource-server settings for externally authenticated MCP callers. */
|
||||||
|
@ConfigurationProperties(prefix = "dds.mcp.oidc")
|
||||||
|
public record DdsMcpOidcProperties(boolean enabled, String issuerUri, String audience) {
|
||||||
|
|
||||||
|
@ConstructorBinding
|
||||||
|
public DdsMcpOidcProperties {
|
||||||
|
issuerUri = trim(issuerUri);
|
||||||
|
audience = trim(audience);
|
||||||
|
if (enabled && (issuerUri.isBlank() || audience.isBlank())) {
|
||||||
|
throw new IllegalArgumentException("DDS MCP OIDC issuer-uri와 audience를 설정해야 합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.cloudhandson.ddsbackoffice.service;
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.DataAccessResourceFailureException;
|
import org.springframework.dao.DataAccessResourceFailureException;
|
||||||
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
|
/** Republishes all local DDS MCP identities in the same request as an authorization change. */
|
||||||
@Component
|
@Component
|
||||||
public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer {
|
public class DdsMcpAuthorizationChangeListener implements ExternalAuthorizationSynchronizer {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
|
private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class);
|
||||||
private final DdsMcpEndUserPublisher publisher;
|
private final DdsMcpEndUserPublisher publisher;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
|||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Revalidates the MCP request bearer before every tool invocation. */
|
/** Revalidates the MCP request bearer before every tool invocation. */
|
||||||
@@ -20,22 +21,32 @@ public class DdsMcpBearerAuthenticator {
|
|||||||
private final BearerTokenService bearerTokenService;
|
private final BearerTokenService bearerTokenService;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final DdsMcpEndUserResolver endUserResolver;
|
private final DdsMcpEndUserResolver endUserResolver;
|
||||||
|
private final ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public DdsMcpBearerAuthenticator(
|
public DdsMcpBearerAuthenticator(
|
||||||
BearerTokenService bearerTokenService,
|
BearerTokenService bearerTokenService,
|
||||||
UserMapper userMapper,
|
UserMapper userMapper,
|
||||||
DdsMcpEndUserResolver endUserResolver,
|
DdsMcpEndUserResolver endUserResolver,
|
||||||
|
ObjectProvider<DdsMcpOidcAuthenticator> oidcAuthenticator,
|
||||||
Clock clock
|
Clock clock
|
||||||
) {
|
) {
|
||||||
this.bearerTokenService = bearerTokenService;
|
this.bearerTokenService = bearerTokenService;
|
||||||
this.userMapper = userMapper;
|
this.userMapper = userMapper;
|
||||||
this.endUserResolver = endUserResolver;
|
this.endUserResolver = endUserResolver;
|
||||||
|
this.oidcAuthenticator = oidcAuthenticator;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DdsMcpAuthenticatedUser authenticate(String authorization) {
|
public DdsMcpAuthenticatedUser authenticate(String authorization) {
|
||||||
String plainToken = bearerValue(authorization);
|
String plainToken = bearerValue(authorization);
|
||||||
|
if (plainToken.chars().filter(character -> character == '.').count() == 2) {
|
||||||
|
DdsMcpOidcAuthenticator oidc = oidcAuthenticator.getIfAvailable();
|
||||||
|
if (oidc == null) {
|
||||||
|
throw denied();
|
||||||
|
}
|
||||||
|
return oidc.authenticate(plainToken);
|
||||||
|
}
|
||||||
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
|
BearerTokenRecord token = bearerTokenService.findByPlainToken(plainToken);
|
||||||
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault()));
|
||||||
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {
|
if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Maps a verified OIDC issuer and subject to one active HMM application user. */
|
||||||
|
@Service
|
||||||
|
public class DdsMcpExternalIdentityResolver {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public DdsMcpExternalIdentityResolver(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long resolveApplicationUserId(String issuer, String subject) {
|
||||||
|
if (!safe(issuer) || !safe(subject)) {
|
||||||
|
throw denied();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Long> userIds = jdbcTemplate.query("""
|
||||||
|
SELECT b.application_user_id
|
||||||
|
FROM cb_external_identity_binding b
|
||||||
|
JOIN cb_app_user u ON u.user_id = b.application_user_id
|
||||||
|
WHERE b.issuer = ?
|
||||||
|
AND b.subject = ?
|
||||||
|
AND b.active = 'Y'
|
||||||
|
AND u.active = 'Y'
|
||||||
|
""", (row, ignored) -> row.getLong("application_user_id"), issuer, subject);
|
||||||
|
if (userIds.size() != 1) {
|
||||||
|
throw denied();
|
||||||
|
}
|
||||||
|
return userIds.getFirst();
|
||||||
|
} catch (AppException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
throw new AppException("DDS_CONTEXT_UNAVAILABLE: 외부 ID 매핑을 확인할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean safe(String value) {
|
||||||
|
return value != null && !value.isBlank() && value.length() <= 512;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppException denied() {
|
||||||
|
return new AppException("AUTHORIZATION_DENIED: 외부 OIDC 사용자를 DDS에 매핑할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.cloudhandson.ddsbackoffice.service;
|
||||||
|
|
||||||
|
import com.cloudhandson.ddsbackoffice.domain.DdsMcpAuthenticatedUser;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
||||||
|
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||||
|
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** Validates a signed OIDC JWT before looking up the DDS identity bridge. */
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(prefix = "dds.mcp.oidc", name = "enabled", havingValue = "true")
|
||||||
|
public class DdsMcpOidcAuthenticator {
|
||||||
|
|
||||||
|
private final JwtDecoder jwtDecoder;
|
||||||
|
private final DdsMcpExternalIdentityResolver externalIdentityResolver;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final DdsMcpEndUserResolver endUserResolver;
|
||||||
|
|
||||||
|
public DdsMcpOidcAuthenticator(
|
||||||
|
JwtDecoder jwtDecoder,
|
||||||
|
DdsMcpExternalIdentityResolver externalIdentityResolver,
|
||||||
|
UserMapper userMapper,
|
||||||
|
DdsMcpEndUserResolver endUserResolver
|
||||||
|
) {
|
||||||
|
this.jwtDecoder = jwtDecoder;
|
||||||
|
this.externalIdentityResolver = externalIdentityResolver;
|
||||||
|
this.userMapper = userMapper;
|
||||||
|
this.endUserResolver = endUserResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DdsMcpAuthenticatedUser authenticate(String token) {
|
||||||
|
try {
|
||||||
|
Jwt jwt = jwtDecoder.decode(token);
|
||||||
|
long userId = externalIdentityResolver.resolveApplicationUserId(
|
||||||
|
jwt.getIssuer() == null ? null : jwt.getIssuer().toString(), jwt.getSubject());
|
||||||
|
AppUser user = userMapper.findById(userId);
|
||||||
|
if (user == null || !user.active()) {
|
||||||
|
throw denied();
|
||||||
|
}
|
||||||
|
return new DdsMcpAuthenticatedUser(user.userId(), user.username(), endUserResolver.resolve(user.userId()));
|
||||||
|
} catch (AppException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (JwtException | IllegalArgumentException exception) {
|
||||||
|
throw denied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppException denied() {
|
||||||
|
return new AppException("AUTHORIZATION_DENIED: MCP OIDC 토큰을 확인할 수 없습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,12 @@ backoffice:
|
|||||||
dds:
|
dds:
|
||||||
mcp:
|
mcp:
|
||||||
permission-sync-enabled: true
|
permission-sync-enabled: true
|
||||||
|
oidc:
|
||||||
|
# Disabled unless an external OIDC issuer is explicitly configured.
|
||||||
|
# The production issuer must use HTTPS; the isolated AD VM uses a temporary HTTP value only for PoC.
|
||||||
|
enabled: ${DDS_MCP_OIDC_ENABLED:false}
|
||||||
|
issuer-uri: ${DDS_MCP_OIDC_ISSUER_URI:}
|
||||||
|
audience: ${DDS_MCP_OIDC_AUDIENCE:dds-mcp}
|
||||||
iam:
|
iam:
|
||||||
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
|
domain-url: ${DDS_OCI_IAM_DOMAIN_URL:}
|
||||||
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}
|
token-uri: ${DDS_OCI_IAM_TOKEN_URI:}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package com.cloudhandson.ddsbackoffice.service;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationChangeNotifier;
|
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationChangeNotifier;
|
||||||
import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer;
|
import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ class DdsMcpAuthorizationChangeListenerTest {
|
|||||||
when(publisher.publish()).thenReturn(new com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult(3, 0, 0));
|
when(publisher.publish()).thenReturn(new com.cloudhandson.ddsbackoffice.domain.DdsMcpEndUserPublishResult(3, 0, 0));
|
||||||
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
|
DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher);
|
||||||
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
|
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
|
||||||
factory.registerSingleton("ddsSynchronizer", (DdsAuthorizationSynchronizer) listener);
|
factory.registerSingleton("ddsSynchronizer", (ExternalAuthorizationSynchronizer) listener);
|
||||||
|
|
||||||
new DdsAuthorizationChangeNotifier(factory.getBeanProvider(DdsAuthorizationSynchronizer.class))
|
new ExternalAuthorizationChangeNotifier(factory.getBeanProvider(ExternalAuthorizationSynchronizer.class))
|
||||||
.changed("PERMISSION_SAVED");
|
.changed("PERMISSION_SAVED");
|
||||||
|
|
||||||
verify(publisher).publish();
|
verify(publisher).publish();
|
||||||
|
|||||||
81
docs/design/744-windows-ad-dds-end-user-mapping/README.md
Normal file
81
docs/design/744-windows-ad-dds-end-user-mapping/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 설계서: Windows AD 기반 DDS END USER 매핑 테스트 (#744)
|
||||||
|
|
||||||
|
> **상태**: OIDC JWT → MCP 매핑 검증 완료 · DDS data grant 검증 대기
|
||||||
|
> **최종수정**: 2026-08-04
|
||||||
|
> **추적성**: Redmine #744 · 선행 설계: [DDS MCP END USER Context](../617-dds-mcp-end-user-context/README.md)
|
||||||
|
|
||||||
|
## 1. 목적과 범위
|
||||||
|
|
||||||
|
Microsoft Entra ID 테넌트가 아직 준비되지 않은 상황에서, OCI 격리 네트워크의 Windows Server AD DS를 이용해 업무 사용자 디렉터리와 DDS END USER 매핑을 먼저 검증한다.
|
||||||
|
|
||||||
|
이 환경은 **Microsoft Entra access token을 Oracle DB가 직접 검증하는 환경이 아니다.** AD DS는 사용자·그룹·UPN을 제공하고, 이후 Keycloak 또는 별도 검증 서비스가 AD LDAP을 기반으로 발급·검증한 토큰의 안정적인 subject를 MCP의 업무 사용자로 해석한다. Entra tenant와 app registration이 준비되면 OIDC issuer/audience/JWKS 검증 경로로 교체 또는 병행한다.
|
||||||
|
|
||||||
|
## 2. 격리 인프라
|
||||||
|
|
||||||
|
| 항목 | 구성 |
|
||||||
|
|---|---|
|
||||||
|
| 네트워크 | `handson-vcn`의 전용 `10.0.2.0/28` public subnet |
|
||||||
|
| Windows VM | `hmm-ad-dss-test-isolated`, Windows Server 2022, 2 OCPU / 16 GB |
|
||||||
|
| 관리 접근 | RDP TCP/3389은 작업자 공인 IP 한 곳에만 NSG로 허용 |
|
||||||
|
| OIDC HTTPS | `https://ad.cloud-handson.com` (Caddy TLS reverse proxy → Keycloak) |
|
||||||
|
| 서브넷 보안 목록 | 인바운드 전부 차단, Windows Update 및 테스트용 아웃바운드만 허용 |
|
||||||
|
| 도메인 | `dds.test` (AD DS forest/domain) |
|
||||||
|
|
||||||
|
이 VM은 테스트 전용이다. 기존 공유 public subnet의 NAT 경로를 변경하지 않으며, 비용 발생 리소스이므로 검증 종료 뒤 중지 또는 삭제를 결정한다.
|
||||||
|
|
||||||
|
## 3. 식별자와 권한 흐름
|
||||||
|
|
||||||
|
```text
|
||||||
|
AD 사용자 (UPN: alice@dds.test, objectGUID)
|
||||||
|
│ LDAP / OIDC bridge
|
||||||
|
▼
|
||||||
|
검증된 Bearer claims
|
||||||
|
iss, aud, exp, sub = 불변 외부 subject
|
||||||
|
│ MCP bearer authenticator
|
||||||
|
▼
|
||||||
|
CB_EXTERNAL_IDENTITY_BINDING
|
||||||
|
issuer + subject -> CB_APP_USER.user_id
|
||||||
|
▼
|
||||||
|
CB_DDS_END_USER_MAP
|
||||||
|
user_id -> DDS_U_<user_id> local END USER
|
||||||
|
▼
|
||||||
|
ORA_END_USER_CONTEXT + DDS DATA GRANT / 권한 함수
|
||||||
|
```
|
||||||
|
|
||||||
|
`sub`는 Keycloak이 발급하는 안정적인 federated-user 식별자다. AD objectGUID는 원천 디렉터리의 감사 식별자로 유지하되, 실제 권한 키는 **검증된 JWT의 `iss + sub`**로 고정한다. UPN은 로그인 화면·감사 표시용으로 보관할 수 있지만 권한 키로 신뢰하지 않는다. `iss + sub` 조합이 유일하지 않거나, 매핑이 없거나, 사용자가 비활성이면 요청은 fail-closed로 거부한다.
|
||||||
|
|
||||||
|
## 4. 구현 단계
|
||||||
|
|
||||||
|
1. Windows Server에 AD DS를 설치하고 `dds.test` forest를 생성한다.
|
||||||
|
2. 테스트 사용자 `dds-alice`, `dds-bob`와 권한 그룹을 만들고, LDAP 조회와 인증을 확인한다.
|
||||||
|
3. AD LDAP과 연동한 OIDC bridge(예: Keycloak)를 별도 서비스로 구성한다. bridge가 내는 JWT의 `iss`, `aud`, `exp`, JWKS 서명을 MCP가 검증한다.
|
||||||
|
4. `issuer + sub`에서 `CB_APP_USER` 및 `CB_DDS_END_USER_MAP`을 해석하도록 백오피스/DB 매핑을 추가한다.
|
||||||
|
5. alice/bob의 서로 다른 `DDS_U_*` context에서 동일 MCP tool 호출 결과가 권한에 따라 달라지는지 검증한다.
|
||||||
|
6. 매핑 누락, 만료 토큰, 다른 issuer/audience, 비활성 계정은 모두 거부되는 regression을 추가한다.
|
||||||
|
|
||||||
|
## 4.1 현재 테스트 bridge
|
||||||
|
|
||||||
|
`database/adb/47_dds_ad_identity_test_setup.sql`은 HMM DB의 기존 업무 사용자 원천을 변경하지 않는다. 스크립트가 만든 `CB_EXTERNAL_IDENTITY_BINDING`에는 최종 OIDC issuer와 Keycloak이 실제로 발급한 subject만 보관한다.
|
||||||
|
|
||||||
|
| AD 사용자 | OIDC issuer | JWT `sub` | HMM application user | DDS END USER |
|
||||||
|
|---|---|---|---:|---|
|
||||||
|
| `dds-alice` | `https://ad.cloud-handson.com/realms/dds-test` | `11cf09c9-4713-4145-85cd-e3397d4a2405` | 1 | `DDS_U_1` |
|
||||||
|
| `dds-bob` | `https://ad.cloud-handson.com/realms/dds-test` | `d9610e18-a327-4dda-9496-69e5a2067460` | 2 | `DDS_U_2` |
|
||||||
|
|
||||||
|
`dds-mcp` client는 access token audience에 `dds-mcp`를 포함한다. MCP는 Keycloak JWKS에서 JWT 서명과 `iss`, `aud`, 만료 시간을 검증한 뒤에만 위 매핑을 조회한다. 실제 `/dds/mcp/messages` 초기화 요청에 Alice JWT를 넣어 성공 응답을 확인했다.
|
||||||
|
|
||||||
|
이 단계는 END USER와 DATA ROLE만 게시한다. 각 DATA ROLE에는 data grant를 만들지 않았으므로 데이터 도구 호출은 여전히 **default deny**다.
|
||||||
|
|
||||||
|
## 5. Entra ID로 전환할 때
|
||||||
|
|
||||||
|
Entra tenant가 확보되면 AD DS/bridge 테스트에서 확인한 `issuer + immutable subject -> CB_APP_USER -> DDS END USER` 계약은 유지한다. 바뀌는 부분은 token issuer와 JWKS 검증 설정뿐이다. Entra의 claim 이름(`oid`, `sub`, `preferred_username` 등)은 실제 발급 토큰을 확인한 뒤 결정하며, `sub` 단독이 아니라 tenant/issuer 경계를 반드시 포함한다.
|
||||||
|
|
||||||
|
## 6. 완료 기준
|
||||||
|
|
||||||
|
- [x] `dds.test` AD forest와 두 테스트 사용자가 생성되었다.
|
||||||
|
- [x] Keycloak LDAP bridge가 HTTPS OIDC JWT를 발급하고 각 사용자가 서로 다른 안정 subject를 가진다.
|
||||||
|
- [x] MCP가 JWT signature/issuer/audience를 검증하고 `issuer + sub` 매핑을 해석한다.
|
||||||
|
- [ ] subject 매핑을 통해 각 요청에 대응하는 DDS END USER context만 attach된다.
|
||||||
|
- [ ] 서로 다른 권한의 동일 MCP 호출에서 데이터 행/열 결과가 달라진다.
|
||||||
|
- [ ] 미매핑·만료·issuer/audience 불일치 요청은 데이터 접근 전에 거부된다.
|
||||||
|
- [ ] 종료 시 테스트 VM과 전용 네트워크 리소스의 정리 여부 및 비용 상태를 Redmine에 기록한다.
|
||||||
Reference in New Issue
Block a user