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 @@ -13,5 +13,29 @@ default String description() {
return null;
}

/**
* Coarse-grained evaluation entry point — returns just the
* {@link PolicyDecision} enum.
*
* <p>Every policy must implement this method. Policies that want to
* surface a reason or the rules they matched should ALSO override
* {@link #evaluateDetailed(PolicyContext)}; the default implementation
* of that method wraps the result of {@code evaluate} with an empty
* reason / matched-rule list.
*/
PolicyDecision evaluate(PolicyContext context);

/**
* Detailed evaluation entry point — returns the decision wrapped with
* an optional reason + matched-rule list.
*
* <p>Default implementation delegates to {@link #evaluate(PolicyContext)}
* so legacy policies that only override {@code evaluate} keep producing
* a valid {@link PolicyEvaluation} (with null reason + empty matched
* rules) for the admin UI's policy tester. Override this method
* directly when you have detail worth surfacing.
*/
default PolicyEvaluation evaluateDetailed(PolicyContext context) {
return PolicyEvaluation.of(evaluate(context));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package kr.devslab.kit.access.policy;

import java.util.List;
import java.util.Objects;

/**
* Richer outcome of a {@link Policy} evaluation.
*
* <p>Wraps the {@link PolicyDecision} enum with two debugging signals the
* admin UI surfaces in its policy tester:
*
* <ul>
* <li>{@code reason} — short human-readable explanation of why the
* policy chose what it chose ({@code null} if the policy didn't
* supply one).</li>
* <li>{@code matchedRules} — the named rules / conditions inside the
* policy that fired against this {@link PolicyContext}. Empty when
* the policy is opaque or when the decision is
* {@link PolicyDecision#NOT_APPLICABLE}.</li>
* </ul>
*
* <p>Constructed via the factory methods so call-sites stay readable
* and the {@code matchedRules} list is always non-null + immutable.
*/
public record PolicyEvaluation(
PolicyDecision decision,
String reason,
List<String> matchedRules
) {

public PolicyEvaluation {
Objects.requireNonNull(decision, "PolicyEvaluation decision must not be null");
matchedRules = matchedRules == null ? List.of() : List.copyOf(matchedRules);
}

public static PolicyEvaluation permit(String reason, List<String> matchedRules) {
return new PolicyEvaluation(PolicyDecision.PERMIT, reason, matchedRules);
}

public static PolicyEvaluation deny(String reason, List<String> matchedRules) {
return new PolicyEvaluation(PolicyDecision.DENY, reason, matchedRules);
}

public static PolicyEvaluation notApplicable(String reason) {
return new PolicyEvaluation(PolicyDecision.NOT_APPLICABLE, reason, List.of());
}

/**
* Convenience for policies that only have a coarse-grained decision
* to share. Use the named factory methods when you can — they make
* the intent obvious at the call-site.
*/
public static PolicyEvaluation of(PolicyDecision decision) {
return new PolicyEvaluation(decision, null, List.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@
public interface PolicyEvaluator {

PolicyDecision evaluate(String policyName, PolicyContext context);

/**
* Detailed evaluation entry point — returns the decision wrapped with
* an optional reason + matched-rule list, as supplied by the underlying
* {@link Policy#evaluateDetailed(PolicyContext)}.
*
* <p>Default delegates to {@link #evaluate(String, PolicyContext)} so
* legacy evaluators keep working unchanged. Override directly when the
* evaluator can produce a richer answer than just wrapping the enum.
*/
default PolicyEvaluation evaluateDetailed(String policyName, PolicyContext context) {
return PolicyEvaluation.of(evaluate(policyName, context));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import kr.devslab.kit.access.policy.Policy;
import kr.devslab.kit.access.policy.PolicyContext;
import kr.devslab.kit.access.policy.PolicyDecision;
import kr.devslab.kit.access.policy.PolicyEvaluation;
import kr.devslab.kit.access.policy.PolicyEvaluator;

public class DefaultPolicyEvaluator implements PolicyEvaluator {
Expand Down Expand Up @@ -36,6 +37,18 @@ public PolicyDecision evaluate(String policyName, PolicyContext context) {
return policy.evaluate(context);
}

@Override
public PolicyEvaluation evaluateDetailed(String policyName, PolicyContext context) {
if (policyName == null) {
return PolicyEvaluation.notApplicable("policyName was null");
}
Policy policy = policiesByName.get(policyName);
if (policy == null) {
return PolicyEvaluation.notApplicable("no policy registered for name: " + policyName);
}
return policy.evaluateDetailed(context);
}

public Set<String> registeredNames() {
return policiesByName.keySet();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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.PolicyEvaluation;
import kr.devslab.kit.access.policy.PolicyEvaluator;
import kr.devslab.kit.admin.AdminApiPaths;
import kr.devslab.kit.core.id.TenantId;
Expand Down Expand Up @@ -67,8 +68,8 @@ public PolicyTestResponse test(@Valid @RequestBody PolicyTestRequest req) {
: Optional.ofNullable(req.resource().attributes()).orElseGet(Map::of))
.environmentAttributes(Optional.ofNullable(req.environment()).orElseGet(Map::of))
.build();
PolicyDecision decision = evaluator.evaluate(req.policyName(), ctx);
return new PolicyTestResponse(decision, null, List.of());
PolicyEvaluation result = evaluator.evaluateDetailed(req.policyName(), ctx);
return new PolicyTestResponse(result.decision(), result.reason(), result.matchedRules());
}

public record PolicyDescriptor(String name, String description) {
Expand Down Expand Up @@ -100,12 +101,11 @@ public record Resource(
/**
* 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.
* <p>{@code reason} and {@code matchedRules} carry whatever the underlying
* policy chose to expose via {@link PolicyEvaluation#reason()} /
* {@link PolicyEvaluation#matchedRules()}; policies that only implement
* the coarse {@code evaluate} entry point land here with {@code reason}
* = null and an empty {@code matchedRules}.
*/
public record PolicyTestResponse(
PolicyDecision effect,
Expand Down
Loading