Deepen PracticeOrdered learning track

Policy as Code: When Authorization Leaves Application Code

Learn Java Authorization Pattern - Part 026

Policy as Code for Java authorization systems: when to externalize policy, PDP topology, policy lifecycle, decision contracts, testing, rollout, audit, governance, and migration from embedded authorization.

11 min read2165 words
PrevNext
Lesson 2640 lesson track23–33 Deepen Practice
#java#authorization#policy-as-code#opa+7 more

Part 026 — Policy as Code: When Authorization Leaves Application Code

At small scale, authorization starts as Java code.

if (subject.hasPermission("case.close") && caseFile.isAssignedTo(subject.userId())) {
    return permit();
}

At enterprise scale, the same logic becomes harder to manage:

- multiple services need the same rule
- policy must change faster than application release cycles
- audit teams need visibility into who can do what
- compliance wants reviewable policy diffs
- product teams need fine-grained entitlements
- platform teams need consistent enforcement across Java, Node, Go, data, and infrastructure
- security needs deny-by-default and provable coverage

Policy as Code is the move from invisible authorization scattered in application logic to explicit, versioned, testable, reviewable, deployable policy artifacts.

But this move is not free.

Externalized policy can improve governance and consistency. It can also create latency, complexity, semantic drift, operational coupling, and confusing failure modes.

This part explains when authorization should leave Java application code, when it should not, and how to design the boundary without losing domain clarity.


1. What Policy as Code Means

Policy as Code means policy is represented as machine-readable artifacts that can be:

- versioned
- reviewed
- tested
- deployed
- rolled back
- audited
- diffed
- promoted across environments
- evaluated consistently by a policy engine

Examples:

package authz.case

default allow := false

allow if {
  input.action == "case.close"
  input.subject.tenant_id == input.resource.tenant_id
  input.subject.permissions[_] == "case.close"
  input.resource.status == "IN_REVIEW"
  input.resource.assignee_id == input.subject.id
}

Cedar-style example:

permit(
  principal,
  action == CaseAction::"close",
  resource
)
when {
  principal.tenant == resource.tenant &&
  principal has permission &&
  principal.permission.contains("case.close") &&
  resource.status == "IN_REVIEW" &&
  resource.assignee == principal
};

The exact language matters less than the architectural shift:

Policy becomes an artifact, not just imperative application code.

2. What Policy as Code Is Not

Policy as Code is not:

- replacing application authorization entirely
- removing the need for object-level authorization
- letting security team write all domain rules alone
- passing entire domain entities to a policy engine
- moving every if-statement into Rego/Cedar
- adding a central PDP and assuming the system is secure
- a reason to skip tests

The Java service still needs:

- enforcement point
- safe request construction
- resource loading/scoping
- attribute projection
- error handling
- audit emission
- fallback behavior
- policy version awareness

Policy engine decides.

Application enforces.

That separation is non-negotiable.


3. Embedded Authorization vs Policy as Code

DimensionEmbedded Java AuthorizationPolicy as Code
Change speedtied to service releasecan be policy release
Governancecode review by app teampolicy review by app/security/compliance
Cross-service consistencymanual duplication riskcentral/shared policy possible
Type safetystrong Java typesdepends on schema/validation
Latencylocal calllocal engine, sidecar, or remote PDP
Observabilitymust be builtoften decision-log friendly
Domain clarityclose to codecan drift from domain if poorly modeled
Failure modeapp bugapp + policy + PDP + distribution bugs
Testingnormal Java testspolicy tests + integration tests
Ownershipengineering teamshared engineering/security/product

Policy as Code is valuable when governance and consistency outweigh added complexity.


4. When to Keep Authorization in Java

Keep authorization embedded when:

- system is small or single-service
- policies change rarely
- domain rules are highly coupled to aggregate behavior
- policy is simple and easy to test in Java
- latency budget is extremely tight
- security/compliance does not require central policy governance
- team lacks operational maturity for external PDP

Example:

public AuthorizationDecision canEditOwnDraft(SubjectContext subject, Draft draft) {
    if (!subject.userId().equals(draft.createdBy())) {
        return deny("NOT_OWNER");
    }
    if (draft.status() != DraftStatus.DRAFT) {
        return deny("NOT_EDITABLE_STATE");
    }
    return permit();
}

