Skip to content

Policy patterns

Veridor has no built-in notion of who may do what. Your configuration is the policy, and a check is one sentence of it: this request, sent as each of these identities, must draw these statuses and, optionally, must or must not carry this identity's marker. This page is a cookbook of those sentences for the authorization rules most APIs have.

Each pattern has three parts: the rule in plain words, the declaration, and what a finding on it means. Every declaration here was run with veridor run against scripts/demo_server.py, the deterministic demo API, and the output under it is what that run printed, abridged where marked with …. The keys themselves are specified in the configuration reference; the words vulnerable, unexpected_denial, confirmed and suspected are defined on verdicts and evidence grades.

A finding is a disagreement with your declaration

Every pattern below reports where the target differed from what you wrote. None of them can tell you whether what you wrote is the policy your product intends. A wrong declaration and a real vulnerability produce the same finding. See your configuration is the policy.

The setup every pattern shares

The demo was started on port 18802, in its default mode and then with --vulnerable, which switches on its cross-tenant bugs:

python3 scripts/demo_server.py --port 18802
python3 scripts/demo_server.py --port 18802 --vulnerable

Unless a pattern says otherwise, its checks fragment was run inside this preamble. A file named in a command, such as tenant.json, is this preamble with that pattern's fragment in place of "checks": []. The four identities are two tenants, an administrator, and a caller with no credentials:

{
  "version": 1,
  "target": {"base_url": "http://127.0.0.1:18802", "timeout_seconds": 3},
  "identities": {
    "invoice_owner": {
      "headers": {"Authorization": "Bearer owner-token"},
      "marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1",
      "marker_probe": {"method": "GET", "path": "/api/invoices/inv-100"}
    },
    "other_tenant": {
      "headers": {"Authorization": "Bearer outsider-token"},
      "marker": "vdr-Bq3Wm8Ke5Tz1Yx7Rn2Hd4Vs6",
      "marker_probe": {"method": "GET", "path": "/api/invoices/inv-200"}
    },
    "administrator": {"headers": {"Authorization": "Bearer admin-token"}},
    "anonymous": {"headers": {}}
  },
  "checks": []
}

Two choices in it carry weight in every pattern.

  • Each tenant has a marker and a marker_probe. A marker is a unique string planted in that identity's own data (veridor new-marker prints one). The probe is a request that identity should be served, issued once per run, and it proves the credential is live. Without it, an identity that is only ever denied cannot be told apart from one whose token expired; the role pattern shows what Veridor prints then.
  • anonymous declares "headers": {}. An identity meant to send no credentials has to say so. One that defines none of headers, marker, marker_probe or session is refused at load time (identities).

A check sends one request as every identity its expect names, and must name at least two. Every excludes_marker needs a control: the owner asserting contains_marker in the same check, or an identity marker_probe. The same check is the stronger of the two (where the grades come from).

Tenant isolation

The rule. An object owned by one tenant is not served to another tenant, and none of its data reaches them.

"checks": [
  {
    "id": "invoice-tenant-isolation",
    "request": {"method": "GET", "path": "/api/invoices/inv-100"},
    "expect": {
      "invoice_owner": {"status": [200], "contains_marker": "invoice_owner"},
      "other_tenant": {"status": [404], "excludes_marker": "invoice_owner"}
    }
  }
]

The owner's line is the control: it proves the marker is observable at this path, so its absence from the other tenant's response is something that could have failed. Against the demo in its default mode:

veridor run --config tenant.json
…
Veridor run 20261110T…: 2/2 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:18802
…

Exit code 0. With --vulnerable, the same file:

Veridor run 20261110T…: 1/2 probes matched their declaration, 1 vulnerable, 0 unexpected denial, 0 inconclusive
…
Evidence: 1 confirmed, 0 suspected, 0 ungraded, 0 escalated since the baseline
…
- [vulnerable (confirmed)] invoice-tenant-isolation / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [404]) and the response carried 'invoice_owner''s marker: …

Exit code 1.

What a finding means here. vulnerable (confirmed): the other tenant was served, and the owner's marker was in the response, so the owner's string crossed the boundary. vulnerable (suspected): the other tenant was served but the marker was not seen, so exposure is unproven. unexpected_denial: the other tenant was refused, but with a status you did not list, for example 403 where you declared 404; see both 403 and 404 are acceptable denials.

Ownership within a tenant

The rule. Inside one tenant, an object owned by one user is not served to another user, and one user's response carries none of another user's data.

