@@ -0,0 +1,14 @@
|
||||
package com.cloudhandson.vpdbackoffice;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@MapperScan("com.cloudhandson.vpdbackoffice.mapper")
|
||||
@SpringBootApplication
|
||||
public class VpdBackofficeApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(VpdBackofficeApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "backoffice")
|
||||
public record BackofficeProperties(
|
||||
Security security,
|
||||
Token token,
|
||||
Ords ords
|
||||
) {
|
||||
|
||||
public record Security(String adminUser, String adminPassword) {
|
||||
}
|
||||
|
||||
public record Token(int maxDays) {
|
||||
}
|
||||
|
||||
public record Ords(String baseUrl, Duration timeout) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
@Configuration
|
||||
public class OrdsClientConfig {
|
||||
|
||||
@Bean
|
||||
RestTemplate ordsRestTemplate(BackofficeProperties properties) {
|
||||
Duration timeout = properties.ords().timeout();
|
||||
return new RestTemplateBuilder()
|
||||
.setConnectTimeout(timeout)
|
||||
.setReadTimeout(timeout)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.formLogin(Customizer.withDefaults())
|
||||
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService(
|
||||
BackofficeProperties properties,
|
||||
PasswordEncoder passwordEncoder
|
||||
) {
|
||||
var security = properties.security();
|
||||
var user = User.withUsername(security.adminUser())
|
||||
.password(passwordEncoder.encode(security.adminPassword()))
|
||||
.roles("ADMIN")
|
||||
.build();
|
||||
return new InMemoryUserDetailsManager(user);
|
||||
}
|
||||
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() {
|
||||
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.audit;
|
||||
|
||||
public record AuditEvent(
|
||||
String eventType,
|
||||
Long keyId,
|
||||
Long objectId,
|
||||
String status,
|
||||
Integer rowCount,
|
||||
String errorCode,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
|
||||
public record AppRole(
|
||||
long roleId,
|
||||
String roleName,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
|
||||
public record PermissionRule(
|
||||
long ruleId,
|
||||
long permissionId,
|
||||
String ruleType,
|
||||
String ruleValue
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PermissionSet(
|
||||
long permissionId,
|
||||
long roleId,
|
||||
long objectId,
|
||||
String action,
|
||||
List<PermissionRule> rules,
|
||||
List<String> visibleColumns
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.util.List;
|
||||
|
||||
public record PermissionSetCommand(
|
||||
@Positive long roleId,
|
||||
@Positive long objectId,
|
||||
@NotBlank String action,
|
||||
@NotEmpty List<RuleCommand> rules,
|
||||
List<String> visibleColumns
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.permission;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RuleCommand(
|
||||
@NotBlank String ruleType,
|
||||
String ruleValue
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.probe;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public record ProbeCommand(
|
||||
@Positive long keyId,
|
||||
@Positive long objectId,
|
||||
@NotBlank String bearerToken,
|
||||
@Min(1) @Max(500) int limit
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.probe;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public record ProbeResult(
|
||||
ProbeStatus status,
|
||||
List<String> columns,
|
||||
List<Map<String, Object>> rows,
|
||||
int rowCount,
|
||||
List<String> maskedColumns,
|
||||
String errorCode,
|
||||
String errorMessage
|
||||
) {
|
||||
|
||||
public static ProbeResult blocked(ProbeStatus status, String errorCode, String errorMessage) {
|
||||
return new ProbeResult(status, List.of(), List.of(), 0, List.of(), errorCode, errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.probe;
|
||||
|
||||
public enum ProbeStatus {
|
||||
SUCCESS,
|
||||
VPD_DENY_EMPTY_RESULT,
|
||||
TOKEN_NOT_FOUND,
|
||||
TOKEN_INACTIVE,
|
||||
OBJECT_DISABLED,
|
||||
INVALID_TOKEN,
|
||||
OBJECT_NOT_ACCESSIBLE,
|
||||
ORDS_TIMEOUT,
|
||||
INVALID_ORDS_RESPONSE,
|
||||
UNKNOWN_ERROR
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
|
||||
|
||||
public record ProtectedColumn(
|
||||
long columnId,
|
||||
long objectId,
|
||||
String columnName,
|
||||
String sensitiveYn,
|
||||
Long visibleRoleId
|
||||
) {
|
||||
|
||||
public boolean sensitive() {
|
||||
return "Y".equalsIgnoreCase(sensitiveYn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.protectedobject;
|
||||
|
||||
public record ProtectedObject(
|
||||
long objectId,
|
||||
String owner,
|
||||
String objectName,
|
||||
String ordsPath,
|
||||
String enabledYn
|
||||
) {
|
||||
|
||||
public boolean enabled() {
|
||||
return "Y".equalsIgnoreCase(enabledYn);
|
||||
}
|
||||
|
||||
public String displayName() {
|
||||
return owner + "." + objectName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.token;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record BearerTokenRecord(
|
||||
long keyId,
|
||||
long userId,
|
||||
String username,
|
||||
String keyPrefix,
|
||||
String keyHash,
|
||||
OffsetDateTime expiresAt,
|
||||
OffsetDateTime revokedAt,
|
||||
String description
|
||||
) {
|
||||
|
||||
public boolean active(OffsetDateTime now) {
|
||||
return revokedAt == null && expiresAt != null && expiresAt.isAfter(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.token;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record IssuedToken(
|
||||
long keyId,
|
||||
String prefix,
|
||||
String plainToken,
|
||||
OffsetDateTime expiresAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.token;
|
||||
|
||||
import jakarta.validation.constraints.Future;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record TokenIssueCommand(
|
||||
@Positive long userId,
|
||||
@Future OffsetDateTime expiresAt,
|
||||
@Size(max = 200) String description
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.user;
|
||||
|
||||
public record AppUser(
|
||||
long userId,
|
||||
String username,
|
||||
String empNo,
|
||||
String deptCode,
|
||||
String activeYn
|
||||
) {
|
||||
|
||||
public boolean active() {
|
||||
return "Y".equalsIgnoreCase(activeYn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cloudhandson.vpdbackoffice.mapper;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AuditMapper {
|
||||
|
||||
void insert(AuditEvent event);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cloudhandson.vpdbackoffice.mapper;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface BearerTokenMapper {
|
||||
|
||||
List<BearerTokenRecord> findAll();
|
||||
|
||||
BearerTokenRecord findById(@Param("keyId") long keyId);
|
||||
|
||||
long nextKeyId();
|
||||
|
||||
void insertToken(BearerTokenRecord token);
|
||||
|
||||
int revokeToken(
|
||||
@Param("keyId") long keyId,
|
||||
@Param("revokedAt") OffsetDateTime revokedAt,
|
||||
@Param("reason") String reason
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.cloudhandson.vpdbackoffice.mapper;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface PermissionMapper {
|
||||
|
||||
List<AppRole> findRoles();
|
||||
|
||||
AppRole findRole(@Param("roleId") long roleId);
|
||||
|
||||
PermissionSet findPermissionSet(@Param("roleId") long roleId, @Param("objectId") long objectId);
|
||||
|
||||
Long findPermissionId(@Param("roleId") long roleId, @Param("objectId") long objectId);
|
||||
|
||||
void insertPermission(@Param("permissionId") long permissionId,
|
||||
@Param("roleId") long roleId,
|
||||
@Param("objectId") long objectId,
|
||||
@Param("action") String action);
|
||||
|
||||
void updatePermissionAction(@Param("permissionId") long permissionId, @Param("action") String action);
|
||||
|
||||
void deleteRules(@Param("permissionId") long permissionId);
|
||||
|
||||
void insertRule(PermissionRule rule);
|
||||
|
||||
void deleteVisibleColumns(@Param("permissionId") long permissionId);
|
||||
|
||||
void insertVisibleColumn(@Param("permissionId") long permissionId, @Param("columnName") String columnName);
|
||||
|
||||
long nextPermissionId();
|
||||
|
||||
long nextRuleId();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cloudhandson.vpdbackoffice.mapper;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface ProtectedObjectMapper {
|
||||
|
||||
List<ProtectedObject> findEnabled();
|
||||
|
||||
ProtectedObject findById(@Param("objectId") long objectId);
|
||||
|
||||
List<ProtectedColumn> findColumns(@Param("objectId") long objectId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cloudhandson.vpdbackoffice.mapper;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface UserMapper {
|
||||
|
||||
List<AppUser> findAll();
|
||||
|
||||
AppUser findById(@Param("userId") long userId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
public class AppException extends RuntimeException {
|
||||
|
||||
public AppException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.AuditMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AuditService {
|
||||
|
||||
private final AuditMapper auditMapper;
|
||||
|
||||
public AuditService(AuditMapper auditMapper) {
|
||||
this.auditMapper = auditMapper;
|
||||
}
|
||||
|
||||
public void record(AuditEvent event) {
|
||||
auditMapper.insert(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import java.time.Clock;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class BearerTokenService {
|
||||
|
||||
private final BearerTokenMapper tokenMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final AuditService auditService;
|
||||
private final TokenGenerator tokenGenerator;
|
||||
private final TokenHasher tokenHasher;
|
||||
private final BackofficeProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
public BearerTokenService(
|
||||
BearerTokenMapper tokenMapper,
|
||||
UserMapper userMapper,
|
||||
AuditService auditService,
|
||||
TokenGenerator tokenGenerator,
|
||||
TokenHasher tokenHasher,
|
||||
BackofficeProperties properties,
|
||||
Clock clock
|
||||
) {
|
||||
this.tokenMapper = tokenMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.auditService = auditService;
|
||||
this.tokenGenerator = tokenGenerator;
|
||||
this.tokenHasher = tokenHasher;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public List<BearerTokenRecord> findAll() {
|
||||
return tokenMapper.findAll();
|
||||
}
|
||||
|
||||
public BearerTokenRecord findById(long keyId) {
|
||||
return tokenMapper.findById(keyId);
|
||||
}
|
||||
|
||||
public boolean matches(BearerTokenRecord record, String plainToken) {
|
||||
if (record == null || plainToken == null || plainToken.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String hash = tokenHasher.sha256(plainToken);
|
||||
return hash.equalsIgnoreCase(record.keyHash());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IssuedToken issueToken(TokenIssueCommand command) {
|
||||
AppUser user = userMapper.findById(command.userId());
|
||||
if (user == null) {
|
||||
throw new AppException("사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (!user.active()) {
|
||||
throw new AppException("비활성 사용자에게는 토큰을 발급할 수 없습니다.");
|
||||
}
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now(clock);
|
||||
if (command.expiresAt() == null || !command.expiresAt().isAfter(now)) {
|
||||
throw new AppException("만료일은 현재 시각 이후여야 합니다.");
|
||||
}
|
||||
if (command.expiresAt().isAfter(now.plusDays(properties.token().maxDays()))) {
|
||||
throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다.");
|
||||
}
|
||||
|
||||
String plainToken = tokenGenerator.generate();
|
||||
String prefix = tokenGenerator.prefix(plainToken);
|
||||
String hash = tokenHasher.sha256(plainToken);
|
||||
long keyId = tokenMapper.nextKeyId();
|
||||
|
||||
tokenMapper.insertToken(new BearerTokenRecord(
|
||||
keyId,
|
||||
user.userId(),
|
||||
user.username(),
|
||||
prefix,
|
||||
hash,
|
||||
command.expiresAt(),
|
||||
null,
|
||||
command.description()
|
||||
));
|
||||
auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null, "issued"));
|
||||
return new IssuedToken(keyId, prefix, plainToken, command.expiresAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revokeToken(long keyId, String reason) {
|
||||
int updated = tokenMapper.revokeToken(keyId, OffsetDateTime.now(clock), reason);
|
||||
if (updated == 0) {
|
||||
throw new AppException("회수할 활성 토큰을 찾을 수 없습니다.");
|
||||
}
|
||||
auditService.record(new AuditEvent("TOKEN_REVOKED", keyId, null, "SUCCESS", null, null, reason));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.config.BackofficeProperties;
|
||||
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
@Service
|
||||
public class OrdsProbeService {
|
||||
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final BearerTokenService tokenService;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final AuditService auditService;
|
||||
private final ProbeErrorClassifier errorClassifier;
|
||||
private final RestTemplate ordsRestTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BackofficeProperties properties;
|
||||
private final Clock clock;
|
||||
|
||||
public OrdsProbeService(
|
||||
BearerTokenService tokenService,
|
||||
ProtectedObjectService protectedObjectService,
|
||||
AuditService auditService,
|
||||
ProbeErrorClassifier errorClassifier,
|
||||
RestTemplate ordsRestTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
BackofficeProperties properties,
|
||||
Clock clock
|
||||
) {
|
||||
this.tokenService = tokenService;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.auditService = auditService;
|
||||
this.errorClassifier = errorClassifier;
|
||||
this.ordsRestTemplate = ordsRestTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public ProbeResult runProbe(ProbeCommand command) {
|
||||
BearerTokenRecord token = tokenService.findById(command.keyId());
|
||||
if (token == null) {
|
||||
return auditAndReturn(command, ProbeResult.blocked(
|
||||
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
|
||||
}
|
||||
if (!token.active(OffsetDateTime.now(clock))) {
|
||||
return auditAndReturn(command, ProbeResult.blocked(
|
||||
ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다."));
|
||||
}
|
||||
if (!tokenService.matches(token, command.bearerToken())) {
|
||||
return auditAndReturn(command, ProbeResult.blocked(
|
||||
ProbeStatus.INVALID_TOKEN, "INVALID_TOKEN", "입력한 Bearer Token이 선택한 key와 일치하지 않습니다."));
|
||||
}
|
||||
|
||||
ProtectedObject object;
|
||||
try {
|
||||
object = protectedObjectService.assertEnabled(command.objectId());
|
||||
} catch (AppException e) {
|
||||
return auditAndReturn(command, ProbeResult.blocked(
|
||||
ProbeStatus.OBJECT_DISABLED, "OBJECT_DISABLED", e.getMessage()));
|
||||
}
|
||||
|
||||
try {
|
||||
URI uri = buildUri(object.ordsPath(), command.limit());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(command.bearerToken());
|
||||
ResponseEntity<String> response = ordsRestTemplate.exchange(
|
||||
uri, HttpMethod.POST, new HttpEntity<>(headers), String.class);
|
||||
ProbeResult result = parseSuccess(response.getBody(), object.objectId());
|
||||
return auditAndReturn(command, result);
|
||||
} catch (HttpStatusCodeException e) {
|
||||
ProbeStatus status = errorClassifier.classify(e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return auditAndReturn(command, ProbeResult.blocked(
|
||||
status, status.name(), trimMessage(e.getResponseBodyAsString())));
|
||||
} catch (ResourceAccessException e) {
|
||||
ProbeStatus status = errorClassifier.isTimeout(e) ? ProbeStatus.ORDS_TIMEOUT : ProbeStatus.UNKNOWN_ERROR;
|
||||
return auditAndReturn(command, ProbeResult.blocked(status, status.name(), e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
return auditAndReturn(command, ProbeResult.blocked(
|
||||
ProbeStatus.INVALID_ORDS_RESPONSE, "INVALID_ORDS_RESPONSE", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private URI buildUri(String ordsPath, int limit) {
|
||||
String baseUrl = properties.ords().baseUrl();
|
||||
String path = ordsPath.startsWith("/") ? ordsPath.substring(1) : ordsPath;
|
||||
return UriComponentsBuilder.fromUriString(baseUrl)
|
||||
.path("/")
|
||||
.path(path)
|
||||
.queryParam("limit", limit)
|
||||
.build()
|
||||
.toUri();
|
||||
}
|
||||
|
||||
private ProbeResult parseSuccess(String body, long objectId) throws Exception {
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode rowsNode = root.has("rows") ? root.get("rows") : root;
|
||||
rowsNode = root.has("items") ? root.get("items") : rowsNode;
|
||||
if (!rowsNode.isArray()) {
|
||||
throw new AppException("ORDS 응답에 rows 배열이 없습니다.");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
Set<String> columns = new LinkedHashSet<>();
|
||||
for (JsonNode rowNode : rowsNode) {
|
||||
Map<String, Object> row = objectMapper.convertValue(rowNode, MAP_TYPE);
|
||||
rows.add(row);
|
||||
columns.addAll(row.keySet());
|
||||
}
|
||||
|
||||
ProbeStatus status = rows.isEmpty() ? ProbeStatus.VPD_DENY_EMPTY_RESULT : ProbeStatus.SUCCESS;
|
||||
return new ProbeResult(
|
||||
status,
|
||||
List.copyOf(columns),
|
||||
rows,
|
||||
rows.size(),
|
||||
findMaskedColumns(objectId, rows),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private List<String> findMaskedColumns(long objectId, List<Map<String, Object>> rows) {
|
||||
if (rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> sensitiveColumns = protectedObjectService.findColumns(objectId).stream()
|
||||
.filter(ProtectedColumn::sensitive)
|
||||
.map(column -> column.columnName().toLowerCase(Locale.ROOT))
|
||||
.toList();
|
||||
if (sensitiveColumns.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> masked = new ArrayList<>();
|
||||
for (String column : sensitiveColumns) {
|
||||
boolean present = false;
|
||||
boolean allNull = true;
|
||||
for (Map<String, Object> row : rows) {
|
||||
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(column)) {
|
||||
present = true;
|
||||
allNull = allNull && entry.getValue() == null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (present && allNull) {
|
||||
masked.add(column);
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
private ProbeResult auditAndReturn(ProbeCommand command, ProbeResult result) {
|
||||
auditService.record(new AuditEvent(
|
||||
"ORDS_PROBE",
|
||||
command.keyId(),
|
||||
command.objectId(),
|
||||
result.status().name(),
|
||||
result.rowCount(),
|
||||
result.errorCode(),
|
||||
result.errorMessage()
|
||||
));
|
||||
return result;
|
||||
}
|
||||
|
||||
private String trimMessage(String body) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
return body.length() <= 500 ? body : body.substring(0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.AppRole;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.PermissionMapper;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class PermissionService {
|
||||
|
||||
private static final Set<String> RULE_TYPES = Set.of("ALL", "MY_DEPT", "SELF", "REGION");
|
||||
|
||||
private final PermissionMapper permissionMapper;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public PermissionService(
|
||||
PermissionMapper permissionMapper,
|
||||
ProtectedObjectService protectedObjectService,
|
||||
AuditService auditService
|
||||
) {
|
||||
this.permissionMapper = permissionMapper;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
public List<AppRole> findRoles() {
|
||||
return permissionMapper.findRoles();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PermissionSet savePermissionSet(PermissionSetCommand command) {
|
||||
if (!"SELECT".equalsIgnoreCase(command.action())) {
|
||||
throw new AppException("초기 구현에서는 SELECT 권한만 저장할 수 있습니다.");
|
||||
}
|
||||
AppRole role = permissionMapper.findRole(command.roleId());
|
||||
if (role == null) {
|
||||
throw new AppException("역할을 찾을 수 없습니다.");
|
||||
}
|
||||
protectedObjectService.assertEnabled(command.objectId());
|
||||
validateRules(command.rules());
|
||||
validateVisibleColumns(command.objectId(), command.visibleColumns());
|
||||
|
||||
Long existingId = permissionMapper.findPermissionId(command.roleId(), command.objectId());
|
||||
long permissionId = existingId == null ? permissionMapper.nextPermissionId() : existingId;
|
||||
if (existingId == null) {
|
||||
permissionMapper.insertPermission(permissionId, command.roleId(), command.objectId(), "SELECT");
|
||||
} else {
|
||||
permissionMapper.updatePermissionAction(permissionId, "SELECT");
|
||||
}
|
||||
|
||||
permissionMapper.deleteRules(permissionId);
|
||||
for (RuleCommand rule : command.rules()) {
|
||||
permissionMapper.insertRule(new PermissionRule(
|
||||
permissionMapper.nextRuleId(),
|
||||
permissionId,
|
||||
normalize(rule.ruleType()),
|
||||
clean(rule.ruleValue())
|
||||
));
|
||||
}
|
||||
|
||||
permissionMapper.deleteVisibleColumns(permissionId);
|
||||
if (command.visibleColumns() != null) {
|
||||
for (String columnName : command.visibleColumns()) {
|
||||
permissionMapper.insertVisibleColumn(permissionId, columnName.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
auditService.record(new AuditEvent(
|
||||
"PERMISSION_SAVED", null, command.objectId(), "SUCCESS", null, null,
|
||||
"roleId=" + command.roleId()
|
||||
));
|
||||
return new PermissionSet(permissionId, command.roleId(), command.objectId(), "SELECT", List.of(), List.of());
|
||||
}
|
||||
|
||||
private void validateRules(List<RuleCommand> rules) {
|
||||
if (rules == null || rules.isEmpty()) {
|
||||
throw new AppException("행 규칙은 하나 이상 필요합니다.");
|
||||
}
|
||||
Set<String> seen = new HashSet<>();
|
||||
boolean hasAll = false;
|
||||
for (RuleCommand rule : rules) {
|
||||
String type = normalize(rule.ruleType());
|
||||
if (!RULE_TYPES.contains(type)) {
|
||||
throw new AppException("허용되지 않은 행 규칙입니다: " + type);
|
||||
}
|
||||
if (!seen.add(type + ":" + clean(rule.ruleValue()))) {
|
||||
throw new AppException("중복된 행 규칙이 있습니다.");
|
||||
}
|
||||
hasAll = hasAll || "ALL".equals(type);
|
||||
if (!"ALL".equals(type) && clean(rule.ruleValue()).isBlank()) {
|
||||
throw new AppException(type + " 규칙에는 값이 필요합니다.");
|
||||
}
|
||||
}
|
||||
if (hasAll && rules.size() > 1) {
|
||||
throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateVisibleColumns(long objectId, List<String> visibleColumns) {
|
||||
if (visibleColumns == null || visibleColumns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> allowed = new HashSet<>();
|
||||
for (ProtectedColumn column : protectedObjectService.findColumns(objectId)) {
|
||||
allowed.add(column.columnName().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
for (String columnName : visibleColumns) {
|
||||
if (!allowed.contains(columnName.trim().toUpperCase(Locale.ROOT))) {
|
||||
throw new AppException("등록되지 않은 컬럼입니다: " + columnName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return clean(value).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String clean(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
|
||||
import java.net.SocketTimeoutException;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
@Component
|
||||
public class ProbeErrorClassifier {
|
||||
|
||||
public ProbeStatus classify(HttpStatusCode status, String body) {
|
||||
String text = body == null ? "" : body;
|
||||
if (text.contains("ORA-20002")) {
|
||||
return ProbeStatus.INVALID_TOKEN;
|
||||
}
|
||||
if (text.contains("ORA-00942") || text.contains("ORA-01031")) {
|
||||
return ProbeStatus.OBJECT_NOT_ACCESSIBLE;
|
||||
}
|
||||
if (status != null && (status.value() == 401 || status.value() == 403)) {
|
||||
return ProbeStatus.INVALID_TOKEN;
|
||||
}
|
||||
return ProbeStatus.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
public boolean isTimeout(ResourceAccessException exception) {
|
||||
Throwable cause = exception;
|
||||
while (cause != null) {
|
||||
if (cause instanceof SocketTimeoutException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn;
|
||||
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ProtectedObjectService {
|
||||
|
||||
private final ProtectedObjectMapper mapper;
|
||||
|
||||
public ProtectedObjectService(ProtectedObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public List<ProtectedObject> findEnabled() {
|
||||
return mapper.findEnabled();
|
||||
}
|
||||
|
||||
public ProtectedObject assertEnabled(long objectId) {
|
||||
ProtectedObject object = mapper.findById(objectId);
|
||||
if (object == null || !object.enabled()) {
|
||||
throw new AppException("활성 보호 객체를 찾을 수 없습니다.");
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
public List<ProtectedColumn> findColumns(long objectId) {
|
||||
return mapper.findColumns(objectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class TokenGenerator {
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public String generate() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return "vpd_live_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
public String prefix(String token) {
|
||||
if (token == null || token.length() <= 16) {
|
||||
return token;
|
||||
}
|
||||
return token.substring(0, 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cloudhandson.vpdbackoffice.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class TokenHasher {
|
||||
|
||||
public String sha256(String token) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8))).toUpperCase();
|
||||
} catch (Exception e) {
|
||||
throw new AppException("Failed to hash bearer token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.AppException;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
|
||||
@ControllerAdvice
|
||||
public class AppExceptionHandler {
|
||||
|
||||
@ExceptionHandler(AppException.class)
|
||||
public String handleAppException(AppException exception, Model model) {
|
||||
model.addAttribute("errorMessage", exception.getMessage());
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
import com.cloudhandson.vpdbackoffice.service.PermissionService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class DashboardController {
|
||||
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final PermissionService permissionService;
|
||||
private final BearerTokenService bearerTokenService;
|
||||
|
||||
public DashboardController(
|
||||
ProtectedObjectService protectedObjectService,
|
||||
PermissionService permissionService,
|
||||
BearerTokenService bearerTokenService
|
||||
) {
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.permissionService = permissionService;
|
||||
this.bearerTokenService = bearerTokenService;
|
||||
}
|
||||
|
||||
@GetMapping("/")
|
||||
public String dashboard(Model model) {
|
||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||
model.addAttribute("roles", permissionService.findRoles());
|
||||
model.addAttribute("tokens", bearerTokenService.findAll());
|
||||
return "dashboard";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.PermissionSetCommand;
|
||||
import com.cloudhandson.vpdbackoffice.domain.permission.RuleCommand;
|
||||
import com.cloudhandson.vpdbackoffice.service.PermissionService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
public class PermissionController {
|
||||
|
||||
private final PermissionService permissionService;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
|
||||
public PermissionController(
|
||||
PermissionService permissionService,
|
||||
ProtectedObjectService protectedObjectService
|
||||
) {
|
||||
this.permissionService = permissionService;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
}
|
||||
|
||||
@GetMapping("/permissions")
|
||||
public String permissions(Model model) {
|
||||
model.addAttribute("roles", permissionService.findRoles());
|
||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||
return "permissions";
|
||||
}
|
||||
|
||||
@PostMapping("/permissions")
|
||||
public String save(
|
||||
@RequestParam long roleId,
|
||||
@RequestParam long objectId,
|
||||
@RequestParam String ruleType,
|
||||
@RequestParam(required = false) String ruleValue,
|
||||
@RequestParam(required = false) List<String> visibleColumns,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
permissionService.savePermissionSet(new PermissionSetCommand(
|
||||
roleId,
|
||||
objectId,
|
||||
"SELECT",
|
||||
List.of(new RuleCommand(ruleType, ruleValue)),
|
||||
visibleColumns == null ? List.of() : visibleColumns
|
||||
));
|
||||
redirectAttributes.addFlashAttribute("message", "권한을 저장했습니다.");
|
||||
return "redirect:/permissions";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
|
||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
|
||||
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@Controller
|
||||
public class ProbeController {
|
||||
|
||||
private final OrdsProbeService probeService;
|
||||
private final BearerTokenService tokenService;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
|
||||
public ProbeController(
|
||||
OrdsProbeService probeService,
|
||||
BearerTokenService tokenService,
|
||||
ProtectedObjectService protectedObjectService
|
||||
) {
|
||||
this.probeService = probeService;
|
||||
this.tokenService = tokenService;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
}
|
||||
|
||||
@GetMapping("/probe")
|
||||
public String probe(Model model) {
|
||||
model.addAttribute("tokens", tokenService.findAll());
|
||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||
return "probe";
|
||||
}
|
||||
|
||||
@PostMapping("/probe")
|
||||
public String run(
|
||||
@RequestParam long keyId,
|
||||
@RequestParam long objectId,
|
||||
@RequestParam String bearerToken,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
Model model
|
||||
) {
|
||||
model.addAttribute("result", probeService.runProbe(new ProbeCommand(keyId, objectId, bearerToken, limit)));
|
||||
return "fragments/probe-result :: result";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
|
||||
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
|
||||
import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
|
||||
import java.time.OffsetDateTime;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
@Controller
|
||||
public class TokenController {
|
||||
|
||||
private final BearerTokenService tokenService;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
public TokenController(BearerTokenService tokenService, UserMapper userMapper) {
|
||||
this.tokenService = tokenService;
|
||||
this.userMapper = userMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/tokens")
|
||||
public String tokens(Model model) {
|
||||
model.addAttribute("tokens", tokenService.findAll());
|
||||
model.addAttribute("users", userMapper.findAll());
|
||||
return "tokens";
|
||||
}
|
||||
|
||||
@PostMapping("/tokens")
|
||||
public String issue(
|
||||
@RequestParam long userId,
|
||||
@RequestParam String expiresAt,
|
||||
@RequestParam(required = false) String description,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
var issued = tokenService.issueToken(new TokenIssueCommand(
|
||||
userId,
|
||||
OffsetDateTime.parse(expiresAt),
|
||||
description
|
||||
));
|
||||
redirectAttributes.addFlashAttribute("issued", issued);
|
||||
return "redirect:/tokens";
|
||||
}
|
||||
|
||||
@PostMapping("/tokens/revoke")
|
||||
public String revoke(
|
||||
@RequestParam long keyId,
|
||||
@RequestParam(required = false) String reason,
|
||||
RedirectAttributes redirectAttributes
|
||||
) {
|
||||
tokenService.revokeToken(keyId, reason);
|
||||
redirectAttributes.addFlashAttribute("message", "토큰을 회수했습니다.");
|
||||
return "redirect:/tokens";
|
||||
}
|
||||
}
|
||||
33
src/main/resources/application.yml
Normal file
33
src/main/resources/application.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
spring:
|
||||
application:
|
||||
name: vpd-permission-backoffice
|
||||
datasource:
|
||||
url: ${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}
|
||||
username: ${BACKOFFICE_DB_USERNAME:backoffice}
|
||||
password: ${BACKOFFICE_DB_PASSWORD:backoffice}
|
||||
driver-class-name: oracle.jdbc.OracleDriver
|
||||
hikari:
|
||||
pool-name: vpd-backoffice-pool
|
||||
maximum-pool-size: ${BACKOFFICE_DB_POOL_MAX:5}
|
||||
minimum-idle: ${BACKOFFICE_DB_POOL_MIN:1}
|
||||
connection-timeout: 10000
|
||||
thymeleaf:
|
||||
cache: false
|
||||
|
||||
mybatis:
|
||||
mapper-locations: classpath:/mapper/*.xml
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
|
||||
server:
|
||||
port: ${BACKOFFICE_PORT:8080}
|
||||
|
||||
backoffice:
|
||||
security:
|
||||
admin-user: ${BACKOFFICE_ADMIN_USER:admin}
|
||||
admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin}
|
||||
token:
|
||||
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
|
||||
ords:
|
||||
base-url: ${BACKOFFICE_ORDS_BASE_URL:http://localhost:8081/ords}
|
||||
timeout-seconds: ${BACKOFFICE_ORDS_TIMEOUT_SECONDS:10}
|
||||
13
src/main/resources/mapper/AuditMapper.xml
Normal file
13
src/main/resources/mapper/AuditMapper.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
|
||||
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
|
||||
INSERT INTO cb_ords_probe_audit (
|
||||
audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
|
||||
) VALUES (
|
||||
cb_ords_probe_audit_seq.NEXTVAL,
|
||||
#{eventType}, #{keyId}, #{objectId}, #{status}, #{rowCount}, #{errorCode}, #{message}, SYSTIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
</mapper>
|
||||
43
src/main/resources/mapper/BearerTokenMapper.xml
Normal file
43
src/main/resources/mapper/BearerTokenMapper.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
|
||||
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||
SELECT k.key_id, k.user_id, u.user_name AS username, k.key_prefix, k.key_hash,
|
||||
k.expires_at, k.revoked_at, k.description
|
||||
FROM cb_agent_bearer_key k
|
||||
JOIN cb_app_user u ON u.user_id = k.user_id
|
||||
ORDER BY k.key_id DESC
|
||||
</select>
|
||||
|
||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||
SELECT k.key_id, k.user_id, u.user_name AS username, k.key_prefix, k.key_hash,
|
||||
k.expires_at, k.revoked_at, k.description
|
||||
FROM cb_agent_bearer_key k
|
||||
JOIN cb_app_user u ON u.user_id = k.user_id
|
||||
WHERE k.key_id = #{keyId}
|
||||
</select>
|
||||
|
||||
<select id="nextKeyId" resultType="long">
|
||||
SELECT cb_agent_bearer_key_seq.NEXTVAL FROM dual
|
||||
</select>
|
||||
|
||||
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||
INSERT INTO cb_agent_bearer_key (
|
||||
key_id, user_id, key_prefix, key_hash, expires_at, revoked_at, description
|
||||
) VALUES (
|
||||
#{keyId}, #{userId}, #{keyPrefix}, #{keyHash}, #{expiresAt}, #{revokedAt}, #{description}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="revokeToken">
|
||||
UPDATE cb_agent_bearer_key
|
||||
SET revoked_at = #{revokedAt},
|
||||
description = CASE
|
||||
WHEN #{reason} IS NULL THEN description
|
||||
ELSE SUBSTR(COALESCE(description, '') || ' revoked: ' || #{reason}, 1, 200)
|
||||
END
|
||||
WHERE key_id = #{keyId}
|
||||
AND revoked_at IS NULL
|
||||
</update>
|
||||
</mapper>
|
||||
76
src/main/resources/mapper/PermissionMapper.xml
Normal file
76
src/main/resources/mapper/PermissionMapper.xml
Normal file
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.PermissionMapper">
|
||||
<select id="findRoles" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole">
|
||||
SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description
|
||||
FROM cb_app_role
|
||||
ORDER BY role_name
|
||||
</select>
|
||||
|
||||
<select id="findRole" resultType="com.cloudhandson.vpdbackoffice.domain.permission.AppRole">
|
||||
SELECT role_id, role_name, CAST(NULL AS VARCHAR2(200)) AS description
|
||||
FROM cb_app_role
|
||||
WHERE role_id = #{roleId}
|
||||
</select>
|
||||
|
||||
<select id="findPermissionId" resultType="long">
|
||||
SELECT p.perm_id
|
||||
FROM cb_permission p
|
||||
JOIN cb_protected_object o ON o.object_name = p.target_name
|
||||
WHERE p.role_id = #{roleId}
|
||||
AND o.object_id = #{objectId}
|
||||
</select>
|
||||
|
||||
<select id="findPermissionSet" resultType="com.cloudhandson.vpdbackoffice.domain.permission.PermissionSet">
|
||||
SELECT p.perm_id AS permission_id,
|
||||
p.role_id,
|
||||
o.object_id,
|
||||
p.action_name AS action
|
||||
FROM cb_permission p
|
||||
JOIN cb_protected_object o ON o.object_name = p.target_name
|
||||
WHERE p.role_id = #{roleId}
|
||||
AND o.object_id = #{objectId}
|
||||
</select>
|
||||
|
||||
<select id="nextPermissionId" resultType="long">
|
||||
SELECT cb_permission_seq.NEXTVAL FROM dual
|
||||
</select>
|
||||
|
||||
<select id="nextRuleId" resultType="long">
|
||||
SELECT cb_permission_rule_seq.NEXTVAL FROM dual
|
||||
</select>
|
||||
|
||||
<insert id="insertPermission">
|
||||
INSERT INTO cb_permission (perm_id, role_id, target_name, action_name)
|
||||
SELECT #{permissionId}, #{roleId}, object_name, #{action}
|
||||
FROM cb_protected_object
|
||||
WHERE object_id = #{objectId}
|
||||
</insert>
|
||||
|
||||
<update id="updatePermissionAction">
|
||||
UPDATE cb_permission
|
||||
SET action_name = #{action}
|
||||
WHERE perm_id = #{permissionId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteRules">
|
||||
DELETE FROM cb_permission_rule
|
||||
WHERE perm_id = #{permissionId}
|
||||
</delete>
|
||||
|
||||
<insert id="insertRule" parameterType="com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule">
|
||||
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_type, rule_value)
|
||||
VALUES (#{ruleId}, #{permissionId}, #{ruleType}, #{ruleValue})
|
||||
</insert>
|
||||
|
||||
<delete id="deleteVisibleColumns">
|
||||
DELETE FROM cb_permission_column
|
||||
WHERE permission_id = #{permissionId}
|
||||
</delete>
|
||||
|
||||
<insert id="insertVisibleColumn">
|
||||
INSERT INTO cb_permission_column (permission_id, column_name)
|
||||
VALUES (#{permissionId}, #{columnName})
|
||||
</insert>
|
||||
</mapper>
|
||||
24
src/main/resources/mapper/ProtectedObjectMapper.xml
Normal file
24
src/main/resources/mapper/ProtectedObjectMapper.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.ProtectedObjectMapper">
|
||||
<select id="findEnabled" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
||||
SELECT object_id, owner, object_name, ords_path, enabled_yn
|
||||
FROM cb_protected_object
|
||||
WHERE enabled_yn = 'Y'
|
||||
ORDER BY owner, object_name
|
||||
</select>
|
||||
|
||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject">
|
||||
SELECT object_id, owner, object_name, ords_path, enabled_yn
|
||||
FROM cb_protected_object
|
||||
WHERE object_id = #{objectId}
|
||||
</select>
|
||||
|
||||
<select id="findColumns" resultType="com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedColumn">
|
||||
SELECT column_id, object_id, column_name, sensitive_yn, visible_role_id
|
||||
FROM cb_protected_column
|
||||
WHERE object_id = #{objectId}
|
||||
ORDER BY column_id
|
||||
</select>
|
||||
</mapper>
|
||||
24
src/main/resources/mapper/UserMapper.xml
Normal file
24
src/main/resources/mapper/UserMapper.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
|
||||
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
||||
SELECT user_id,
|
||||
user_name AS username,
|
||||
employee_no AS emp_no,
|
||||
dept_code,
|
||||
active AS active_yn
|
||||
FROM cb_app_user
|
||||
ORDER BY username
|
||||
</select>
|
||||
|
||||
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
|
||||
SELECT user_id,
|
||||
user_name AS username,
|
||||
employee_no AS emp_no,
|
||||
dept_code,
|
||||
active AS active_yn
|
||||
FROM cb_app_user
|
||||
WHERE user_id = #{userId}
|
||||
</select>
|
||||
</mapper>
|
||||
99
src/main/resources/static/css/app.css
Normal file
99
src/main/resources/static/css/app.css
Normal file
@@ -0,0 +1,99 @@
|
||||
body {
|
||||
background: #f7f8fa;
|
||||
color: #20242a;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: #667085;
|
||||
margin: .35rem 0 0;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-tile {
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.summary-tile .label {
|
||||
color: #667085;
|
||||
display: block;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.summary-tile strong {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
margin-top: .35rem;
|
||||
}
|
||||
|
||||
.content-band {
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-band h2,
|
||||
.section-heading h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
color: #344054;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
display: block;
|
||||
margin-top: .5rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
6
src/main/resources/static/js/app.js
Normal file
6
src/main/resources/static/js/app.js
Normal file
@@ -0,0 +1,6 @@
|
||||
document.body.addEventListener('htmx:responseError', (event) => {
|
||||
const target = event.detail.target;
|
||||
if (target) {
|
||||
target.innerHTML = '<div class="alert alert-danger">요청 처리 중 오류가 발생했습니다.</div>';
|
||||
}
|
||||
});
|
||||
58
src/main/resources/templates/dashboard.html
Normal file
58
src/main/resources/templates/dashboard.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('VPD 백오피스')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>VPD 권한 백오피스</h1>
|
||||
<p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
<section class="summary-grid">
|
||||
<a class="summary-tile" href="/permissions">
|
||||
<span class="label">보호 객체</span>
|
||||
<strong th:text="${#lists.size(objects)}">0</strong>
|
||||
</a>
|
||||
<a class="summary-tile" href="/permissions">
|
||||
<span class="label">역할</span>
|
||||
<strong th:text="${#lists.size(roles)}">0</strong>
|
||||
</a>
|
||||
<a class="summary-tile" href="/tokens">
|
||||
<span class="label">토큰</span>
|
||||
<strong th:text="${#lists.size(tokens)}">0</strong>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<h2>최근 보호 객체</h2>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/probe">검증 실행</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Object</th>
|
||||
<th>ORDS Path</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="object : ${objects}">
|
||||
<td th:text="${object.owner()}">ADMIN</td>
|
||||
<td th:text="${object.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
|
||||
<td><code th:text="${object.ordsPath()}">search/documents</code></td>
|
||||
<td><span class="badge text-bg-success">enabled</span></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(objects)}">
|
||||
<td colspan="4" class="text-muted">등록된 보호 객체가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
14
src/main/resources/templates/error.html
Normal file
14
src/main/resources/templates/error.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('오류')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="alert alert-danger">
|
||||
<strong>처리할 수 없습니다.</strong>
|
||||
<span th:text="${errorMessage} ?: '요청 처리 중 오류가 발생했습니다.'"></span>
|
||||
</div>
|
||||
<a class="btn btn-outline-primary" href="/">대시보드</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
29
src/main/resources/templates/fragments/layout.html
Normal file
29
src/main/resources/templates/fragments/layout.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:fragment="head(title)">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${title}">VPD 백오피스</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/css/app.css" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
<script defer src="/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav th:fragment="nav" class="navbar navbar-expand-lg bg-body border-bottom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">VPD Backoffice</a>
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-link" href="/permissions">권한</a>
|
||||
<a class="nav-link" href="/tokens">토큰</a>
|
||||
<a class="nav-link" href="/probe">ORDS 검증</a>
|
||||
</div>
|
||||
<form method="post" action="/logout" class="ms-auto">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">로그아웃</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
39
src/main/resources/templates/fragments/probe-result.html
Normal file
39
src/main/resources/templates/fragments/probe-result.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:fragment="result">
|
||||
<div class="section-heading">
|
||||
<h2>검증 결과</h2>
|
||||
<span class="badge" th:classappend="${result.status().name() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-warning'"
|
||||
th:text="${result.status()}">SUCCESS</span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" th:if="${result.errorCode()}">
|
||||
<strong th:text="${result.errorCode()}">ERROR</strong>
|
||||
<span th:text="${result.errorMessage()}">message</span>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
<span>Rows: <strong th:text="${result.rowCount()}">0</strong></span>
|
||||
<span th:if="${!#lists.isEmpty(result.maskedColumns())}">
|
||||
Masked: <code th:text="${#strings.listJoin(result.maskedColumns(), ', ')}"></code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive" th:if="${!#lists.isEmpty(result.rows())}">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:each="column : ${result.columns()}" th:text="${column}">column</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${result.rows()}">
|
||||
<td th:each="column : ${result.columns()}" th:text="${row[column]}">value</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
47
src/main/resources/templates/permissions.html
Normal file
47
src/main/resources/templates/permissions.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('권한 관리')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>권한 관리</h1>
|
||||
<p>역할별 보호 객체 접근과 행 규칙을 저장합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||
|
||||
<section class="content-band">
|
||||
<form method="post" action="/permissions" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
역할
|
||||
<select class="form-select" name="roleId" required>
|
||||
<option th:each="role : ${roles}" th:value="${role.roleId()}" th:text="${role.roleName()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
보호 객체
|
||||
<select class="form-select" name="objectId" required>
|
||||
<option th:each="object : ${objects}" th:value="${object.objectId()}" th:text="${object.displayName()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
행 규칙
|
||||
<select class="form-select" name="ruleType">
|
||||
<option value="ALL">ALL</option>
|
||||
<option value="REGION">REGION</option>
|
||||
<option value="MY_DEPT">MY_DEPT</option>
|
||||
<option value="SELF">SELF</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
규칙 값
|
||||
<input class="form-control" name="ruleValue" placeholder="APAC 또는 HR">
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">저장</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
44
src/main/resources/templates/probe.html
Normal file
44
src/main/resources/templates/probe.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('ORDS 검증')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>ORDS 검증</h1>
|
||||
<p>Bearer Token과 보호 객체를 선택해 VPD/Redaction 적용 결과를 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
<section class="content-band">
|
||||
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
Token ID
|
||||
<select class="form-select" name="keyId" required>
|
||||
<option th:each="token : ${tokens}" th:value="${token.keyId()}" th:text="${token.keyPrefix()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Bearer Token 원문
|
||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
|
||||
</label>
|
||||
<label>
|
||||
보호 객체
|
||||
<select class="form-select" name="objectId" required>
|
||||
<option th:each="object : ${objects}" th:value="${object.objectId()}" th:text="${object.displayName()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">호출</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="probe-result" class="content-band">
|
||||
<div class="text-muted">검증 결과가 여기에 표시됩니다.</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
79
src/main/resources/templates/tokens.html
Normal file
79
src/main/resources/templates/tokens.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/layout :: head('Bearer Token')}"></head>
|
||||
<body>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>Bearer Token</h1>
|
||||
<p>토큰은 발급 직후 한 번만 원문을 표시합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
|
||||
<div class="alert alert-warning" th:if="${issued}">
|
||||
<div class="fw-semibold">새 토큰이 발급되었습니다. 이 값은 다시 표시되지 않습니다.</div>
|
||||
<code class="token-value" th:text="${issued.plainToken()}"></code>
|
||||
</div>
|
||||
|
||||
<section class="content-band">
|
||||
<h2>토큰 발급</h2>
|
||||
<form method="post" action="/tokens" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
사용자
|
||||
<select class="form-select" name="userId" required>
|
||||
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
만료 시각
|
||||
<input class="form-control" name="expiresAt" placeholder="2026-12-31T23:59:59+09:00" required>
|
||||
</label>
|
||||
<label>
|
||||
설명
|
||||
<input class="form-control" name="description" maxlength="200">
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">발급</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<h2>토큰 목록</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Prefix</th>
|
||||
<th>Expires</th>
|
||||
<th>Revoked</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="token : ${tokens}">
|
||||
<td th:text="${token.keyId()}">1</td>
|
||||
<td th:text="${token.username()}">user</td>
|
||||
<td><code th:text="${token.keyPrefix()}">vpd_live_x</code></td>
|
||||
<td th:text="${token.expiresAt()}">2026</td>
|
||||
<td th:text="${token.revokedAt()} ?: '-'">-</td>
|
||||
<td>
|
||||
<form method="post" action="/tokens/revoke" class="inline-form">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<input type="hidden" name="keyId" th:value="${token.keyId()}">
|
||||
<input type="hidden" name="reason" value="manual">
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" th:disabled="${token.revokedAt() != null}">회수</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(tokens)}">
|
||||
<td colspan="6" class="text-muted">등록된 토큰이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user