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 @@ -4,5 +4,14 @@ public interface Policy {

String name();

/**
* Human-readable explanation of what this policy does, surfaced in the
* admin UI's policy list. Implementations can leave this null to fall
* back to the policy name.
*/
default String description() {
return null;
}

PolicyDecision evaluate(PolicyContext context);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package kr.devslab.kit.access.policy;

/**
* Outcome of evaluating a {@link Policy}.
*
* <p>Wire names follow the XACML 3.0 vocabulary (PERMIT / DENY / NOT_APPLICABLE)
* so the admin UI's policy tester and downstream auditors see a familiar shape.
*/
public enum PolicyDecision {
ALLOW,
PERMIT,
DENY,
NOT_APPLICABLE
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,12 @@ public PolicyDecision evaluate(String policyName, PolicyContext context) {
public Set<String> registeredNames() {
return policiesByName.keySet();
}

/**
* Returns the registered policies (used by {@code PolicyAdminController}
* to surface name + description in the admin UI's policy list).
*/
public java.util.Collection<Policy> registeredPolicies() {
return policiesByName.values();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class DefaultPolicyEvaluatorTest {

private final Policy alwaysAllow = new Policy() {
public String name() { return "always-allow"; }
public PolicyDecision evaluate(PolicyContext ctx) { return PolicyDecision.ALLOW; }
public PolicyDecision evaluate(PolicyContext ctx) { return PolicyDecision.PERMIT; }
};

private final Policy alwaysDeny = new Policy() {
Expand All @@ -22,11 +22,11 @@ class DefaultPolicyEvaluatorTest {
};

@Test
void returnsAllowFromMatchingPolicy() {
void returnsPermitFromMatchingPolicy() {
var evaluator = new DefaultPolicyEvaluator(List.of(alwaysAllow));

assertThat(evaluator.evaluate("always-allow", PolicyContext.builder().build()))
.isEqualTo(PolicyDecision.ALLOW);
.isEqualTo(PolicyDecision.PERMIT);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package kr.devslab.kit.admin.policy;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import kr.devslab.kit.access.core.service.DefaultPolicyEvaluator;
import kr.devslab.kit.access.policy.PolicyContext;
Expand All @@ -10,12 +14,24 @@
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;

/**
* Read-only admin surface for the ABAC {@link PolicyEvaluator}.
*
* <p>Two endpoints:
*
* <ul>
* <li>{@code GET /admin/api/v1/policies} — list registered policies
* (name + optional description) for the admin UI's policy table.</li>
* <li>{@code POST /admin/api/v1/policies/test} — dry-run a
* {@code (subject, action, resource)} tuple through the evaluator
* and report the resulting effect.</li>
* </ul>
*/
@RestController
@RequestMapping(AdminApiPaths.BASE + "/policies")
public class PolicyAdminController {
Expand All @@ -27,26 +43,74 @@ public PolicyAdminController(PolicyEvaluator evaluator) {
}

@GetMapping
public List<String> list() {
public List<PolicyDescriptor> list() {
if (evaluator instanceof DefaultPolicyEvaluator def) {
return def.registeredNames().stream().sorted().toList();
return def.registeredPolicies().stream()
.map(p -> new PolicyDescriptor(p.name(), p.description()))
.sorted(Comparator.comparing(PolicyDescriptor::name))
.toList();
}
return List.of();
}

@PostMapping("/{name}/test")
public PolicyTestResponse test(@PathVariable String name, @RequestBody PolicyTestRequest req) {
@PostMapping("/test")
public PolicyTestResponse test(@Valid @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))
.user(req.subject() != null && req.subject().userId() != null
? UserId.of(req.subject().userId()) : null)
.tenant(req.subject() != null && req.subject().tenantId() != null
? TenantId.of(req.subject().tenantId()) : null)
.resource(
req.resource() == null ? null : req.resource().type(),
req.resource() == null ? null : req.resource().id())
.resourceAttributes(req.resource() == null ? Map.of()
: Optional.ofNullable(req.resource().attributes()).orElseGet(Map::of))
.environmentAttributes(Optional.ofNullable(req.environment()).orElseGet(Map::of))
.build();
PolicyDecision decision = evaluator.evaluate(name, ctx);
return new PolicyTestResponse(name, decision);
PolicyDecision decision = evaluator.evaluate(req.policyName(), ctx);
return new PolicyTestResponse(decision, null, List.of());
}

public record PolicyTestResponse(String name, PolicyDecision decision) {
public record PolicyDescriptor(String name, String description) {
}

public record PolicyTestRequest(
@NotBlank String policyName,
Subject subject,
String action,
Resource resource,
Map<String, Object> environment
) {
}

public record Subject(
java.util.UUID userId,
String tenantId,
Map<String, Object> attributes
) {
}

public record Resource(
String type,
String id,
Map<String, Object> attributes
) {
}

/**
* Wire shape matches the admin UI's {@code PolicyTestResponse} interface.
*
* <p>{@code reason} and {@code matchedRules} are placeholders today —
* the current {@link Policy} SPI returns only a {@link PolicyDecision}
* enum, so we can't surface why a policy chose what it chose. Promoting
* the SPI to return a richer decision object (with reason + matched
* rules) is queued as a follow-up so this PR stays focused on the wire
* contract.
*/
public record PolicyTestResponse(
PolicyDecision effect,
String reason,
List<String> matchedRules
) {
}
}
Loading