This may be perfectly fine.

Do not externalize policy because it sounds modern.

Externalize because it solves a real governance, consistency, or scale problem.


5. When Authorization Should Leave Application Code

Externalize policy when several of these are true:

1. Multiple services need the same policy semantics.
2. Policy changes are more frequent than service deployments.
3. Non-application teams must review or approve policy changes.
4. You need consistent decision logging across services.
5. You need dry-run/shadow evaluation.
6. Authorization is fine-grained and cross-cutting.
7. Tenant-specific or customer-specific policy is required.
8. You need formal policy diff and approval workflow.
9. You need to combine RBAC, ABAC, and relationship data.
10. You need regulatory defensibility: who changed policy, when, why, and what effect.

Typical examples:

- enterprise SaaS authorization
- regulatory case management
- document sharing
- healthcare record access
- financial approval flows
- cloud control plane authorization
- internal platform entitlement systems
- multi-tenant admin consoles

6. Externalized Authorization Architecture

The canonical model:

In code, the Java service owns PEP behavior:

AuthorizationDecision decision = authorizationPort.decide(request);

auditSink.record(decision.toAuditEvent());

decision.requirePermit();

The PDP owns policy evaluation.

The PAP owns policy administration and lifecycle.

The PIP owns attribute or relationship lookup.


7. PDP Deployment Topologies

Topology A — In-Process Policy Engine

Pros:

- low latency
- fewer network failure modes
- easier local testing
- service autonomy

Cons:

- policy bundle distribution required
- memory footprint per service
- language/runtime support may vary
- central decision logging requires extra work

Use when latency and availability matter more than centralized runtime control.

Topology B — Sidecar PDP

Pros:

- low-latency local network call
- policy engine isolated from app process
- common in Kubernetes/service mesh environments
- easy to update policy independently

Cons:

- sidecar lifecycle management
- local network still can fail
- version skew between app and sidecar
- more deployment complexity

OPA sidecar deployment often fits this model.

Topology C — Central Remote PDP

Pros:

- central governance
- central decision logging
- consistent runtime behavior
- easier policy administration

Cons:

- network latency
- shared dependency blast radius
- availability engineering required
- requires strict API compatibility

Use for enterprise control planes, SaaS administration, and systems where central governance is dominant.

Topology D — Hybrid

Common design:

- local PDP for low-latency common decisions
- remote PDP for admin/governed decisions
- cached policy bundle
- central audit pipeline
- fail-closed for sensitive actions

8. Decision Contract First

The most important artifact is not the policy language.

It is the decision contract.

{
  "subject": {
    "id": "user-123",
    "type": "USER",
    "tenantId": "tenant-a",
    "permissions": ["case.close"],
    "groups": ["unit:enforcement-west"],
    "attributes": {
      "clearance": "CONFIDENTIAL"
    }
  },
  "action": "case.close",
  "resource": {
    "type": "case_file",
    "id": "case-456",
    "tenantId": "tenant-a",
    "attributes": {
      "status": "IN_REVIEW",
      "classification": "CONFIDENTIAL",
      "assigneeId": "user-123",
      "legalHold": false
    }
  },
  "environment": {
    "time": "2026-07-03T10:00:00Z",
    "channel": "HTTP",
    "ipRisk": "LOW"
  },
  "metadata": {
    "requestId": "req-789",
    "service": "case-service",
    "schemaVersion": "authz-request-v1"
  }
}

If this contract is sloppy, every policy language will produce sloppy authorization.


9. Java Contract Types

Use typed builders to avoid accidental malformed decisions.

public record PolicyDecisionRequest(
    PolicySubject subject,
    String action,
    PolicyResource resource,
    PolicyEnvironment environment,
    PolicyRequestMetadata metadata
) {}

public record PolicySubject(
    String id,
    String type,
    String tenantId,
    Set<String> permissions,
    Set<String> groups,
    Map<String, Object> attributes
) {}

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

public record PolicyEnvironment(
    Instant time,
    String channel,
    Map<String, Object> risk
) {}

Decision response:

