Misk OPA Authorization: Type-Safe Policy with misk-policy


Series: Building Production Services with Misk — Part 10 of 24

The capability model from Part 9 handles most authorization cleanly: does this caller hold this capability? But some decisions are richer than a capability check — they depend on the content of the request, on relationships between entities, on data that lives outside your service. Hard-coding that logic in Kotlin means redeploying every time a rule changes. The alternative is to externalize it to a policy engine, and Misk’s bridge to one is misk-policy, a type-safe interface to Open Policy Agent (OPA). This is a deep-dive post; if you don’t have a policy engine in your stack, capability checks may be all you need.

When capabilities aren’t enough

Capabilities answer “is this caller an admin?” They don’t comfortably answer “can this caller refund this particular transaction, given its age, amount, and the caller’s region?” That’s a policy decision with inputs, and you don’t want it compiled into your service — you want it expressed as policy, evaluated at runtime, changeable without a deploy. That’s precisely what OPA exists for, and misk-policy is how a Misk service asks OPA for an answer.

A blunt caveat up front, straight from the module’s own docs: the only supported policy engine is OPA, and Misk does not set OPA up for you. misk-policy assumes an OPA instance is already running and reachable over HTTP(S) — typically as a sidecar next to your service. This post is about the client half; deploying OPA is your infrastructure’s job.

What misk-policy gives you

OPA’s HTTP API is free-form JSON: you POST an input document, OPA evaluates your policy, and returns an output document. Throwing untyped JSON around in Kotlin is exactly the kind of thing this audience avoids, so misk-policy’s core value is type safety over that JSON interface. You describe the input and output as data classes, and the engine marshals both ends. That’s the whole pitch: free-form policy, strongly-typed call site.

Setup: install OpaModule and configure

Install the module and point it at your OPA instance:

install(OpaModule(config.opa))

The configuration lives in your YAML, with a required base URL. OPA can also be reached over a Unix domain socket, though the docs warn that sockets complicate local development across different laptops — so a common pattern is HTTP locally, socket in staging/prod:

opa:
  baseUrl: "http://localhost:8181/"
  # unixSocket: "..."   # optional; staging/prod only
  # provenance: true    # optional; see below

Add the corresponding opa property to your service’s Config class, the same composition pattern from Part 2.

Type-safe queries

Define the shape of the policy’s input and output as data classes extending OpaRequest and OpaResponse:

data class AuthzInput(val someValue: Int) : OpaRequest()
data class AuthzResult(val test: String) : OpaResponse()

Then inject OpaPolicyEngine and call evaluate, naming the policy and passing your input. The return type drives deserialization, so you get a typed result back:

@Singleton
class RefundAuthorizer @Inject constructor(
  private val opa: OpaPolicyEngine,
) {
  fun mayRefund(amountCents: Int): Boolean {
    val result: AuthzResult = opa.evaluate("abc", AuthzInput(someValue = amountCents))
    return result.test == "allowed"
  }
}

evaluate("abc", AuthzInput(1)) queries the policy named abc with your input document and returns the typed response — no Map<String, Any?> fishing, no manual JSON parsing. Under the hood misk-policy talks to OPA’s /v1/data API.

The Rego side

The policy itself is written in OPA’s language, Rego, and lives with OPA, not in your service. A matching policy for the example above (abc.rego):

package abc

default test = "some value"

test = returnVal {
  input.someValue == 1
  returnVal := "something else"
}

test = returnVal {
  input.someValue > 9000
  returnVal := "That's impossible!"
}

The fields here line up with the data classes: input.someValue is your OpaRequest field, and test is the OpaResponse field. The important architectural point is that this file changes independently of your service — update the rule, push it to OPA, and your Kotlin doesn’t recompile. That decoupling is the entire reason to externalize policy.

Testing and local development

A policy engine you can’t run locally would be miserable to develop against, so Misk ships misk-policy-testing for unit tests and local dev. Use it to exercise your evaluate call sites without standing up a real OPA sidecar on every laptop — the same fakes-over-reals philosophy that runs through all of Misk’s testing story (Part 23). The README points here explicitly for both unit testing and local development; lean on it rather than requiring every developer to run OPA.

Provenance

OPA can return provenance — metadata about which policy version produced a decision — which is invaluable for auditing authorization decisions after the fact (“which version of the refund policy approved this?”). Opt in via config:

opa:
  baseUrl: "http://localhost:8181/"
  provenance: true

For a service where authorization decisions are themselves audited (a recurring theme in payments), provenance closes the loop between “the policy said yes” and “this policy said yes.”

Production notes & gotchas

  • You operate OPA; Misk only talks to it. misk-policy is a client. An OPA sidecar (or reachable instance) must be deployed and healthy, or every evaluate call fails. Treat OPA as a dependency in your readiness and lifecycle thinking (Part 4).
  • Unix sockets bite local dev. The socket transport is fine in staging/prod but inconsistent across developer machines; default to HTTP locally and reserve the socket for deployed environments, as the docs advise.
  • Don’t route every check through OPA. A network round-trip per authorization decision is real latency. Keep coarse, stable checks as capabilities (Part 9) and reserve OPA for genuinely data-dependent, frequently-changing policy. Most services need both, not one.
  • Keep request/response types total. Your OpaRequest/OpaResponse data classes are the contract with the Rego policy; a field mismatch is a runtime surprise. Evolve the policy and the types together.
  • Turn on provenance where decisions are audited. It’s cheap and it answers the “which policy version” question you’ll eventually be asked.

What’s next

We’ve covered who can call (authentication) and the rich-policy escape hatch (OPA). The remaining security concern is data at rest and in config. In Part 11: Crypto & Secrets we cover misk-crypto primitives, key management, and handling secrets in configuration — the last piece of the security arc before we turn to persistence.


Target keywords: misk opa, misk authorization policy, misk-policy.

Comments