The configuration has the same shape as tenant isolation. What changes is which identities you declare: two users who share a tenant, each with a marker planted in their own records. The demo has no two users in one tenant, so the run below uses its device API, where each resource belongs to one principal and the path embeds that principal. This is a complete file, not a fragment:

{
  "version": 1,
  "target": {"base_url": "http://127.0.0.1:18802", "timeout_seconds": 3},
  "identities": {
    "device_a": {
      "headers": {"Authorization": "Bearer device-a-token"},
      "marker": "vdr-OIItAlvvweFpwlDNmT1j5WJ8",
      "marker_probe": {"method": "GET", "path": "/fleet/v1/device-a/artifacts/fw.bin"}
    },
    "device_b": {
      "headers": {"Authorization": "Bearer device-b-token"},
      "marker": "vdr-lWJ25HBfPp4Y3_MeEkYhL9Gu",
      "marker_probe": {"method": "GET", "path": "/fleet/v1/device-b/artifacts/fw.bin"}
    },
    "anonymous": {"headers": {}}
  },
  "checks": [
    {
      "id": "device-a-artifact-owner-only",
      "request": {"method": "GET", "path": "/fleet/v1/device-a/artifacts/fw.bin"},
      "expect": {
        "device_a": {"status": [200], "contains_marker": "device_a"},
        "device_b": {"status": [403], "excludes_marker": "device_a"},
        "anonymous": {"status": [401]}
      }
    },
    {
      "id": "device-b-artifact-is-its-own",
      "request": {"method": "GET", "path": "/fleet/v1/device-b/artifacts/fw.bin"},
      "expect": {
        "device_b": {"status": [200], "excludes_marker": "device_a"},
        "anonymous": {"status": [401]}
      }
    }
  ]
}

The two checks cover the two directions of the rule. The first asks whether device_b can reach device_a's object. The second asks whether device_b, reading its own object, is handed device_a's data. That second check has no same-check control, because device_a never reads device_b's path, so device_a's marker_probe is what licenses the exclusion. Without that probe the file is refused:

veridor: check 'device-b-artifact-is-its-own' expectation for 'device_b' excludes_marker 'device_a' requires a control in the same check; add {"device_a": {"contains_marker": "device_a", "status": [200]}} to check 'device-b-artifact-is-its-own' expect, or give identity 'device_a' a 'marker_probe' naming a path where its own marker is served

With the probe, against the default demo, exit code 0, and the run says what the weaker control does not prove:

Veridor run 20261110T…: 5/5 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
…
Unproven oracle: 1 exclusion(s) were licensed by an identity marker_probe, which proves the marker is live elsewhere, not that the path under test can surface it:
  - device-b-artifact-is-its-own / device_b: the absence of 'device_a''s marker in 'device_b''s response was licensed by 'device_a''s identity marker_probe, …

Against --vulnerable, where the demo hands device_b the other device's artifact, exit code 1:

- [vulnerable (confirmed)] device-b-artifact-is-its-own / device_b: Identity 'device_b' received the declared HTTP 200, but the response carried 'device_a''s marker: …

What a finding means here. On the first check, the same as for tenant isolation. On the second, the status was what you declared and the other user's marker was in the body anyway. That is reported under VERIDOR-LEAK-001, the case a status-only check cannot see (which rule each verdict is reported under).

Role or admin-only endpoint

The rule. A function is reserved for a role. Callers without the role are refused even though they are authenticated, and an unauthenticated caller is refused as such.

"checks": [
  {
    "id": "audit-admin-only",
    "request": {"method": "GET", "path": "/api/admin/audit"},
    "expect": {
      "administrator": {"status": [200]},
      "invoice_owner": {"status": [403]},
      "other_tenant": {"status": [403]},
      "anonymous": {"status": [401]}
    }
  }
]
Veridor run 20261110T…: 4/4 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive

The administrator's 200 is the positive control: it shows the route exists and serves someone, so the 403s are refusals rather than a missing route.

The marker_probe on each tenant matters most here. The same check run with the same identities, except that invoice_owner had no marker_probe, still exited 0, and printed this:

Unproven denials: 1 expectation(s) held against a denial that a credential Veridor fabricated received too, so they did not establish who was denied.
  - audit-admin-only / invoice_owner: 'invoice_owner' received HTTP 403 on this request, and so did a credential Veridor fabricated that the target cannot have recognised. …

That is the liveness control: a 403 that any caller gets proves nothing about this caller. It changes the exit code only under --require-complete, which exits 3.

What a finding means here. A non-administrator answered 2xx is vulnerable. Because this check plants no marker, the grade is ungraded: the status differed and nothing measured whether data moved. A 401 where you declared 403, or the reverse, is unexpected_denial: the caller was refused, but not the way you said. A privileged role that should be served everywhere is declared the same way, with [200] on the line for that role.

