← Blog

FGA and OPA decide.
nominee executes.

I work at Auth0. nominee's core is zero-dependency and provider-neutral; Auth0 is one optional strategy of many. This isn't "why not use FGA/OPA" — it's "use them, and put nominee at the boundary where their answer meets a tool call."

OpenFGA, WorkOS FGA, Cerbos, Open Policy Agent — these are Policy Decision Points. Given a request, they return an answer: can this subject do this thing to this object, according to this relationship graph or this Rego policy. That's a hard problem, they're good at it, and nominee has no interest in re-solving it. It isn't a fine-grained-authorization engine, and it doesn't pretend to be one.

A decision is not the same thing as an enforced, evidenced, credentialed action. That's the gap this post is about — and it's a narrower, more specific claim than "FGA can't do authorization." FGA can absolutely tell you the user is allowed. What it doesn't do — because it isn't its job — is turn that "yes" into a single-use grant bound to the exact call that asked for it, hand the tool a credential scoped to that grant, make sure the "yes" still holds after a five-minute approval pause, or keep a durable, tamper-evident record that this exact decision happened. That's a Policy Enforcement Point's job. It's the layer positioned directly underneath the PDP, not instead of it.


Composing them, concretely

nominee's policy rules take an async when predicate. There's nothing special-cased about FGA or OPA here — they're just an authorization source the predicate calls, exactly like it could call your own RBAC table:

import { Nominee, allow, deny } from 'nominee'

const nominee = new Nominee({
  policy: {
    rules: [
      allow('document.share', {
        when: async ({ user, input }) =>
          fga.check({ user: `user:${user}`, relation: 'can_share', object: `doc:${input.docId}` }),
      }),
      deny('document.share', { reason: 'FGA denied the relation' }),
    ],
    fallback: 'deny',
  },
  // or, for the resource check nominee runs at plan time AND again
  // right before the credential resolves and the tool executes:
  authorizer: ({ user, action, resource, tenant }) =>
    opa.evaluate('authz/allow', { user, action, resource, tenant }),
})

Either hook works: when for tool-call-level policy, authorizer for the resource-scoped check that prepareAction() runs while planning and executeCapability() runs again after the capability is consumed — "immediately before credential resolution and tool execution," per the production runbook. An OPA or FGA decision that was true when the action was planned gets asked again, on the same resource, right before anything actually happens. The PDP doesn't have to know that recheck occurs; it just answers the same question twice, at two different moments nominee chooses.


What happens to the "yes"

Once when or authorizer returns true, nominee is what turns that decision into something a tool can actually run against:

  • A single-use capability, bound to a fingerprint of the exact input the policy evaluated — not a standing "this user can do this class of thing" grant. Issued once, expires in minutes, consumed atomically exactly once.
  • A credential resolved under that capability, only after it's consumed — the strategy layer fetches a token scoped to the connection and scope ceiling for this one action, never before the FGA/OPA check clears and never held in the model's context ahead of time.
  • A receipt — hash-chained, sealing the decision, the rule or authorizer outcome, and (hashed by default) the input that was evaluated — whether the PDP said yes, no, or the call never got that far because policy denied it first.

None of that exists inside OpenFGA or OPA today, and it isn't a gap in either project — a PDP that also minted execution capabilities and wrote a hash-chained receipt log would be a different kind of system with a different job.


The honest edge case: what if the PDP itself is unreachable, slow, or wrong

Composing them exposes a real question nominee doesn't paper over. If fga.check() throws, prepareAction() fails the action closed — the same code path that handles a thrown policy predicate denies the call, records why, and emits policy evaluation failed on the receipt rather than leaving the action in limbo. If the external authorizer returns something other than a strict boolean, nominee throws rather than guessing. In production mode, an action that names a resource without a configured authorizer throws rather than proceeding — a hard failure, not a silent allow. The PDP being unavailable is treated as a deny, not as "skip the check" — which is the property you actually want when the decision source is a separate network call.


Layer by layer

Layer Job Owned by
Relationship / policy decision Can this subject do this to this object? FGA / OPA
Tool-call enforcement Bind the "yes" to this exact call, deny/ask otherwise nominee
Post-pause recheck Ask again, same resource, right before execution nominee
Capability issuance Single-use, expiring, exact-input-bound grant nominee
Credential resolution Fresh, scope-ceilinged token at execution nominee (+ your vault/IdP as strategy)
Evidence Hash-chained receipt of every decision nominee

Framework → nominee (enforcement & evidence) → FGA / OPA / IdP / vault (identity & policy decision) → your action service. Nominee is the PEP; FGA and OPA remain the PDP. Neither replaces the other.


When you don't need this

If your agent's tools are read-only, if your FGA or OPA deployment already sits directly in front of the service the tool calls (so the check and the execution are the same hop), or if you don't need a credential to be a consequence of the decision — you may not need an enforcement layer between them at all. The composition described here earns its place once a write-capable agent needs the PDP's "yes" to become a scoped credential and a receipt, not just a boolean your application code has to remember to check next.

See it for yourself

Let FGA or OPA decide. Let nominee execute.

$ npm i nominee zero deps
Star on GitHub Read the production runbook → Read the docs →