public record PolicyDecisionResponse(
    Decision decision,
    List<String> reasonCodes,
    List<Obligation> obligations,
    List<Advice> advice,
    String policyVersion,
    String decisionId,
    CacheDirective cacheDirective
) {
    public boolean permitted() {
        return decision == Decision.PERMIT;
    }
}

Do not return only boolean.

Boolean decisions cannot support audit, debugging, policy diffing, obligations, or safe caching.


10. Policy Schema Is a Security Boundary

Without schema, policy authors can accidentally reference missing attributes.

Example bug:

allow if input.resource.classification == "PUBLIC"

But actual input uses:

{"resource":{"attributes":{"classification":"PUBLIC"}}}

The policy condition may silently fail or behave unexpectedly depending on language semantics.

Define schema:

subject.id: string required
subject.tenantId: string required
subject.permissions: array[string] required
resource.type: string required
resource.id: string required
resource.tenantId: string required
resource.attributes.status: enum required for case_file
resource.attributes.classification: enum required for case_file
action: enum required
environment.time: timestamp required

Schema gives you:

- validation before decision
- safer policy authoring
- compatibility checks
- migration path
- contract tests between app and PDP

11. Policy as Code Lifecycle

Policy must move through a controlled lifecycle:

Treat policies like production code.

Even better: treat them like database migrations plus production code.

Because changing a policy changes who can do what.


12. Policy Repository Structure

A practical repository layout:

policy/
  README.md
  schemas/
    authz-request-v1.schema.json
    case-resource-v1.schema.json
  policies/
    common/
      deny-default.rego
      tenant-boundary.rego
    case/
      close-case.rego
      assign-case.rego
      export-case.rego
    evidence/
      upload-evidence.rego
      seal-evidence.rego
  tests/
    case/
      close-case_test.rego
      assign-case_test.rego
  fixtures/
    subjects/
      investigator.json
      supervisor.json
    resources/
      case-in-review.json
      sealed-case.json
  bundles/
  decisions/
    golden/
      close-case-matrix.json

For Cedar:

policy/
  schema/
    case.cedarschema
  policies/
    case-close.cedar
    case-export.cedar
    evidence-seal.cedar
  tests/
    case-close-tests.json
  fixtures/
    entities/
      investigator.json
      case-file.json

The exact format depends on the engine.

The lifecycle discipline is the same.


13. Policy Ownership Model

Policy ownership is usually the hardest part.

A mature ownership model separates roles:

RoleOwns
Application teamenforcement points, request contract, domain attributes
Security teampolicy review, deny-by-default, sensitive action controls
Product/domain teambusiness meaning of permissions and roles
Compliance/legalregulatory constraints and audit needs
Platform teamPDP availability, policy distribution, observability
SRElatency, fallback, incident response

Bad ownership model:

Security owns policy, application team owns code, nobody owns semantics.

Better:

Domain team defines meaning.
Security reviews risk.
Application team implements enforcement.
Platform team operates PDP.
Compliance reviews audit evidence.

14. Policy Naming Discipline

Policy names should be domain-specific.

Bad:

admin_policy.rego
case_rules.rego
allow_users.rego

Better:

case.closure.request
case.closure.approve
case.assignment.change
case.evidence.upload
case.evidence.seal
case.export.full
case.export.redacted

The action catalog should be shared between Java and policy.

public enum Action {
    CASE_CLOSURE_REQUEST("case.closure.request"),
    CASE_CLOSURE_APPROVE("case.closure.approve"),
    CASE_ASSIGNMENT_CHANGE("case.assignment.change"),
    CASE_EVIDENCE_UPLOAD("case.evidence.upload"),
    CASE_EVIDENCE_SEAL("case.evidence.seal"),
    CASE_EXPORT_FULL("case.export.full"),
    CASE_EXPORT_REDACTED("case.export.redacted");

    private final String policyName;

    Action(String policyName) {
        this.policyName = policyName;
    }

    public String policyName() {
        return policyName;
    }
}

Policy drift often begins with bad naming.


15. Do Not Externalize Domain Invariants Blindly

Some rules belong in the aggregate:

- closed case cannot be edited
- closure requester cannot approve own closure
- sealed evidence cannot be modified
- legal hold prevents deletion
- invoice total cannot be negative

