Skip to main content

Runtime API (.NET)

Complete reference for Ninka.Authz, the .NET runtime for bundles produced by the Ninka CLI. It evaluates compiled OPA WASM in-process — no network, no opa binary, and no policy server on the request path. The runtime targets net8.0 and depends on the version-pinned Wasmtime package.

dotnet add package Ninka.Authz

It loads Artifact Bundle format artifact_format_version 2. There is no compatibility mode: a version-less manifest or build record is refused, an older artifact format is answered with "recompile the artifacts" and a newer one with "upgrade Ninka.Authz".

Generate the C# Consumer Projection

A .NET application integrates through one generated file it commits — the C# Consumer Projection:

npx ninka-authz build --out-csharp Generated/Ninka.g.cs

--out-csharp names the file itself: C# has no source-root convention to resolve one from. The file carries the execution bundle inside it and hands it to the runtime through Authz.FromEmbedded, so an application that uses it has no artifact directory to deploy and none to read at run time:

using Ninka.Generated;

using var authz = NinkaProjection.Create();

Each policy's input contract is a record named i_<hash> — a policy id is data and is never turned into a C# identifier — with a <Pascal>Input alias beside it for the ids that produce a unique one. The policy reference itself lives on a generated Policies container, under a member name derived from the policy id (hyphens removed, PascalCase).

The projection lives in your source tree, not in ninka/out/ — nothing under ninka/out/ is C#. ninka-authz verify --out-csharp <file> compares it against a regeneration, so the file the release ships is the one these Tegata project to; regenerate it with the command above.

Deploying the artifacts as files (optional)

Tooling, scripts and applications that keep their artifacts as files use Authz.Load(dir) instead and include the directory in their output:

<ItemGroup>
<Content Include="ninka/out/**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

dotnet publish carries Content items into the publish output, so the compiled bundle travels with the application deployment.

class Authz

Authz.Load(dir, onInputError?, decisionLog?)

public static Authz Load(
string dir = "ninka/out",
Action<string, IReadOnlyList<string>>? onInputError = null,
DecisionLogOptions? decisionLog = null)

Loads every built policy in dir, verifies each runtime bundle, compiles its WASM to native code as needed, and returns a ready instance. Load it once (for example into the DI container as a singleton) and reuse it for the process lifetime.

What loading verifies

