Skip to main content

Migrate from OPA

Ninka still uses Rego and OPA in its build pipeline, but it changes where authorization intent is authored. Instead of maintaining the authorization policy primarily as handwritten Rego, you express the supported authorization model as Tegata (手形). Ninka compiles that specification to Rego, and the pinned OPA toolchain builds the execution WASM.

Migration is therefore not a syntax translation exercise. You must preserve the old system's authorization meaning while moving the review surface to Tegata.

1. Map the existing decision onto Tegata

A common mapping is:

Existing policy conceptTegata
Protected kind of resourceresource.type
Caller rolessubject.roles
Requested operationactions
Attribute compared with a literalconditions
Subject attribute compared with resource attributerelationships

For example:

allow if {
input.action == "approve"
input.subject.roles[_] == "manager"
input.resource.amount <= 500000
}

can be represented as:

{
"id": "allow-manager-approve-invoice-within-limit",
"effect": "allow",
"subject": { "roles": ["manager"] },
"actions": ["approve"],
"resource": { "type": "invoice" },
"conditions": [
{ "key": "resource.amount", "op": "lte", "value": 500000 }
]
}

A subject/resource ownership comparison becomes a relationship:

"relationships": [
{
"label": "ownership",
"subject_attribute": "user_id",
"op": "eq",
"resource_attribute": "submitted_by"
}
]

See Tegata Schema for the supported model before trying to port a construct that may be outside it.

Preserve externally owned role tokens

Role values come from an external identity source, so write them in Tegata exactly as that source issues them. Values such as admin:operate and Domain Admins are valid role tokens when they satisfy the role-value constraints.

"subject": { "roles": ["admin:operate", "Domain Admins"] }

Ninka does not normalize admin:operate into admin_operate, change case, or otherwise invent a translation rule between the claim and the policy. Those strings are different role values at evaluation time.

If the project uses a closed-world Vocabulary, introducing either spelling requires an explicit vocabulary change. That makes a new project role reviewable, but it does not mean Ninka infers that punctuation variants are equivalent or automatically flags them as the same role.

If you intentionally remodel your identity scheme during migration — for example, splitting a role:verb token into a role and a policy action — treat that as a separate authorization-design change and review it explicitly. Do not let it happen accidentally as part of transcription.

2. Identify policies that cannot be transcribed one-to-one

Collections and filtered results

Ninka's authorization decision is about one concrete resource supplied in the input. It does not compile a policy into a query filter or define a set-level decision over a collection.

Legacy Rego such as:

allow if { input.Data[_].CompanyKey == input.CompanyKey }
records := [r | r := input.Data[_]; r.CompanyKey == input.CompanyKey]

therefore needs an explicit application design. A common shape is:

const visible = [];
for (const record of candidates) {
if (authz.check(policies.documentRead, inputFor(record))) {
visible.push(record);
}
}

Do not turn a set of per-record verdicts into a new endpoint-level authorization rule by accident. For example, records.some(r => check(...)) means “allow the request if any record is allowed,” which is a different authorization semantics defined by application code, not by Tegata.

Use the database to narrow the candidate set when necessary, but keep the distinction clear: query filtering selects candidates; check() authorizes a concrete resource. Ninka 0.x does not prove that a SQL WHERE clause is equivalent to the policy and does not generate such a clause for you.

Per-record evaluation cost grows with the number of candidates. Benchmark the workload you actually serve instead of relying on a fixed microsecond figure from another machine or bundle.

Legacy precedence and else

A Rego else chain can encode priority that disappears if each branch is naively converted into an independent allow rule.

Before porting such a policy, write down the intended precedence. If a broad permission must not apply when a more specific scoping role or condition is present, encode that fact explicitly in the Tegata rather than relying on source-code order that Tegata does not have.

When this requires interpreting the legacy requirement, record the chosen interpretation with the appropriate audit.ambiguities code.

Logic outside Tegata's model

Do not distort the policy just to make it fit. If part of the authorization logic is outside the supported Tegata model, either:

  • resolve it in the caller and pass the result through an explicit custom.* input; or
  • keep that slice outside Ninka until you have an intentional replacement design.

A custom.* value is supplied by application/PIP code. Ninka can use the value in the decision, but it does not verify the external computation that produced it.

3. Build a parity loop before switching authority

Generated validation checks the policy described by the current Tegata. It is not a parity oracle for the legacy policy being replaced.

