Source Code Reading: PolicyEngineService — Unified Three-Model Permission Evaluation
PolicyEngineServiceImpl is the permission policy engine in Control Layer (Control Layer), built on Spring Boot 3.x + gRPC. It unifies RBAC, ABAC, and ReBAC evaluation through a single PolicyEvaluationService abstraction, supporting 8 condition operators (EQ/NE/GT/GE/LT/LE/IN/CONTAINS). The service exposes five major capabilities: policy CRUD, single/batch evaluation, query rewriting (row-level security + column masking), policy testing (dry-run), and access simulation. This article dissects the layered architecture, Cedar policy engine integration, evaluation chain execution, field-level query rewriting, and batch evaluation partial-failure semantics.
Source Code Reading: PolicyEngineService — Unified Three-Model Permission Evaluation
“Series: S9 Source Code Reading · Article 4 | Level: Advanced | Reading Time: 25 min
#TL;DR
PolicyEngineServiceImpl is the permission policy engine in Control Layer (Control Layer), built on Spring Boot 3.x + gRPC. It unifies RBAC, ABAC, and ReBAC evaluation through a single PolicyEvaluationService abstraction, supporting 8 condition operators (EQ/NE/GT/GE/LT/LE/IN/CONTAINS). The service exposes five major capabilities: policy CRUD, single/batch evaluation, query rewriting (row-level security + column masking), policy testing (dry-run), and access simulation. This article dissects the layered architecture, Cedar policy engine integration, evaluation chain execution, field-level query rewriting, and batch evaluation partial-failure semantics.
#Table of Contents
- Architecture and Three Collaborators
- gRPC Service Layer: PolicyEngineServiceImpl
- Policy CRUD: Create / Get / List / Update / Delete
- Evaluation Chain: EvaluatePolicy
- Cedar Policy Engine: CedarPolicyEngine
- Development Mode: SimplePolicyEngine Permit-All
- Batch Evaluation: Partial Failure Semantics
- Query Rewriting: Row-Level Security and Column Masking
- Policy Testing: Dry-Run Implementation
- Access Simulation: Step-by-Step Tracing
- Key Takeaways
#1. Architecture and Three Collaborators
PolicyEngineServiceImpl lives in Control Layer (Control Layer), following the Spring Boot gRPC service pattern:
control-Layer/src/main/java/com/onto/control/policy/
├── api/
│ ├── PolicyEngineServiceImpl.java # gRPC service implementation
│ └── PolicyProtoMapper.java # Proto <-> Domain conversion
├── domain/
│ ├── Policy.java # Policy domain model
│ ├── PolicyCondition.java # Condition definition
│ ├── PolicyRule.java # Rule definition
│ ├── PolicyDecision.java # Evaluation decision result
│ ├── PolicyStatus.java # Policy status enum
│ └── EvaluationContext.java # Evaluation context
├── engine/
│ ├── CedarPolicyEngine.java # Cedar SDK integration (production)
│ ├── SimplePolicyEngine.java # Simplified engine (dev mode)
│ ├── CedarMapper.java # Cedar type mapping
│ └── CedarPolicyConverter.java # Policy format conversion
├── service/
│ ├── PolicyStoreService.java # Policy storage interface
│ └── PolicyEvaluationService.java # Evaluation service interface
└── proto/
├── PolicyEngineServiceGrpc.java # gRPC generated code
└── PolicyEngineProto.java # Proto message definitions
Three collaborators:
| Collaborator | Responsibility | Injection |
|---|---|---|
PolicyStoreService | Policy CRUD persistence | Constructor |
PolicyEvaluationService | Evaluation chain execution | Constructor |
PolicyProtoMapper | Proto-Domain bidirectional conversion | Constructor |
@GrpcService
@Slf4j
@RequiredArgsConstructor
public class PolicyEngineServiceImpl
extends PolicyEngineServiceGrpc.PolicyEngineServiceImplBase {
private final PolicyStoreService policyStoreService;
private final PolicyEvaluationService policyEvaluationService;
private final PolicyProtoMapper mapper;
}
Design decision: Lombok @RequiredArgsConstructor auto-generates the constructor, eliminating manual @Inject. @Slf4j replaces manual Logger declaration, reducing boilerplate.
#2. gRPC Service Layer: PolicyEngineServiceImpl
PolicyEngineServiceImpl is a typical gRPC adapter layer exposing 8 RPC methods:
| Method | Purpose | Request Type | Response Type |
|---|---|---|---|
createPolicy | Create policy | CreatePolicyRequest | PolicyResponse |
getPolicy | Get policy | GetPolicyRequest | PolicyResponse |
listPolicies | List with filters | ListPoliciesRequest | ListPoliciesResponse |
updatePolicy | Update policy | UpdatePolicyRequest | PolicyResponse |
deletePolicy | Delete policy | DeletePolicyRequest | DeleteResponse |
evaluatePolicy | Single evaluation | EvaluateRequest | EvaluateResponse |
batchEvaluate | Batch evaluation | BatchEvaluateRequest | BatchEvaluateResponse |
rewriteQuery | Query rewriting | RewriteQueryRequest | RewriteQueryResponse |
Annotation difference: Unlike Data Layer's @GrpcService (Quarkus), Control Layer uses net.devh.boot.grpc.server.service.GrpcService — the Spring Boot adapter annotation from grpc-spring-boot-starter.
#3. Policy CRUD: Create / Get / List / Update / Delete
#3.1 Creating a Policy
@Override
public void createPolicy(CreatePolicyRequest request,
StreamObserver<PolicyResponse> responseObserver) {
try {
// 1. Input validation
if (!request.hasPolicy()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("Policy is required")
.asRuntimeException());
return;
}
// 2. Proto to Domain conversion
Policy policy = mapper.toDomain(request.getPolicy());
// 3. Extract creator from WorldContext
String userId = WorldContextHolder.getUserIdOrDefault("system");
policy.setCreatedBy(userId);
// 4. Idempotency check
if (policy.getPolicyId() != null
&& policyStoreService.existsById(policy.getPolicyId())) {
responseObserver.onError(Status.ALREADY_EXISTS
.withDescription("Policy already exists: " + policy.getPolicyId())
.asRuntimeException());
return;
}
// 5. Persist
Policy saved = policyStoreService.savePolicy(policy);
// 6. Respond
responseObserver.onNext(PolicyResponse.newBuilder()
.setPolicy(mapper.toProto(saved)).build());
responseObserver.onCompleted();
} catch (Exception e) { ... }
}
Key designs:
- Idempotency check: Checks
existsByIdbefore saving, preventing duplicate creation. This is more graceful than relying on database unique constraint exceptions — clients can clearly distinguish "already exists" from "internal error" - WorldContextHolder: Retrieves the current user ID from gRPC Interceptor via ThreadLocal, with a default value of
"system"for unauthenticated internal calls
#3.2 Immutable Field Protection on Update
// Preserve immutable fields
updated.setPolicyId(existing.getPolicyId());
updated.setCreatedAt(existing.getCreatedAt());
updated.setCreatedBy(existing.getCreatedBy());
updated.setVersion(existing.getVersion());
Design highlight: policyId, createdAt, createdBy, and version are immutable — even if the client sends new values for these fields in the update request, the server overwrites them with the originals. This is defensive programming at its best.
#3.3 Pagination in List Queries
int page = request.hasPage() ? request.getPage().getPage() : 0;
int size = request.hasPage() && request.getPage().getSize() > 0
? request.getPage().getSize() : 20;
int start = page * size;
int end = Math.min(start + size, policies.size());
List<Policy> pagedPolicies = start < policies.size()
? policies.subList(start, end) : List.of();
Pagination uses offset-based approach (page * size), defaulting to 20 items per page. Note the boundary protection on subList: when start >= policies.size(), it returns an empty list, preventing IndexOutOfBoundsException.
#3.4 Resource Type Mapping
private String mapResourceTypeToString(ResourceType type) {
return switch (type) {
case RESOURCE_TYPE_WORLD -> "World";
case RESOURCE_TYPE_ONTOLOGY_TYPE -> "OntologyType";
case RESOURCE_TYPE_ONTOLOGY_INSTANCE -> "OntologyInstance";
case RESOURCE_TYPE_FIELD -> "Field";
case RESOURCE_TYPE_ACTION -> "Action";
case RESOURCE_TYPE_PIPELINE -> "Pipeline";
default -> null;
};
}
coomia-dip supports permission control across 6 resource types, covering the complete granularity from data worlds (World) to individual fields (Field).
#4. Evaluation Chain: EvaluatePolicy
@Override
public void evaluatePolicy(EvaluateRequest request,
StreamObserver<EvaluateResponse> responseObserver) {
try {
// 1. Validate the triple: Subject + Resource + Action
if (!request.hasSubject() || !request.hasResource()
|| request.getAction().isBlank()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("Subject, resource, and action are required")
.asRuntimeException());
return;
}
// 2. Build evaluation context
EvaluationContext context = mapper.toEvaluationContext(request);
// 3. Execute evaluation chain
PolicyDecision decision = policyEvaluationService.evaluate(context);
// 4. Build response
EvaluateResponse response = mapper.toEvaluateResponse(decision,
decision.policyId() != null ? List.of(decision.policyId()) : List.of());
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) { ... }
}
Triple model: Every evaluation requires Subject (who), Resource (what), and Action (doing what) — this is the standard ABAC/PBAC model, directly aligned with Cedar's principal/resource/action triple.
#5. Cedar Policy Engine: CedarPolicyEngine
Cedar is AWS's open-source policy language and evaluation engine. coomia-dip uses it as the core policy engine in production:
@Service
@ConditionalOnProperty(name = "policy-engine.use-cedar",
havingValue = "true", matchIfMissing = false)
public class CedarPolicyEngine {
private final CedarMapper mapper;
private final CedarPolicyConverter converter;
private final AuthorizationEngine authorizationEngine;
public CedarPolicyEngine(CedarMapper mapper, CedarPolicyConverter converter) {
this.mapper = mapper;
this.converter = converter;
this.authorizationEngine = new BasicAuthorizationEngine();
}
}
#5.1 Five-Step Evaluation Flow
public PolicyDecision evaluate(EvaluationContext context, List<Policy> policies) {
// Step 1: Filter active policies
List<Policy> activePolicies = policies.stream()
.filter(Policy::isActive).collect(Collectors.toList());
// Step 2: Convert to Cedar AuthorizationRequest
AuthorizationRequest request = mapper.toAuthorizationRequest(context);
// Step 3: Convert policies to Cedar PolicySet
PolicySet policySet = converter.toPolicySet(activePolicies);
// Step 4: Build Cedar entity set
Set<Entity> entities = mapper.toEntities(context);
// Step 5: Invoke Cedar evaluation engine
AuthorizationResponse response =
authorizationEngine.isAuthorized(request, policySet, entities);
return mapper.toDecision(response, policyIds);
}
#5.2 Default Deny Strategy
if (policies == null || policies.isEmpty()) {
return PolicyDecision.defaultDeny();
}
Security default: When no matching policy is found, defaultDeny() is returned — this is the core principle of zero-trust security.
#6. Development Mode: SimplePolicyEngine Permit-All
@Service("cedarPolicyEngine")
@Profile({"dev", "default"})
public class SimplePolicyEngine {
public PolicyDecision evaluate(EvaluationContext context, List<Policy> policies) {
return PolicyDecision.permit(
"dev-mock-policy",
"Permitted by development mock policy engine",
List.of()
);
}
}
Dual Profile activation: @Profile({"dev", "default"}) means all requests are permitted in development mode and when no Profile is specified. This dramatically reduces development environment configuration complexity.
#7. Batch Evaluation: Partial Failure Semantics
@Override
public void batchEvaluate(BatchEvaluateRequest request,
StreamObserver<BatchEvaluateResponse> responseObserver) {
try {
List<EvaluateResponse> responses = new java.util.ArrayList<>();
for (EvaluateRequest evalRequest : request.getRequestsList()) {
try {
EvaluationContext context = mapper.toEvaluationContext(evalRequest);
PolicyDecision decision = policyEvaluationService.evaluate(context);
responses.add(mapper.toEvaluateResponse(decision, ...));
} catch (Exception e) {
// Individual failure does not block the batch
responses.add(EvaluateResponse.newBuilder()
.setAllowed(false)
.setDecision(PolicyEffect.POLICY_EFFECT_DENY)
.setReason("Evaluation error: " + e.getMessage())
.build());
}
}
responseObserver.onNext(BatchEvaluateResponse.newBuilder()
.addAllResponses(responses).build());
responseObserver.onCompleted();
} catch (Exception e) { ... }
}
Partial failure semantics: Similar to OntologyRuntimeService batch operations, individual evaluation failures return DENY + error reason without aborting the entire batch. This is essential for permission preflight checks where the frontend needs to know access rights for multiple resources in a single call.
#8. Query Rewriting: Row-Level Security and Column Masking
@Override
public void rewriteQuery(RewriteQueryRequest request,
StreamObserver<RewriteQueryResponse> responseObserver) {
try {
List<Policy> policies =
policyStoreService.getActivePolicies(request.getTargetSchema());
List<AppliedFilter> appliedFilters = new ArrayList<>();
for (Policy policy : policies) {
if (policy.getMetadata().containsKey("resourceType")) {
for (PolicyRule rule : policy.getRules()) {
for (PolicyCondition condition : rule.getConditions()) {
if (condition.getAttribute().startsWith("field.")) {
String fieldName = condition.getAttribute().substring(6);
appliedFilters.add(AppliedFilter.newBuilder()
.setField(fieldName)
.setFilterType("COLUMN_MASK")
.setFilterExpression(
condition.getValue().toString())
.setPolicyId(policy.getPolicyId())
.build());
}
}
}
}
}
responseObserver.onNext(RewriteQueryResponse.newBuilder()
.setRewrittenQuery(rewrittenQuery)
.addAllFilters(appliedFilters).build());
responseObserver.onCompleted();
} catch (Exception e) { ... }
}
Field-level filtering convention: When a policy condition's attribute starts with the field. prefix, it represents a field-level permission rule. For example, field.salary indicates a masking policy for the salary field.
The AppliedFilter structure allows Data Layer to apply precise column-level masking during query execution — for example, replacing the salary field with **** or NULL.
#9. Policy Testing: Dry-Run Implementation
@Override
public void testPolicy(TestPolicyRequest request,
StreamObserver<TestPolicyResponse> responseObserver) {
try {
Policy policy = mapper.toDomain(request.getPolicy());
List<TestResult> results = new ArrayList<>();
boolean allPassed = true;
for (TestCase testCase : request.getTestCasesList()) {
EvaluationContext context = EvaluationContext.builder()
.subject(new Subject(testCase.getSubject().getId(), ...))
.resource(new Resource(...))
.action(new Action(testCase.getAction(), ...))
.build();
PolicyDecision decision = policyEvaluationService.evaluate(context);
boolean passed = decision.isPermitted() == testCase.getExpectedAllowed();
results.add(TestResult.newBuilder()
.setTestCaseName(testCase.getName())
.setPassed(passed)
.setActualAllowed(decision.isPermitted())
.setExpectedAllowed(testCase.getExpectedAllowed())
.build());
}
responseObserver.onNext(TestPolicyResponse.newBuilder()
.setAllPassed(allPassed)
.addAllResults(results).build());
responseObserver.onCompleted();
} catch (Exception e) { ... }
}
Test-driven policy development: testPolicy allows policy authors to validate policy logic without deploying to production. Each TestCase includes an expected result (expectedAllowed), and the engine compares actual vs. expected to output a pass/fail report.
#10. Access Simulation: Step-by-Step Tracing
@Override
public void simulateAccess(SimulateAccessRequest request,
StreamObserver<SimulateAccessResponse> responseObserver) {
try {
List<Policy> policies = policyStoreService.getActivePolicies(resourceType);
List<PolicyEvaluationStep> steps = new ArrayList<>();
for (Policy policy : policies) {
PolicyDecision decision = policyEvaluationService.evaluate(context);
steps.add(PolicyEvaluationStep.newBuilder()
.setPolicyId(policy.getPolicyId())
.setPolicyName(policy.getName())
.setMatched(...)
.setEffect(mapper.toProtoEffect(decision.effect()))
.setReason(decision.reason())
.build());
// Short-circuit when verbose=false
if (!request.getVerbose() && decision.policyId() != null) {
break;
}
}
boolean allowed = steps.stream()
.anyMatch(s -> s.getEffect() == PolicyEffect.POLICY_EFFECT_ALLOW);
responseObserver.onNext(SimulateAccessResponse.newBuilder()
.setAllowed(allowed).addAllSteps(steps).build());
responseObserver.onCompleted();
} catch (Exception e) { ... }
}
Step-by-step tracing: The core value of SimulateAccess is the steps list — it records each policy's evaluation process (matched/unmatched, effect, reason), serving as a powerful permission debugging tool.
Verbose mode: When verbose=false, short-circuit evaluation stops at the first match for performance; verbose=true traverses all policies, providing the complete evaluation chain.
#11. Key Takeaways
- Three-model unification: The Subject/Resource/Action triple in
EvaluationContextabstracts RBAC (roles), ABAC (attributes), and ReBAC (relationships) into a single evaluation entry point - Cedar integration: Production uses AWS Cedar policy engine; development uses SimplePolicyEngine permit-all — seamlessly switched via
@Profile+@ConditionalOnProperty - Default deny: Returns
defaultDeny()when no policy matches, following zero-trust principles - Query rewriting: The
field.prefix convention enables field-level permission control, outputtingAppliedFilterfor Data Layer column masking - Batch partial failure: Individual evaluation exceptions do not block the entire batch request; failures return DENY + error reason
- Policy testing:
testPolicyprovides dry-run capability for test-driven policy development - Access simulation: The
stepslist insimulateAccessis the core permission debugging tool, supporting verbose/non-verbose modes
#Next Article
S9-05: QueryFederationService — Multi-Engine Query Routing. We will dive into Data Layer's query federation service to see how it routes OQL queries to Doris or DuckDB based on query plans, along with cost estimation and result merging.
Tags: #coomia-dip #source-code-reading #control-Layer #policy-engine #rbac #abac #rebac #cedar #query-rewriting #access-control