Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import kr.devslab.kit.access.policy.Policy;
import kr.devslab.kit.access.policy.PolicyContext;
import kr.devslab.kit.access.policy.PolicyDecision;
Expand Down Expand Up @@ -34,4 +35,8 @@ public PolicyDecision evaluate(String policyName, PolicyContext context) {
}
return policy.evaluate(context);
}

public Set<String> registeredNames() {
return policiesByName.keySet();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package kr.devslab.kit.admin.diagnostics;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Set;
import java.util.UUID;
import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository;
import kr.devslab.kit.admin.AdminApiPaths;
import kr.devslab.kit.core.id.PublicId;
import kr.devslab.kit.core.id.TenantId;
import kr.devslab.kit.core.id.UserId;
import kr.devslab.kit.identity.AccountLoginException;
import kr.devslab.kit.identity.CurrentUser;
import kr.devslab.kit.identity.LoginCommand;
import kr.devslab.kit.identity.LoginFailureReason;
import kr.devslab.kit.identity.LoginResult;
import kr.devslab.kit.identity.core.entity.PlatformUserAccountEntity;
import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository;
import kr.devslab.kit.identity.core.service.LocalLoginService;
import kr.devslab.kit.menu.MenuTree;
import kr.devslab.kit.menu.core.service.MenuTreeBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(AdminApiPaths.BASE + "/diagnostics")
public class DiagnosticsController {

private final LocalLoginService loginService;
private final JpaPlatformUserAccountRepository userRepo;
private final JpaPlatformPermissionRepository permissionRepo;
private final kr.devslab.kit.menu.core.repository.JpaPlatformMenuRepository menuRepo;
private final MenuTreeBuilder menuTreeBuilder;

public DiagnosticsController(
LocalLoginService loginService,
JpaPlatformUserAccountRepository userRepo,
JpaPlatformPermissionRepository permissionRepo,
kr.devslab.kit.menu.core.repository.JpaPlatformMenuRepository menuRepo,
MenuTreeBuilder menuTreeBuilder
) {
this.loginService = loginService;
this.userRepo = userRepo;
this.permissionRepo = permissionRepo;
this.menuRepo = menuRepo;
this.menuTreeBuilder = menuTreeBuilder;
}

@PostMapping("/login-test")
public LoginTestResponse loginTest(@Valid @RequestBody LoginTestRequest req) {
LoginCommand cmd = new LoginCommand(TenantId.of(req.tenantId()), req.loginId(), req.rawPassword());
try {
LoginResult result = loginService.login(cmd);
CurrentUser u = result.user();
return new LoginTestResponse(true, null, u.id().value().toString(),
u.publicId().value(), u.loginId(), u.status().name());
} catch (AccountLoginException ex) {
return new LoginTestResponse(false, ex.reason(), null, null, null, null);
}
}

@PostMapping("/permission-check")
public PermissionCheckResponse permissionCheck(@Valid @RequestBody PermissionCheckRequest req) {
Set<String> codes = permissionRepo.findCodesForUserId(req.userId());
return new PermissionCheckResponse(codes.contains(req.permissionCode()), codes);
}

@GetMapping("/menu-visibility")
public MenuTree menuVisibility(@RequestParam UUID userId) {
PlatformUserAccountEntity user = userRepo.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
Set<String> codes = permissionRepo.findCodesForUserId(userId);
var entities = menuRepo.findAllByTenantIdOrderBySortOrderAsc(user.getTenantId());
MenuTree fullTree = menuTreeBuilder.build(entities);
return new MenuTree(fullTree.roots().stream()
.map(item -> filterByCodes(item, codes))
.filter(java.util.Objects::nonNull)
.toList());
}

private kr.devslab.kit.menu.MenuItem filterByCodes(kr.devslab.kit.menu.MenuItem item, Set<String> codes) {
if (item.requiredPermission().isPresent() && !codes.contains(item.requiredPermission().get().code())) {
return null;
}
var visibleChildren = item.children().stream()
.map(c -> filterByCodes(c, codes))
.filter(java.util.Objects::nonNull)
.toList();
return new kr.devslab.kit.menu.MenuItem(
item.id(), item.code(), item.label(), item.path(),
item.requiredPermission(), visibleChildren);
}

public record LoginTestRequest(
@NotBlank String tenantId,
@NotBlank String loginId,
@NotBlank String rawPassword
) {
}

public record LoginTestResponse(
boolean success,
LoginFailureReason failureReason,
String userId,
String publicId,
String loginId,
String status
) {
}

public record PermissionCheckRequest(
@NotNull UUID userId,
@NotBlank String permissionCode
) {
}

public record PermissionCheckResponse(boolean allowed, Set<String> userPermissionCodes) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package kr.devslab.kit.admin.policy;

import java.util.List;
import java.util.Optional;
import kr.devslab.kit.access.core.service.DefaultPolicyEvaluator;
import kr.devslab.kit.access.policy.PolicyContext;
import kr.devslab.kit.access.policy.PolicyDecision;
import kr.devslab.kit.access.policy.PolicyEvaluator;
import kr.devslab.kit.admin.AdminApiPaths;
import kr.devslab.kit.core.id.TenantId;
import kr.devslab.kit.core.id.UserId;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping(AdminApiPaths.BASE + "/policies")
public class PolicyAdminController {

private final PolicyEvaluator evaluator;

public PolicyAdminController(PolicyEvaluator evaluator) {
this.evaluator = evaluator;
}

@GetMapping
public List<String> list() {
if (evaluator instanceof DefaultPolicyEvaluator def) {
return def.registeredNames().stream().sorted().toList();
}
return List.of();
}

@PostMapping("/{name}/test")
public PolicyTestResponse test(@PathVariable String name, @RequestBody PolicyTestRequest req) {
PolicyContext ctx = PolicyContext.builder()
.user(req.userId() == null ? null : UserId.of(req.userId()))
.tenant(req.tenantId() == null ? null : TenantId.of(req.tenantId()))
.resource(req.resourceType(), req.resourceId())
.resourceAttributes(Optional.ofNullable(req.resourceAttributes()).orElseGet(java.util.Map::of))
.environmentAttributes(Optional.ofNullable(req.environmentAttributes()).orElseGet(java.util.Map::of))
.build();
PolicyDecision decision = evaluator.evaluate(name, ctx);
return new PolicyTestResponse(name, decision);
}

public record PolicyTestResponse(String name, PolicyDecision decision) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.devslab.kit.admin.policy;

import java.util.Map;
import java.util.UUID;

public record PolicyTestRequest(
UUID userId,
String tenantId,
String resourceType,
String resourceId,
Map<String, Object> resourceAttributes,
Map<String, Object> environmentAttributes
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package kr.devslab.kit.admin.settings;

import java.util.LinkedHashMap;
import java.util.Map;
import kr.devslab.kit.admin.AdminApiPaths;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Read-only view of {@code devslab.kit.*} runtime settings.
*
* <p>Sensitive fields (JWT secret, etc.) are masked. Mutating settings at
* runtime requires Spring Cloud RefreshScope or an external config source —
* deliberately out of scope here.
*
* <p>Implemented against {@link Environment} (rather than {@code DevslabKitProperties})
* so this module avoids depending on {@code devslab-kit-autoconfigure} and we
* keep the dependency graph acyclic.
*/
@RestController
@RequestMapping(AdminApiPaths.BASE + "/settings")
public class SettingsAdminController {

private static final String MASKED = "***";
private static final String PREFIX = "devslab.kit.";

private final Environment env;

public SettingsAdminController(Environment env) {
this.env = env;
}

@GetMapping
public Map<String, Object> get() {
Map<String, Object> root = new LinkedHashMap<>();
root.put("identity", identityView());
root.put("access", section("access.enabled"));
root.put("tenant", tenantView());
root.put("menu", section("menu.enabled"));
root.put("audit", section("audit.enabled"));
return root;
}

private Map<String, Object> identityView() {
Map<String, Object> view = new LinkedHashMap<>();
view.put("enabled", env.getProperty(PREFIX + "identity.enabled", Boolean.class, true));
view.put("mode", env.getProperty(PREFIX + "identity.mode", "LOCAL"));
view.put("maxFailedAttempts", env.getProperty(PREFIX + "identity.max-failed-attempts", Integer.class, 5));
view.put("lockoutDuration", env.getProperty(PREFIX + "identity.lockout-duration", "PT15M"));
Map<String, Object> jwt = new LinkedHashMap<>();
jwt.put("issuer", env.getProperty(PREFIX + "identity.jwt.issuer", "devslab-kit"));
jwt.put("ttl", env.getProperty(PREFIX + "identity.jwt.ttl", "PT8H"));
String secret = env.getProperty(PREFIX + "identity.jwt.secret");
jwt.put("secret", secret == null || secret.isBlank() ? null : MASKED);
view.put("jwt", jwt);
return view;
}

private Map<String, Object> tenantView() {
Map<String, Object> view = new LinkedHashMap<>();
view.put("enabled", env.getProperty(PREFIX + "tenant.enabled", Boolean.class, true));
view.put("mode", env.getProperty(PREFIX + "tenant.mode", "SINGLE"));
view.put("defaultTenantId", env.getProperty(PREFIX + "tenant.default-tenant-id", "default"));
view.put("resolver", env.getProperty(PREFIX + "tenant.resolver", "FIXED"));
view.put("headerName", env.getProperty(PREFIX + "tenant.header-name", "X-Tenant-Id"));
view.put("subdomainIndex", env.getProperty(PREFIX + "tenant.subdomain-index", Integer.class, 0));
return view;
}

private Map<String, Object> section(String enabledKey) {
return Map.of("enabled", env.getProperty(PREFIX + enabledKey, Boolean.class, true));
}
}
3 changes: 3 additions & 0 deletions devslab-kit-autoconfigure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ dependencies {

implementation("org.springframework.boot:spring-boot-autoconfigure")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")

// Micrometer for custom metrics; consumer's spring-boot-actuator activates it at runtime.
compileOnly("io.micrometer:micrometer-core")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package kr.devslab.kit.autoconfigure;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import kr.devslab.kit.identity.event.LoginFailedEvent;
import kr.devslab.kit.identity.event.LoginSucceededEvent;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.event.EventListener;

/**
* Custom Micrometer metrics for devslab-kit (planning §13).
*
* <p>Listens to identity events and increments per-vertical counters so
* that any consumer that exposes Spring Boot Actuator can read them via
* {@code /actuator/metrics/{name}}.
*
* <p>Counters registered lazily on first event so we don't need to know
* up front what failure reasons / tenant ids exist.
*/
@AutoConfiguration
@ConditionalOnClass(MeterRegistry.class)
public class MetricsAutoConfiguration {

private static final String METRIC_LOGIN_SUCCESS = "devslab.identity.login.success";
private static final String METRIC_LOGIN_FAILURE = "devslab.identity.login.failure";

private final MeterRegistry meterRegistry;

public MetricsAutoConfiguration(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}

@EventListener
public void onLoginSucceeded(LoginSucceededEvent event) {
Counter.builder(METRIC_LOGIN_SUCCESS)
.tag("tenant", event.tenantId().value())
.register(meterRegistry)
.increment();
}

@EventListener
public void onLoginFailed(LoginFailedEvent event) {
Counter.builder(METRIC_LOGIN_FAILURE)
.tag("tenant", event.tenantId().value())
.tag("reason", event.reason().name())
.register(meterRegistry)
.increment();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ kr.devslab.kit.autoconfigure.AccessAutoConfiguration
kr.devslab.kit.autoconfigure.MenuAutoConfiguration
kr.devslab.kit.autoconfigure.AuditAutoConfiguration
kr.devslab.kit.autoconfigure.AdminApiAutoConfiguration
kr.devslab.kit.autoconfigure.MetricsAutoConfiguration
Loading