During migration, evaluate representative cases through both the legacy authority and Ninka and compare the results independently. Include cases that are easy to miss during transcription:

  • allow and deny paths;
  • boundary values;
  • missing or empty inputs that had special meaning in the old system;
  • subjects carrying combinations of roles from multiple rules;
  • legacy precedence or filtering behavior.

A small project-specific script is often sufficient:

import { createNinka, policies } from "./src/generated/ninka";
import { execFileSync } from "node:child_process";

const authz = await createNinka();
let mismatches = 0;

for (const c of cases) {
const legacy = JSON.parse(execFileSync("opa", [
"eval",
"-d", "opa/policies/document_read.rego",
"--format", "raw",
"--stdin-input",
"data.app.document_read.allow",
], { input: JSON.stringify(c.legacyInput), encoding: "utf8" }));

const ninka = authz.check(policies.documentRead, c.ninkaInput);
if (legacy !== ninka) {
mismatches++;
console.error("MISMATCH:", c.name, { legacy, ninka });
}
}

process.exit(mismatches ? 1 : 0);

This script is intentionally project-owned. Ninka cannot know the legacy input shape or how that shape should map onto subject, action, and resource.

A green parity loop proves only that the compared cases agreed. It does not prove exhaustive equivalence. Also verify that the expected policies and cases actually ran; “zero mismatches” from a comparison that exercised nothing is not useful evidence.

For a legacy policy that returned a filtered collection, compare membership per concrete record rather than comparing one set-level boolean with one Ninka decision.

4. Check semantic differences explicitly

Empty strings

Ninka's three-valued semantics treat "" as absent for present(). Rego equality can still make expressions such as x == "" true.

A port that relied on empty strings as sentinel values can therefore move toward deny under Ninka's fail-closed semantics. Include empty-string and missing-input cases in parity testing.

If the application must distinguish “missing” from “present but empty,” model that distinction explicitly in the input instead of depending on an empty-string comparison that means something different in the two systems.

Role and vocabulary handling

Role matching is exact and case-sensitive. Ninka preserves externally owned role values; it does not normalize them on your behalf.

For Ninka-owned terms such as actions, resource types, and attribute names, follow the Tegata naming rules and declare the project vocabulary when using vocabulary.json.

5. Compile and inspect the port

npx ninka-authz compile
npx ninka-authz docs

Use Author a Policy for the normal authoring workflow and Review Authorization to review the resulting Tegata as a specification.

6. Shadow before you switch enforcement

Do not replace the old authority in the same step that you first introduce the new one.

During a shadow phase, evaluate Ninka beside the legacy system and record the comparison while the legacy system continues to make the production decision. The shadow path must not become an availability dependency for production traffic.

When you later switch enforcement, the failure posture changes: a service that is supposed to enforce Ninka authorization must not silently degrade to allow because the bundle failed to load or check() failed.

SituationShadow phaseEnforcement phase
Ninka bundle cannot loadrecord the shadow failure; leave the legacy decision unchangedfail startup or otherwise prevent unprotected service
Ninka evaluation errorsrecord the comparison failure; leave the legacy decision unchangednever convert the error into allow
Legacy/Ninka mismatchrecord and investigateresolve before removing legacy authority
ninka-authz verify in CIstrongly recommendedtreat as a required release gate for the generated state you deploy

Compare denied legacy requests as well as allowed ones. If shadow evaluation runs only after a legacy allow, the dangerous direction — legacy deny / Ninka allow — is never observed.

Do not reuse a swallow-all shadow wrapper as the final enforcement integration. Build the enforcement path around the actual runtime contract and make its failures visible.

When a mismatch reflects an intentional interpretation difference, record that interpretation using the appropriate defined audit.ambiguities code. When it is a porting error, fix the Tegata or application mapping instead.

Toolchain notes

Ninka's current default build toolchain pins OPA 0.65.0. Ninka does not search an arbitrary opa on PATH for its normal build. NINKA_OPA_PATH is an explicit override for environments that provide their own binary.

That override is not Ninka's pinned binary: Ninka does not checksum or probe it as the pinned toolchain. If you use the override, the responsibility for choosing and managing that binary is yours.

Tegata deliberately supports a bounded authorization model. Check Tegata Schema before migrating unsupported constructs, and use the explicit delegation boundary rather than hiding unsupported logic in generated application code.

See also