Anonymous must be refused

The rule. A caller with no credentials is refused before anything else is decided.

"checks": [
  {
    "id": "invoice-anonymous-refused",
    "request": {"method": "GET", "path": "/api/invoices/inv-100"},
    "expect": {
      "invoice_owner": {"status": [200]},
      "anonymous": {"status": [401]}
    }
  }
]
Veridor run 20261110T…: 2/2 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive

A check needs two identities, so the anonymous line always sits beside one that is served. That served line is what makes the 401 mean something: it shows the route exists. The anonymous line can be added to any other check instead of standing on its own, as the role and ownership patterns above do.

What a finding means here. 2xx to the anonymous caller is vulnerable. 403 or 404 where you declared 401 is unexpected_denial. If your API answers unauthenticated callers with a redirect to a login page, Veridor does not follow it; the 3xx is the observed status, so declare it.

Read allowed, write refused

The rule. An identity may read an object, or is shown nothing at all, but it must not change it.

A write is where a status code alone misleads most. A target can answer 403 to a cross-tenant DELETE and delete the row anyway. A single check would match that 403 and go green. The way to test a refused write is to read the object afterwards, as someone who may, and see whether it is still there. That needs a sequence, which requires "version": 2. This fragment replaces checks in the preamble, and version becomes 2:

"sequences": [
  {
    "id": "note-cross-tenant-delete",
    "steps": [
      {"role": "setup", "as": "invoice_owner",
       "request": {"method": "POST", "path": "/api/v2/notes",
                   "json": {"title": "fixture", "provenance": "${marker:invoice_owner}"}},
       "expect": {"status": [201]},
       "capture": {"note_id": "$.id"}},
      {"role": "assert", "as": "invoice_owner",
       "request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
       "expect": {"status": [200], "contains_marker": "invoice_owner"}},
      {"role": "assert", "as": "other_tenant",
       "request": {"method": "DELETE", "path": "/api/v2/notes/${note_id}"},
       "expect": {"status": [403, 404]}},
      {"role": "assert", "as": "invoice_owner",
       "request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
       "expect": {"status": [200], "contains_marker": "invoice_owner"}}
    ],
    "teardown": [
      {"as": "invoice_owner",
       "request": {"method": "DELETE", "path": "/api/v2/notes/${note_id}"},
       "expect": {"status": [204, 404]}}
    ]
  }
]

The owner creates a note with its marker planted through ${marker:invoice_owner}, reads it back to prove the marker is there, the other tenant tries to delete it, and the owner reads it again. In the demo only a note's owner may read it, so the reads are the owner's. In your API, if the identity under test may read the object, add an assert step as that identity with a GET and [200] before its write.

Against the default demo, exit code 0:

veridor: write mode -- up to 3 mutating request(s) (DELETE, POST) will be issued against 127.0.0.1. Do not run this against production. Pass --read-only to refuse instead, before anything is sent.
…
Sequences: 1 executed, 4/4 steps matched their declaration, 0 aborted, 0 teardown failure(s)
  - note-cross-tenant-delete: completed (teardown: clean)

Against --vulnerable, where the demo answers the cross-tenant DELETE with 403 and deletes the note, exit code 1. The DELETE step matched its declaration; the read after it did not:

Sequences: 1 executed, 3/4 steps matched their declaration, 0 aborted, 0 teardown failure(s)
  - note-cross-tenant-delete: completed (teardown: clean)
- [vulnerable (confirmed)] note-cross-tenant-delete / invoice_owner: Identity 'invoice_owner' received HTTP 404 (declared: one of [200]). 'invoice_owner''s own marker was not observed, and step 2 issued DELETE as 'other_tenant' against the same path this step reads. 'invoice_owner''s marker was observed at step 1 of this sequence and is absent now.

What a finding means here. A 2xx to the other tenant's write is vulnerable; for a mutating request a 204 with no body counts, because the success is the harm. A finding on the owner's later read, as above, is reported under VERIDOR-EFFECT-001: the write took effect across the boundary although its own response said it was refused. Veridor does not undo a write the attacker step succeeded in; it cannot know what that write did.

Writing to the target safely

The rule. Requests that change state go only to an environment you can recreate, and a run against anything else cannot write at all.