These are not just authorization policies.

They are domain validity rules.

Even if policy denies most bad actions, aggregate invariants should still protect impossible states.

public void sealEvidence(ActorId actor, Instant now) {
    if (status == EvidenceStatus.SEALED) {
        throw new InvalidEvidenceState("Evidence is already sealed");
    }
    if (hash == null) {
        throw new InvalidEvidenceState("Evidence hash is required before sealing");
    }
    this.status = EvidenceStatus.SEALED;
    this.sealedBy = actor;
    this.sealedAt = now;
}

Policy says who may seal.

Domain says what sealing means and when the object can be sealed.


16. OPA-Style Java Adapter Shape

Later parts will go deep into OPA. Here we focus on boundary shape.

public final class OpaAuthorizationAdapter implements AuthorizationPort {
    private final HttpClient httpClient;
    private final URI decisionEndpoint;
    private final ObjectMapper mapper;
    private final Duration timeout;

    @Override
    public AuthorizationDecision decide(AuthorizationRequest request) {
        PolicyDecisionRequest input = PolicyDecisionRequestMapper.from(request);

        try {
            HttpRequest httpRequest = HttpRequest.newBuilder(decisionEndpoint)
                .timeout(timeout)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(Map.of("input", input))))
                .build();

            HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());

            if (response.statusCode() != 200) {
                return AuthorizationDecision.indeterminate("PDP_HTTP_" + response.statusCode());
            }

            return OpaDecisionMapper.fromJson(response.body());
        } catch (IOException | InterruptedException e) {
            Thread.currentThread().interrupt();
            return AuthorizationDecision.indeterminate("PDP_UNAVAILABLE");
        }
    }
}

The adapter should not decide fail-open by itself.

It should return INDETERMINATE.

The enforcement policy decides how to handle indeterminate outcomes per action sensitivity.


17. Fail-Closed, Fail-Open, and Degraded Authorization

Never say “PDP failure means deny” without thinking.

Never say “PDP failure means allow” either.

Classify actions:

Action ClassExamplePDP Failure Behavior
Public readread public status pageallow through public path
Low-risk self readread own profilemaybe allow from local cache
Normal business writeupdate case notefail closed or use fresh cached permit
Sensitive writeclose case, approve paymentfail closed
Bulk/exportexport case datafail closed
Break-glassemergency accessseparate local emergency flow with audit
System maintenancerotate internal keyfail closed or operator-approved fallback

Represent this explicitly:

public enum FailureMode {
    FAIL_CLOSED,
    ALLOW_IF_FRESH_CACHED_PERMIT,
    LOCAL_EMERGENCY_POLICY,
    PUBLIC_BYPASS_ONLY
}

And bind it to action metadata:

public record ActionMetadata(
    Action action,
    Sensitivity sensitivity,
    FailureMode failureMode,
    Duration maxCachedDecisionAge
) {}

18. Decision Cache Design

Policy decisions are tempting to cache.

Cache carefully.

Bad cache key:

userId + action

Better cache key:

subjectId
subjectVersion
tenantId
action
resourceType
resourceId
resourceVersion
policyVersion
contextHash

Example:

public record DecisionCacheKey(
    String subjectId,
    long subjectAuthzVersion,
    String tenantId,
    String action,
    String resourceType,
    String resourceId,
    long resourceAuthzVersion,
    String policyVersion,
    String contextHash
) {}

Do not cache decisions involving:

- one-time approvals
- rapidly changing risk signals
- emergency access
- legal hold overrides
- very sensitive exports
- time-window decisions unless expiration matches window

19. Shadow Evaluation

Shadow evaluation compares old and new policy without enforcing the new result.

AuthorizationDecision primary = primaryPolicy.decide(request);
AuthorizationDecision candidate = candidatePolicy.decide(request);

if (!primary.samePermitDeny(candidate)) {
    shadowDiffSink.record(new PolicyDiff(
        request.metadata().requestId(),
        request.action(),
        request.resource(),
        primary.summary(),
        candidate.summary()
    ));
}

return primary;

Use shadow mode for:

- migration from Java policy to OPA/Cedar
- policy refactor
- role model cleanup
- tenant-specific policy rollout
- tightening policy without surprise outage