At load time the runtime verifies each bundle before it can serve a decision. The implemented checks include:

  • bundles/*.build.json discovery and bundle_id / filename consistency;
  • presence of the module and SHA-256 equality with the build record's wasm_sha256;
  • presence of the manifest each binding names, and matching manifest.policy_id;
  • supported artifact_format_version on both the build record and every manifest — absent is refused, not tolerated;
  • required manifest.rego_sha256 and equality with the binding's rego_sha256;
  • presence of a non-empty manifest.tegata_hash, required to bind generated PolicyRef<T> values to the loaded bundle;
  • agreement between the binding's entrypoint and the one the policy's own manifest declares, plus the presence of that name in the module's entrypoint table. The runtime derives no entrypoint name from a policy id;
  • the runtime's OPA-WASM compatibility checks, including the no-host-builtins contract;
  • uniqueness of policy ids, within a bundle's bindings and across bundles.

Any failure throws NinkaLoadException before a decision can be served, and the failure is the whole bundle's: one broken binding refuses every policy in it, and the modules a refused load already instantiated are disposed.

Load does not compare a generated PolicyRef<T> with the bundle's tegata_hash; no policy reference has been supplied yet. That comparison happens in Check. Load nevertheless requires every loaded manifest to carry a non-empty tegata_hash; Check performs the reference-to-bundle comparison later. Load also does not read source Tegata, so source/artifact STALE state is detected by tooling that can see both sides, such as ninka-authz verify and the LIVE Authorization Reference.

Lifecycle and cost

WASM-to-native compilation dominates the first load of a previously unseen module. Compiled modules are cached process-wide by the WASM SHA-256, so loading the same bytes again reuses the compiled module rather than recompiling it. The cache holds compiled code only — evaluation state remains per Authz instance.

Instances therefore do not share mutable policy state, and disposing one instance does not invalidate another. The module cache is process-wide and is not evicted; its size is bounded by the number of distinct WASM bundles loaded by that process.

Authz owns per-instance native resources and should be disposed with its owner. For the usual singleton lifetime, DI-container disposal at process shutdown is sufficient.

Check(policy, input)

public bool Check<TInput>(PolicyRef<TInput> policy, TInput input) where TInput : notnull

Use the reference the projection generates:

using Ninka.Generated;

using var authz = NinkaProjection.Create();

bool allowed = authz.Check(Policies.InvoiceAccess, new InvoiceAccessInput
{
Subject = new() { Properties = new() { Roles = user.Roles, UserId = user.Id } },
Action = new() { Name = "view" },
Resource = new() { Type = "invoice", Properties = new() { Amount = invoice.Amount, SubmittedBy = invoice.SubmittedBy } },
});

if (!allowed) return Results.Forbid();

Policies.InvoiceAccess names the policy and binds the input to that policy's generated C# contract (InvoiceAccessInput). The reference also carries the tegata_hash of the compile it was projected from. Check compares that hash with the manifest value loaded for the policy; mixing generated C# from one compile with a bundle from another therefore throws instead of evaluating under the wrong generated contract.

The serialized top-level input must be a JSON object. A top-level null, scalar, or array is a caller-contract error and throws before policy evaluation. Missing or wrongly typed values inside an object remain subject to authorization semantics and do not become API-shape errors.

The input is serialized, subject to the shared 64-container-level depth limit, then evaluated against the loaded WASM. Check returns true for ALLOW and false for DENY. Deny-overrides is resolved inside the compiled policy. Unknown policy ids, reference/bundle hash mismatch, invalid input serialization, and WASM failures are exceptions rather than ALLOW fallbacks.

Check is thread-safe. Evaluation is serialized per policy instance; different loaded policies can be evaluated concurrently.

Missing-input notification

onInputError is an observation channel. When the decision is DENY and required input keys associated with applicable rule origins are missing, the callback receives the policy id and missing keys. It never changes the verdict; callback exceptions are swallowed. The default writes one line to stderr.

DecisionLogOptions

Decision logging is off by default. When enabled, each Check emits one DecisionLogEntry following the OPA Decision Log field model where the semantics are explicitly defined as equivalent. Ninka-specific semantics and the known deviations are documented separately — field-name overlap alone does not imply semantic equivalence, so the format is not plainly "OPA-compatible" (see the TypeScript reference for the field-by-field description — both runtimes emit the entry ARTIFACT_SPEC §5.6 defines, so that description applies here too).

var authz = Authz.Load("ninka/out", decisionLog: new DecisionLogOptions
{
Enable = true,
Sink = entry => myLogger.LogInformation("{Entry}", JsonSerializer.Serialize(entry)),
});

The entry carries the fields ARTIFACT_SPEC §5.6 defines — the same set, with the same meanings, that the TypeScript runtime emits:

FieldValue
decision_idunique decision id
timestampdecision timestamp
path"ninka/result"
resultboolean verdict
inputthe evaluation input projected onto the declared AuthzInput shape, then masked
bundlespolicy → { revision }, where revision is the manifest tegata_hash
labelsruntime / operational labels, including policy_id (the evaluated policy, restated as a static key for log aggregation)
ninkaNinka-specific schema/policy verdict data

The logged input is built in two steps: it is first projected onto the declared AuthzInput shape (subject.properties, action.name, resource.type, resource.properties, context), then masked. Only attributes in manifest.decision_log.unmasked — derived from the vocabulary's decision_log.unmasked, and named log_allowlist before artifact v2 — may appear raw; other values become "***". action.name and resource.type remain raw policy-selection literals. Keys outside the projected shape are omitted from the entry rather than masked in it, even when the evaluation input carried them, and no field records the omission. The sink is observation-only: if it throws, the decision is preserved and the callback failure is swallowed. The default sink writes one JSON line to stdout.

An entry's field set is not closed. A consumer must tolerate fields the contract does not name and must not reject or drop an entry because it carries one — compatible fields may be added without changing ninka.schema or artifact_format_version, so a reader that validates against a closed field set will start discarding decision records on an ordinary upgrade.

What both runtimes are held to is the specification: the field set, each field's meaning, the declared deviations from the OPA field model, and the projection and masking rules (ARTIFACT_SPEC §5.6–§5.6.2), plus the shared validation/conformance corpus. The serialized JSON is not guaranteed identical field-for-field — how each language represents an absent or null value inside the projected shape is fixed by neither the specification nor the corpus, and the two runtimes are known to differ there. Read an entry by its fields; a consumer that diffs the two runtimes' output byte-for-byte is relying on something nothing fixes.

PolicyIds

public IReadOnlyCollection<string> PolicyIds

Runtime introspection over the ids currently loaded. Application decisions use the generated Policies.<Name> references rather than constructing policy selection from these strings.

Dispose()

Authz owns per-instance native Wasmtime resources. Dispose it when its owner is disposed; for a singleton lifetime, container disposal is sufficient.

Failure contract

No failure mode returns ALLOW:

SituationResult
Policy decision is DENY (including a deny with missing required input)false
input is nullArgumentNullException
Top-level input serializes to null, a scalar, or an arrayexception before policy evaluation
Input exceeds 64 container levelsJsonException
Unknown policyNinkaUnknownPolicyException
PolicyRef<T> carries no tegata_hashexception before policy evaluation
PolicyRef<T>.tegata_hash does not match the loaded manifestNinkaLoadException
manifest.tegata_hash missing or emptyNinkaLoadException at load
Bundle identity / integrity / format / compatibility failureNinkaLoadException at load
WASM trap / abort during evaluationexception; the Authz instance remains usable for later checks

Differences from the TypeScript runtime

  • .NET Check is synchronous (bool, not Promise<boolean>).
  • Both runtimes consume the same runtime bundle contract and are held to the same validation/conformance corpus.
  • Decision-log field set, input projection and masking semantics are fixed by ARTIFACT_SPEC §5.6–§5.6.2 and both runtimes implement them; runtime-identifying labels differ, and the serialized JSON is not guaranteed identical field-for-field.

Process-global trap handling on macOS

Ninka configures its Wasmtime engines for POSIX-signal trap handling on macOS. Wasmtime's default Mach-port mode conflicts with the per-thread exception ports CoreCLR needs. Trap handling is process-global in Wasmtime: if an application also creates a default-configured Wasmtime Engine in the same process, the second incompatible trap configuration fails. Configure other engines with macos_use_mach_ports = false when they must coexist in the same process.

See also