From d868d7bbb8b31225b0b8779786437dc861f50b84 Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 4 Aug 2026 14:24:07 +0900 Subject: [PATCH] refs #744: add AD OIDC MCP identity bridge --- .gitignore | 1 + .../adb/47_dds_ad_identity_test_setup.sql | 159 ++++++++++++++++++ dds-backoffice/pom.xml | 4 + .../DdsBackofficeApplication.java | 3 +- .../config/DdsMcpOidcConfiguration.java | 29 ++++ .../config/DdsMcpOidcProperties.java | 22 +++ .../DdsMcpAuthorizationChangeListener.java | 4 +- .../service/DdsMcpBearerAuthenticator.java | 11 ++ .../DdsMcpExternalIdentityResolver.java | 51 ++++++ .../service/DdsMcpOidcAuthenticator.java | 55 ++++++ .../src/main/resources/application.yml | 6 + ...DdsMcpAuthorizationChangeListenerTest.java | 8 +- .../README.md | 81 +++++++++ 13 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 database/adb/47_dds_ad_identity_test_setup.sql create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcConfiguration.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcProperties.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpExternalIdentityResolver.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpOidcAuthenticator.java create mode 100644 docs/design/744-windows-ad-dds-end-user-mapping/README.md diff --git a/.gitignore b/.gitignore index 357e835..ecd81b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # 환경/비밀 — 절대 commit 금지 .env +.runtime/ .env.* !.env.example diff --git a/database/adb/47_dds_ad_identity_test_setup.sql b/database/adb/47_dds_ad_identity_test_setup.sql new file mode 100644 index 0000000..58b41f1 --- /dev/null +++ b/database/adb/47_dds_ad_identity_test_setup.sql @@ -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; diff --git a/dds-backoffice/pom.xml b/dds-backoffice/pom.xml index 4b05413..0fa8262 100644 --- a/dds-backoffice/pom.xml +++ b/dds-backoffice/pom.xml @@ -42,6 +42,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.security + spring-security-oauth2-jose + com.oracle.database.jdbc ojdbc11 diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java index 651b039..5873be9 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java @@ -2,6 +2,7 @@ package com.cloudhandson.ddsbackoffice; import com.cloudhandson.ddsbackoffice.config.DdsProperties; import com.cloudhandson.ddsbackoffice.config.DdsMcpIamProperties; +import com.cloudhandson.ddsbackoffice.config.DdsMcpOidcProperties; import com.cloudhandson.vpdbackoffice.VpdBackofficeApplication; import com.cloudhandson.vpdbackoffice.web.DashboardController; import com.cloudhandson.vpdbackoffice.web.LoginController; @@ -15,7 +16,7 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; @SpringBootApplication -@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class}) +@EnableConfigurationProperties({DdsProperties.class, DdsMcpIamProperties.class, DdsMcpOidcProperties.class}) @MapperScan("com.cloudhandson.vpdbackoffice.mapper") @ComponentScan( basePackages = {"com.cloudhandson.ddsbackoffice", "com.cloudhandson.vpdbackoffice"}, diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcConfiguration.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcConfiguration.java new file mode 100644 index 0000000..77867f7 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcConfiguration.java @@ -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 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; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcProperties.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcProperties.java new file mode 100644 index 0000000..afcc425 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsMcpOidcProperties.java @@ -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(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java index 5667d78..ce4cde4 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListener.java @@ -1,6 +1,6 @@ package com.cloudhandson.ddsbackoffice.service; -import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer; +import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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. */ @Component -public class DdsMcpAuthorizationChangeListener implements DdsAuthorizationSynchronizer { +public class DdsMcpAuthorizationChangeListener implements ExternalAuthorizationSynchronizer { private static final Logger log = LoggerFactory.getLogger(DdsMcpAuthorizationChangeListener.class); private final DdsMcpEndUserPublisher publisher; diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java index 28b15b4..ce5a97d 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpBearerAuthenticator.java @@ -9,6 +9,7 @@ import com.cloudhandson.vpdbackoffice.service.BearerTokenService; import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; /** Revalidates the MCP request bearer before every tool invocation. */ @@ -20,22 +21,32 @@ public class DdsMcpBearerAuthenticator { private final BearerTokenService bearerTokenService; private final UserMapper userMapper; private final DdsMcpEndUserResolver endUserResolver; + private final ObjectProvider oidcAuthenticator; private final Clock clock; public DdsMcpBearerAuthenticator( BearerTokenService bearerTokenService, UserMapper userMapper, DdsMcpEndUserResolver endUserResolver, + ObjectProvider oidcAuthenticator, Clock clock ) { this.bearerTokenService = bearerTokenService; this.userMapper = userMapper; this.endUserResolver = endUserResolver; + this.oidcAuthenticator = oidcAuthenticator; this.clock = clock; } public DdsMcpAuthenticatedUser authenticate(String 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); LocalDateTime now = LocalDateTime.now(clock.withZone(ZoneId.systemDefault())); if (token == null || !token.active(now) || !bearerTokenService.matches(token, plainToken)) { diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpExternalIdentityResolver.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpExternalIdentityResolver.java new file mode 100644 index 0000000..16cf829 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpExternalIdentityResolver.java @@ -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 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에 매핑할 수 없습니다."); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpOidcAuthenticator.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpOidcAuthenticator.java new file mode 100644 index 0000000..5f497a6 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsMcpOidcAuthenticator.java @@ -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 토큰을 확인할 수 없습니다."); + } +} diff --git a/dds-backoffice/src/main/resources/application.yml b/dds-backoffice/src/main/resources/application.yml index 10eed9e..b9bcb6c 100644 --- a/dds-backoffice/src/main/resources/application.yml +++ b/dds-backoffice/src/main/resources/application.yml @@ -58,6 +58,12 @@ backoffice: dds: mcp: 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: domain-url: ${DDS_OCI_IAM_DOMAIN_URL:} token-uri: ${DDS_OCI_IAM_TOKEN_URI:} diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListenerTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListenerTest.java index a952fd6..2d4c39e 100644 --- a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListenerTest.java +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsMcpAuthorizationChangeListenerTest.java @@ -3,8 +3,8 @@ package com.cloudhandson.ddsbackoffice.service; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationChangeNotifier; -import com.cloudhandson.vpdbackoffice.service.DdsAuthorizationSynchronizer; +import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationChangeNotifier; +import com.cloudhandson.vpdbackoffice.service.ExternalAuthorizationSynchronizer; import org.junit.jupiter.api.Test; 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)); DdsMcpAuthorizationChangeListener listener = new DdsMcpAuthorizationChangeListener(publisher); 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"); verify(publisher).publish(); diff --git a/docs/design/744-windows-ad-dds-end-user-mapping/README.md b/docs/design/744-windows-ad-dds-end-user-mapping/README.md new file mode 100644 index 0000000..a9a39b5 --- /dev/null +++ b/docs/design/744-windows-ad-dds-end-user-mapping/README.md @@ -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_ 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에 기록한다.