Shadow logs should answer:

Which requests would change from permit to deny?
Which requests would change from deny to permit?
Which tenants/users/actions are affected?
Is the change expected?

20. Policy Diff Is Not Text Diff

A text diff can show what changed.

It cannot tell you the effect.

Policy semantic diff asks:

For this corpus of authorization requests, did decisions change?

Golden decision matrix:

[
  {
    "name": "assigned investigator may close in-review case",
    "input": "fixtures/close/assigned-investigator.json",
    "expected": "PERMIT"
  },
  {
    "name": "investigator from other tenant denied",
    "input": "fixtures/close/other-tenant.json",
    "expected": "DENY"
  },
  {
    "name": "requester cannot approve own closure",
    "input": "fixtures/close/maker-checker-violation.json",
    "expected": "DENY"
  }
]

Policy promotion should require semantic diff approval for sensitive actions.


21. Attribute Ownership and Freshness

Policy correctness depends on attribute correctness.

Attribute questions:

Who owns this attribute?
Where is it sourced from?
How often does it change?
Can it be trusted from token claims?
Does it require database lookup?
Can it be cached?
What is its freshness budget?
What happens if it is missing?

Example:

AttributeOwnerSourceFreshness BudgetTrust Token?
subject.idIAMtokentoken lifetimeyes
subject.tenantIdIAM/app membershiptoken + membership DBminutesmaybe
subject.permissionsauthorization serviceDB/cacheseconds-minutesrisky if mutable
case.statuscase servicedatabasetransaction freshno
case.classificationcase servicedatabasetransaction freshno
current risk scorerisk serviceAPI/cachesecondsno
legal holdlegal/compliance servicedatabase/APIimmediateno

Never treat every claim in a JWT as current authorization truth.


22. PIP Design: Push vs Pull Attributes

Policy Information Point can work two ways.

Push Model

Java application gathers attributes and sends them to PDP.

Pros:

- app knows domain data
- transactionally fresh resource state
- PDP simpler
- fewer PDP integrations

Cons:

- services may build inconsistent inputs
- more data passed over boundary
- policy cannot query extra data unless included

Pull Model

PDP queries attributes from PIPs.

Pros:

- central attribute lookup
- app sends minimal request
- consistent policy-side enrichment

Cons:

- PDP becomes integration-heavy
- latency increases
- harder transaction consistency
- ownership boundaries blur

Most Java domain systems start with push model for resource attributes and pull model for shared relationships/groups.


23. Tenant-Specific Policy

Tenant-specific policy is powerful and dangerous.

Use it only when the product requires it.

Patterns:

- tenant configuration controls policy parameters
- tenant can assign roles but not write arbitrary policy
- tenant can enable stricter constraints
- platform owns base deny rules
- customer policy runs inside bounded schema and sandbox

Avoid letting tenants write arbitrary policy that can weaken platform invariants.

Base policy:

deny if tenant mismatch
forbid if legal hold deletion
forbid if action requires platform-only privilege

Tenant policy may refine:

require supervisor approval for exports above threshold
restrict access by business hours
require local department membership for sensitive cases

Tenant policy should not override platform forbids.


24. Permit and Forbid Semantics

Policy systems differ in how they combine allow/deny.

Be explicit:

default deny
explicit permit grants access
explicit forbid overrides permit
indeterminate does not permit

Example decision merge:

public AuthorizationDecision combine(List<AuthorizationDecision> decisions) {
    if (decisions.stream().anyMatch(AuthorizationDecision::isForbid)) {
        return AuthorizationDecision.deny("EXPLICIT_FORBID");
    }
    if (decisions.stream().anyMatch(AuthorizationDecision::isIndeterminate)) {
        return AuthorizationDecision.indeterminate("POLICY_INDETERMINATE");
    }
    if (decisions.stream().anyMatch(AuthorizationDecision::isPermit)) {
        return AuthorizationDecision.permit();
    }
    return AuthorizationDecision.deny("DEFAULT_DENY");
}

Do not let an allow from one policy accidentally override a deny from another.


25. Obligations and Advice

A policy decision may include obligations:

{
  "decision": "PERMIT",
  "obligations": [
    {"type":"REDACT_FIELDS", "fields":["ssn", "medicalHistory"]},
    {"type":"REQUIRE_AUDIT", "level":"SENSITIVE"}
  ],
  "advice": [
    {"type":"DISPLAY_WARNING", "message":"Sensitive case access is monitored"}
  ]
}

Obligations must be enforced by the PEP.

for (Obligation obligation : decision.obligations()) {
    obligationHandlerRegistry.handle(obligation, responseContext);
}

If the PEP cannot satisfy an obligation, the safe result is deny.

if (!obligationHandlerRegistry.canSatisfyAll(decision.obligations())) {
    throw new AccessDeniedException("Unsatisfied authorization obligation");
}

Advice is optional guidance.

Obligation is mandatory.


26. Policy Logging and Audit

Decision logs should include:

- decision id
- request id / correlation id
- subject id/type/tenant
- action
- resource type/id/tenant
- decision: permit/deny/forbid/indeterminate
- reason codes
- policy version
- policy bundle version
- input schema version
- PDP instance/version
- latency
- obligations
- enforcement outcome
- timestamp

Do not log sensitive resource attributes unless necessary.

Use redaction:

public final class DecisionAuditSanitizer {
    public Map<String, Object> sanitize(PolicyDecisionRequest request) {
        Map<String, Object> safe = new LinkedHashMap<>();
        safe.put("subjectId", request.subject().id());
        safe.put("tenantId", request.subject().tenantId());
        safe.put("action", request.action());
        safe.put("resourceType", request.resource().type());
        safe.put("resourceId", request.resource().id());
        safe.put("resourceTenantId", request.resource().tenantId());
        safe.put("classification", request.resource().attributes().get("classification"));
        return safe;
    }
}

Audit should prove the decision without leaking the protected data.


27. Migration from Java Checks to Policy as Code

Migration path:

Do not rewrite everything at once.

First, introduce a single Java AuthorizationPort.

public final class ExistingJavaAuthorizationAdapter implements AuthorizationPort {
    @Override
    public AuthorizationDecision decide(AuthorizationRequest request) {
        return switch (request.action()) {
            case CASE_CLOSE -> closeCasePolicy.evaluate(request);
            case CASE_ASSIGN -> assignCasePolicy.evaluate(request);
            case CASE_EXPORT_FULL -> exportPolicy.evaluate(request);
            default -> AuthorizationDecision.deny("UNKNOWN_ACTION");
        };
    }
}

Then add shadow external policy:

public final class ShadowingAuthorizationPort implements AuthorizationPort {
    private final AuthorizationPort primary;
    private final AuthorizationPort shadow;
    private final PolicyDiffSink diffSink;

    @Override
    public AuthorizationDecision decide(AuthorizationRequest request) {
        AuthorizationDecision primaryDecision = primary.decide(request);
        AuthorizationDecision shadowDecision = shadow.decide(request);

        if (!primaryDecision.samePermitDeny(shadowDecision)) {
            diffSink.record(request, primaryDecision, shadowDecision);
        }

        return primaryDecision;
    }
}

Only enforce external policy after diffs are understood.


28. Policy Release Safety

Policy release needs guardrails:

- test coverage for every sensitive action
- deny-by-default tests
- cross-tenant negative tests
- maker-checker negative tests
- high-sensitivity resource tests
- old-vs-new semantic diff
- emergency rollback
- decision log sampling
- production canary by tenant/action
- dashboards for permit/deny rate changes

Dashboard metrics:

authz.decisions.total{decision,action,tenant}
authz.denies.total{reason_code,action}
authz.indeterminate.total{reason_code,pdp}
authz.latency.p95{pdp,action}
authz.policy.version{service}
authz.shadow.diff.total{action,diff_type}
authz.obligation.unsatisfied.total{obligation_type}

If a policy release suddenly changes deny rate from 2% to 40%, you want to know before customers do.


29. Policy Testing Strategy

Testing must happen at multiple layers.

Policy Unit Tests

Input fixture -> expected decision.

assigned investigator closing in-review case -> permit
other tenant investigator closing case -> deny
same requester approving closure -> deny
supervisor exporting sealed evidence without clearance -> deny

Java Contract Tests

Application produces expected decision input.