What the code requires before run sends a mutating method (POST, PUT, PATCH, DELETE): nothing beyond the declaration. There is no acknowledgement flag for run. What it does instead:

  • Before the first request it prints the write-mode line shown above, naming the verbs and an upper bound on the number of mutating requests.
  • The run document records what was written. For the sequence above:

    "mutation": {"verbs": ["DELETE", "POST"], "requests_issued": 3}
    

    The block is absent when the configuration cannot write. It was absent from the run of the tenant-isolation file above.

  • No fabricated liveness credential is sent with a mutating request or inside a sequence. A denial inside a sequence counts as unproven unless something else in the run proves that identity's credential live, here its marker_probe.

  • Teardown runs on a normal exit, on an error, on Ctrl-C and on SIGTERM. A SIGKILL can still leave fixtures behind (writing to the target).

--read-only is the refusal. It fails the load with exit 2 if any request the configuration can issue uses a method outside GET, HEAD and OPTIONS, and it lists the offenders in one message: the first 50 one per line, and any beyond that as a count (... and N more). This file has three:

veridor run --config write.json --read-only
veridor: --read-only refuses POST in sequence 'note-cross-tenant-delete' step 0, and 2 other mutating declaration(s). All 3 are listed below (verbs: DELETE, POST); nothing has been sent, and together they declare 3 request(s) -- more under `mutate`, which re-issues each one per probe:
  POST in sequence 'note-cross-tenant-delete' step 0
  DELETE in sequence 'note-cross-tenant-delete' step 2
  DELETE in sequence 'note-cross-tenant-delete' teardown entry 0

Use it for any target you did not provision yourself. Keep the write patterns in a separate file that only runs against an ephemeral environment, and run the read-only patterns everywhere else with --read-only.

The other verbs are stricter. map sends only GET and HEAD unless each mutating verb is named with --allow-mutating-method and --acknowledge-mutating carries the exact acknowledgement string (map is read-only by default). veridor verify on a capsule that writes refuses to run without --base-url (verifying a capsule). mutate re-issues every request you declared, writes included (mutate).

List endpoints that must not include another tenant's rows

The rule. A collection or export returns only the caller's own rows.

A list answers 200 to everyone entitled to call it, so the status says nothing about which rows came back. The marker does. Plant the owner's marker in a row the owner can list, assert it is present for the owner, and assert it is absent for everyone else.

There are two shapes, depending on whether the other tenant may call the list at all. If it may not, declare its denial:

"checks": [
  {
    "id": "export-tenant-rows",
    "request": {"method": "GET", "path": "/api/exports/acme.json"},
    "expect": {
      "invoice_owner": {"status": [200], "contains_marker": "invoice_owner"},
      "other_tenant": {"status": [404], "excludes_marker": "invoice_owner"}
    }
  }
]

Against --vulnerable, where the demo serves the export to the other tenant with an empty row list, exit code 1:

- [vulnerable (suspected)] export-tenant-rows / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [404]). 'invoice_owner''s marker was not observed (response did not contain it), so data exposure is unproven.

If every tenant may call the list and each should see only its own rows, declare [200] for the other tenant and keep the exclusion:

"checks": [
  {
    "id": "export-no-foreign-rows",
    "request": {"method": "GET", "path": "/api/exports/acme.json"},
    "expect": {
      "invoice_owner": {"status": [200], "contains_marker": "invoice_owner"},
      "other_tenant": {"status": [200], "excludes_marker": "invoice_owner"}
    }
  }
]

Against --vulnerable, the other tenant's 200 with no owner rows matched, exit code 0. Against the default demo, which refuses this export to the other tenant, the same file reported unexpected_denial (suspected) and exited 1: the declaration said the list was open to it, and it was not. When a 200 response does carry the owner's marker, the finding is vulnerable (confirmed) under VERIDOR-LEAK-001. Measured by declaring [200] with the same exclusion on /api/invoices/inv-100 against --vulnerable:

- [vulnerable (confirmed)] invoice-no-foreign-rows / other_tenant: Identity 'other_tenant' received the declared HTTP 200, but the response carried 'invoice_owner''s marker: …

What a finding means here. confirmed: a row, or some other part of the response, carried the owner's marker. suspected: the other tenant was served where you declared it would not be, and no owner row was seen. A marker only proves anything about rows that carry it, so plant it in the field your list actually returns. If the owner's own list comes back without the marker, the exclusion cannot be tested at that path and the probe is inconclusive.

IDs in the query string or the path

The rule. Whichever way the API names the object, the object belongs to someone, and the check names one real object.

A request block has five keys, method, path, headers, json and body. There is no separate key for query parameters. One is refused before anything is sent, with exit 2. In a check with the id invoice-by-query:

"request": {"method": "GET", "path": "/api/invoices", "params": {"id": "inv-100"}}
veridor: check 'invoice-by-query' request has unsupported keys: params (put 'params' in 'path' as a query string, e.g. '/items?tenant=acme') Veridor reads body, headers, json, method, path here and nothing else, so what an unknown key holds would never be sent. Nothing has been sent.

Write the query into path:

"checks": [
  {
    "id": "invoice-by-query",
    "request": {"method": "GET", "path": "/api/invoices?id=inv-100"},
    "expect": {
      "invoice_owner": {"status": [200]},
      "other_tenant": {"status": [403, 404]}
    }
  }
]

The demo has no route that reads an id from the query string, so a run of this check against it draws 404 for every caller and says nothing about query handling. Two things that run did show, and both hold for any target:

  • The query value is not written to disk. The run document stored the URL as http://127.0.0.1:18802/api/invoices?id=%5BREDACTED%5D.
  • For boundary coverage, the query is dropped before the path is matched against the specification, so this check counts toward GET /api/invoices, the collection, and not GET /api/invoices/{id}. Measured with veridor coverage --config query.json --openapi fixtures/openapi.demo.json, the demo specification, which exited 0 and listed GET /api/invoices/{id} among the operations with no cross-identity denial asserted.

A path id needs nothing special: write the real id into the path. A placeholder such as /api/invoices/{id} is refused by run, because Veridor never enumerates ids (request). If the id comes from an environment variable, ${NAME} works in a path, and a value of 8 characters or more is written back as ${NAME} in every report (${ENV_VAR} substitution).

What a finding means here. The same as for the pattern you are expressing, tenant isolation or ownership. The query form adds one more way to be wrong: if the API ignores the parameter and returns a default object, the check is testing that object. A marker in the object you meant to name catches that, because the owner's contains_marker fails when the wrong object comes back.

Both 403 and 404 are acceptable denials

The rule. The caller must be refused, and you do not mind whether the API says "forbidden" or "not found".

List both statuses:

"checks": [
  {
    "id": "invoice-either-denial",
    "request": {"method": "GET", "path": "/api/invoices/inv-100"},
    "expect": {
      "invoice_owner": {"status": [200], "contains_marker": "invoice_owner"},
      "other_tenant": {"status": [403, 404], "excludes_marker": "invoice_owner"}
    }
  }
]

Against the default demo, which answers 404, exit code 0. Against --vulnerable, exit code 1:

- [vulnerable (confirmed)] invoice-either-denial / other_tenant: Identity 'other_tenant' received HTTP 200 (declared: one of [403, 404]) and the response carried 'invoice_owner''s marker: …

[403, 404] is still a statement that the caller is refused. Adding an access status to the same list is not. A list that accepts both access and denial matches whatever the target does, and Veridor reports it as non-asserting rather than as a pass:

"checks": [
  {
    "id": "invoice-accepts-anything",
    "request": {"method": "GET", "path": "/api/invoices/inv-100"},
    "expect": {
      "invoice_owner": {"status": [200]},
      "other_tenant": {"status": [200, 403, 404]}
    }
  }
]
Veridor run 20261110T…: 2/2 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
…
Non-asserting: 1 expectation(s) accept both access and denial, so they assert nothing about the boundary:
  - invoice-accepts-anything / other_tenant: expectation [200, 403, 404] for identity 'other_tenant' on check 'invoice-accepts-anything' accepts both access and denial, so it asserts nothing about the boundary; it matches whatever the target does

That run exited 0; with --require-complete the same file exited 3.

Keep 401 out of a tenant's denial list unless you mean it. A tenant presents a credential, so a 401 to it says the credential was not accepted, which is a different fact from a refusal to that tenant. The liveness controls exist to catch exactly that case.

Boundary coverage counts 403 and 404 as authorization denials by default. If your API refuses with another 4xx, such as 410, coverage.denial_statuses replaces that list for coverage, run --openapi and map; it never changes what run grades (top-level keys).

What a finding means here. vulnerable: the caller was served. A status outside the list that is not 2xx, for example 400 or 405, is unexpected_denial. A 429, a 407, a 5xx or a timeout is inconclusive: not an answer from the authorization layer, so not a finding in either direction (verdicts). The exception is a leak: a 429, 407 or 5xx whose body carries the marker an excludes_marker names is graded like any other status outside the list, so a 500 carrying the owner's marker is vulnerable (confirmed).

Next

  • Configuration reference for every key these patterns use and every refusal they can draw.
  • Sessions and CSRF for identities that log in with a cookie instead of a static header.
  • mutate to find out whether these checks would catch a regression, or match whatever the target does.