@Test
void closeCase_requestContainsCaseStatusAndClassification() {
    PolicyDecisionRequest input = mapper.from(closeCaseAuthorizationRequest(subject, caseFile));

    assertThat(input.resource().attributes())
        .containsEntry("status", "IN_REVIEW")
        .containsEntry("classification", "CONFIDENTIAL");
}

Integration Tests

Java service calls actual PDP test instance.

@Test
void closeCase_deniedByPolicyEngineForOtherTenant() {
    assertThatThrownBy(() -> useCase.handle(otherTenantSubject, command))
        .isInstanceOf(AccessDeniedException.class);
}

Regression Matrix

Historical production decisions replayed against new policy.

Do not replay sensitive raw data; replay sanitized decision fixtures.

30. Policy Versioning

Version all three things separately:

- policy bundle version
- request schema version
- action catalog version

Example decision metadata:

{
  "decision": "DENY",
  "reasonCodes": ["NOT_ASSIGNED"],
  "policyVersion": "case-authz-2026.07.03-3",
  "schemaVersion": "authz-request-v1",
  "actionCatalogVersion": "case-actions-v4"
}

Do not deploy a policy that expects resource.attributes.lifecycleState if Java still sends resource.attributes.status.

Compatibility matrix:

App VersionRequest SchemaPolicy BundleCompatible?
case-service 2.1v1policy 2026.07.01yes
case-service 2.1v1policy 2026.07.03 requiring v2no
case-service 2.2v1+v2policy 2026.07.03yes

31. Policy Rollback

Rollback must be fast and rehearsed.

Rollback options:

- revert policy bundle version
- switch PDP route to previous bundle
- disable tenant-specific policy overlay
- switch enforcement to previous primary in shadowing port
- emergency fail-closed for sensitive actions

Do not make rollback depend on redeploying every Java service.

One reason to externalize policy is independent rollback.

Use that benefit intentionally.


32. Runtime Failure Modes

Externalized policy introduces new failures:

- PDP unavailable
- PDP slow
- PDP returns malformed decision
- policy bundle missing
- policy bundle incompatible with request schema
- attribute source unavailable
- policy engine memory pressure
- network partition
- stale policy cache
- sidecar version mismatch
- decision log sink unavailable

Each failure needs a defined behavior.

Example:

public AuthorizationDecision enforceFailureMode(
    AuthorizationRequest request,
    AuthorizationDecision decision
) {
    if (!decision.isIndeterminate()) {
        return decision;
    }

    ActionMetadata metadata = actionCatalog.metadata(request.action());

    return switch (metadata.failureMode()) {
        case FAIL_CLOSED -> AuthorizationDecision.deny("PDP_INDETERMINATE_FAIL_CLOSED");
        case ALLOW_IF_FRESH_CACHED_PERMIT -> cachedPermit(request, metadata.maxCachedDecisionAge())
            .orElseGet(() -> AuthorizationDecision.deny("NO_FRESH_CACHED_PERMIT"));
        case LOCAL_EMERGENCY_POLICY -> emergencyPolicy.evaluate(request);
        case PUBLIC_BYPASS_ONLY -> request.action().isPublicRead()
            ? AuthorizationDecision.permit("PUBLIC_BYPASS")
            : AuthorizationDecision.deny("PDP_INDETERMINATE");
    };
}

33. Latency Budget

Authorization must fit inside request latency.

Example budget:

Total API p95 budget: 200 ms
Authentication/token parsing: 5 ms
Resource load: 30 ms
Authorization decision: 20 ms
Business operation: 80 ms
DB write: 40 ms
Serialization/logging: 25 ms

If remote PDP p95 is 80 ms, you have a design problem.

Options:

- sidecar PDP
- local policy engine
- decision caching
- batch decision API
- push attributes to avoid PDP lookups
- precompute relationship/entitlement data
- reduce policy complexity
- move low-risk policies local

Do not discover authorization latency after production rollout.


34. Batch Decision API

Avoid one PDP call per row.

Bad:

for (CaseSummary caseSummary : page.items()) {
    decision = authorizationPort.decide(canViewCase(subject, caseSummary));
}

Better:

BatchAuthorizationDecision decisions = authorizationPort.decideBatch(
    page.items().stream()
        .map(caseSummary -> canViewCase(subject, caseSummary))
        .toList()
);

But for search/list, prefer query scoping before retrieval.

Batch decisions are useful for:

- field-level actions on already scoped rows
- UI action availability
- small object collections
- post-query refinement when policy cannot be expressed as SQL

They are not a replacement for safe query scoping.


35. UI Action Availability

Policy as Code is often used to drive UI button visibility.

That is useful.

It is not enforcement.

Map<Action, AuthorizationDecision> availableActions = authorizationPort.decideBatch(
    ActionCatalog.caseActions().stream()
        .map(action -> request(subject, action, caseFile))
        .toList()
).byAction();

Response:

{
  "caseId": "case-456",
  "status": "IN_REVIEW",
  "availableActions": {
    "case.closure.request": true,
    "case.closure.approve": false,
    "case.export.full": false,
    "case.export.redacted": true
  }
}

The write endpoint must still enforce authorization.

Never rely on button hiding.


36. Policy as Code and OpenAPI

Authorization metadata can be documented in OpenAPI, but not enforced by documentation alone.

Example extension:

paths:
  /cases/{caseId}/close:
    post:
      operationId: closeCase
      x-authorization:
        action: case.closure.request
        resource: case_file
        objectLevel: true
        fieldPolicy: false
        failureMode: fail_closed

This helps:

- API review
- documentation
- test generation
- coverage analysis
- gateway coarse rules

It does not replace application enforcement.


37. Security Review Checklist

Before externalizing authorization, ask:

1. What problem are we solving: speed, governance, consistency, tenant customization, audit, or scale?
2. What remains in Java after policy externalization?
3. What is the decision request schema?
4. Who owns each attribute?
5. Which attributes must be transactionally fresh?
6. What is the action catalog?
7. What is the policy test strategy?
8. What is the rollout and rollback plan?
9. What happens if PDP is slow or unavailable?
10. Are obligations supported and enforced?
11. How are decisions audited?
12. How are policy changes approved?
13. How are semantic diffs reviewed?
14. How are tenant-specific policies constrained?
15. How do we prevent policy drift from domain model changes?

38. Anti-Patterns

Anti-Pattern 1 — External PDP as Magic Security Layer

Adding OPA/Cedar/OpenFGA does not secure endpoints that do not call it.

Anti-Pattern 2 — Boolean-Only Decision

{"allow": true}

No reason code, no policy version, no obligation, no audit value.

Anti-Pattern 3 — Passing Full Domain Entity

PDP receives more data than needed and policy couples to internals.

Anti-Pattern 4 — No Schema

Policy references attributes that Java does not send.

Anti-Pattern 5 — Fail-Open by Accident

PDP timeout becomes permit because exception handling defaults to success.

Anti-Pattern 6 — Policy Team Without Domain Context

Security writes policy that does not match business semantics.

Anti-Pattern 7 — No Shadow Mode

Policy migration changes behavior in production without measured diff.

Anti-Pattern 8 — Tenant Policy Can Override Platform Deny

Customer customization bypasses base security invariant.

Anti-Pattern 9 — Decision Cache Without Version Key

User permission changes but cached permit remains valid.

Anti-Pattern 10 — UI-Only Policy Consumption

Policy controls visible buttons but backend write endpoint accepts direct calls.


39. Practical Decision Framework

Use this decision tree:

The best default for serious Java systems:

Put all authorization behind AuthorizationPort early.
Start with embedded Java policy if needed.
Externalize later when governance and consistency require it.

40. Summary

Policy as Code is not about using a fashionable policy engine.

It is about making authorization:

- explicit
- versioned
- testable
- reviewable
- observable
- governable
- deployable
- reversible

The Java application still owns enforcement.

The PDP owns decision.

The contract between them is the real architecture.

A good Policy as Code system has:

- domain-specific action catalog
- stable request/decision schema
- safe attribute projection
- tested policy fixtures
- shadow evaluation
- semantic diff
- explicit failure modes
- decision audit
- rollout/rollback process
- clear ownership

Externalize policy when it gives you real leverage.

Do not externalize your confusion.


References

Lesson Recap

You just completed lesson 26 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.