Authorization

Cedar policies over a resource hierarchy: path-scoped grants, tenant isolation, row filters and column masks.

Overview

Rustberg authorizes every request with Cedar. Cedar is a policy engine rather than a permission table: a decision comes from evaluating policies against a principal, an action and a resource, with the resource's position in a hierarchy available to the policy.

  • Default deny — absence of a permit is a denial, and so is a policy that cannot be evaluated. See When a policy cannot be evaluated.
  • Path-scoped — one policy covers a whole namespace subtree, including tables that do not exist yet.
  • Validated at startup — a policy that does not typecheck against the schema is a startup failure, not a rule that silently never matches.

The resource hierarchy

This is the part that matters. Resources form a tree, and ancestors are derived by truncating the identifier, so no lookup is needed to know where a resource sits:

Rustberg::Table::"acme␟analytics␟web␟events"
  in Rustberg::Namespace::"acme␟analytics␟web"
  in Rustberg::Namespace::"acme␟analytics"
  in Rustberg::Tenant::"acme"

A policy on Namespace::"acme␟analytics" therefore applies to everything beneath it.

One resource sits outside that tree, directly under the tenant:

Rustberg::PolicySet::"acme"
  in Rustberg::Tenant::"acme"

That is the tenant's own policy set. Policy is a protected resource like any other, or the model is circular — whoever can write policy can grant themselves anything, so "may you change the rules" has to be one of the rules. Reading it needs Read; changing it needs Manage. See administering policy.

Identifier encoding

Path segments are joined with ␟ (unit separator, U+001F), never ..

Dots are legal in namespace and table names, so a dotted identifier would be ambiguous: Namespace::"a.b" could denote the namespace ["a", "b"] or a single namespace named "a.b". In authorization, that ambiguity is a vulnerability — a policy written for one resource would silently match another. Name validation rejects ␟, so the encoding is unambiguous.

When writing policies, escape it as \u{1F}:

resource in Rustberg::Namespace::"acme\u{1F}analytics"

Schema

namespace Rustberg {
  entity Group;
  entity User in [Group] { tenant: String };

  entity Tenant { tenant: String };
  entity Namespace in [Namespace, Tenant] { tenant: String };
  entity Table in [Namespace] { tenant: String };
  entity View in [Namespace] { tenant: String };

  // The tenant's policy set: read with Read, changed with Manage. Policy is a
  // protected resource like any other, or the model is circular.
  entity PolicySet in [Tenant] { tenant: String };

  action Read, List, Create, Update, Delete, Manage
    appliesTo {
      principal: User,
      resource: [Tenant, Namespace, Table, View, PolicySet],
      context: { utc_hour: Long, source_ip?: ipaddr }
    };
}

Actions

ActionCovers
ReadLoad namespace, table or view metadata; also grants visibility — see below
ListEnumerate the catalog, or the tables and views in a namespace
CreateCreate a namespace, table or view; register a table; the destination of a rename
UpdateCommit to a table, update properties; the source of a rename
DeleteDrop a namespace, table or view
ManageAdministrative operations, including changing the policy set

Action names are capitalised and live in the Rustberg namespace. A principal's roles become Cedar groups, so Rustberg::Group::"analysts" matches a principal carrying the analysts role.

Read does double duty: besides permitting a metadata load it is what makes a resource visible, which determines both whether it appears in a listing and whether a denial reports 404 or 403. See Visibility and error codes.

A rename needs Update on the source and Create on the destination — for tables and views alike. It deliberately does not need Delete: the object is moved, not destroyed, so the writer role below can rename its own tables.

Context

AttributeTypeNotes
utc_hourLongHour of day, 0–23, always UTC
source_ipipaddrAddress the request came from. Optional — see below

Time is UTC so a policy meaning "outside business hours" does not change meaning when a replica moves region or a zone shifts for daylight saving.

source_ip is optional because there is not always an address: the library can be called in-process, where no connection exists. Guard on it with context has source_ip so the policy fails closed when the address is unknown:

// Correct: an unknown address does not satisfy the condition.
permit(
  principal in Rustberg::Group::"analysts",
  action == Rustberg::Action::"Read",
  resource in Rustberg::Tenant::"acme"
) when {
  context has source_ip && context.source_ip.isInRange(ip("10.0.0.0/8"))
};

The same guard matters even more on a forbid ... unless, where forgetting it would exempt every request whose address is unknown:

forbid(
  principal,
  action == Rustberg::Action::"Read",
  resource in Rustberg::Tenant::"prod"
) unless {
  context has source_ip && context.source_ip.isInRange(ip("10.0.0.0/8"))
};

The address is only as trustworthy as your proxy configuration. By default Rustberg uses the connected socket address and ignores X-Forwarded-For entirely. Behind a load balancer, list its subnet in [server] trusted_proxies; the forwarding chain is then walked from the right until it leaves that infrastructure, so a client cannot prepend an address of its own choosing and walk past this policy. See Trusted proxies.


Default policies

Without configuration, Rustberg loads these:

// Administrators: everything, within their own tenant.
permit(principal in Rustberg::Group::"admin", action, resource)
  when { resource.tenant == principal.tenant };

// Readers: read and list only.
permit(
  principal in Rustberg::Group::"reader",
  action in [Rustberg::Action::"Read", Rustberg::Action::"List"],
  resource
) when { resource.tenant == principal.tenant };

// Writers: read, list, create and update. Deleting is deliberately not granted.
permit(
  principal in Rustberg::Group::"writer",
  action in [
    Rustberg::Action::"Read",
    Rustberg::Action::"List",
    Rustberg::Action::"Create",
    Rustberg::Action::"Update"
  ],
  resource
) when { resource.tenant == principal.tenant };

Every rule is conditioned on the resource belonging to the caller's own tenant, so tenant isolation is part of the policy rather than a separate layer that has to be remembered.


Writing policies

Scope a group to a namespace subtree

permit(
  principal in Rustberg::Group::"analysts",
  action in [Rustberg::Action::"Read", Rustberg::Action::"List"],
  resource in Rustberg::Namespace::"acme\u{1F}analytics"
);

Grant one service account one namespace

permit(
  principal == Rustberg::User::"svc-etl",
  action in [Rustberg::Action::"Create", Rustberg::Action::"Update"],
  resource in Rustberg::Namespace::"acme\u{1F}analytics\u{1F}web"
);

Carve out an exception

forbid always wins over permit:

permit(
  principal in Rustberg::Group::"analysts",
  action == Rustberg::Action::"Read",
  resource in Rustberg::Tenant::"acme"
);

forbid(
  principal,
  action,
  resource in Rustberg::Namespace::"acme\u{1F}restricted"
);

Condition on time

permit(
  principal == Rustberg::User::"svc-batch",
  action == Rustberg::Action::"Update",
  resource in Rustberg::Namespace::"acme\u{1F}warehouse"
) when { context.utc_hour < 6 || context.utc_hour > 20 };

Row filters and column masks

Cedar has no obligations of its own, so a permit carries them as annotations:

@row_filter("{\"type\":\"eq\",\"term\":\"region\",\"value\":\"EU\"}")
@column_mask("ssn,email")
permit(
  principal in Rustberg::Group::"eu-analysts",
  action == Rustberg::Action::"Read",
  resource in Rustberg::Tenant::"acme"
);

Writing a row filter

@row_filter carries an Iceberg predicate, in the same JSON expression grammar a client sends to scan planning. That is what lets the planner apply it rather than merely report it — a SQL string could not be, because Rustberg does not implement a SQL dialect and a parser that mis-modelled a predicate would be worse than one that never looked.

A Cedar annotation is a string, so the quotes inside are escaped. The example above is this predicate:

{ "type": "eq", "term": "region", "value": "EU" }

The accepted grammar is the same one the plan endpoint reads: and, or, not, is-null, not-null, is-nan, not-nan, the comparisons, and in/not-in.

Both spellings of an operand are accepted. The Iceberg spec deprecated term/value in favour of left/right (comparisons) and child (unary and set predicates), where the operand is a reference carrying either a name or a field id:

{ "type": "eq",
  "left":  { "type": "reference", "id": 7 },
  "right": { "type": "literal", "value": "EU" } }

Prefer the field-id form in policies. A name stops matching the moment somebody renames the column, and although Rustberg refuses rather than silently dropping the restriction, a refusal still breaks reads until the policy is edited. A field id survives the rename, so the policy keeps working.

A filter that is not readable JSON, or not a predicate, is a startup failure — the same answer a policy that does not typecheck gets, and for the same reason: a restriction that silently does not apply is worse than one that refuses to install.

Startup checks everything that can be checked without a table: the JSON, the shape, and that every operator and every term is one this catalog can bind. An operator outside the grammar above — a misspelled "type": "equals" — and a term wrapped in a transform or a function application are both refused there, because neither can ever bind against any table and a filter that cannot bind is a restriction that would not apply. A reference by field id is bindable and is accepted; whether that id exists is a question about a table, so it is answered below.

What startup cannot check is the two questions that are about a table: whether a column exists, and whether a literal fits it. One policy covers tables that do not exist yet. Those are checked when the filter meets a table, and a policy filter that cannot be bound to that table selects no rows — it becomes the constant false, both in the read-restrictions a loadTable publishes and in the pruning planTableScan performs.

That is the opposite of what happens to a filter a client sends, where an unbindable term is widened away and the plan is simply a superset. The asymmetry is the point. A superset is safe for a request — the engine applies its own predicate, so extra files cost time and not correctness — and it is a weaker restriction for a policy. @row_filter("region = 'EU'") widening to "everything" is the filter silently ceasing to exist at the moment it was supposed to bite.

A broad permit is the ordinary shape — resource in Tenant::"acme" carrying a filter on region reaches every table in the tenant, and most have no region column. Filters from matching permits are OR-ed, and false is the identity of that union: the branch withholds everything it would have granted, and takes nothing else with it.

Writing a column mask

@column_mask is a comma-separated list of column names, each a full dotted path for a nested column (user.ssn). Whitespace around an entry is trimmed and an empty entry is dropped, so a trailing comma is not a column named "". A column name that itself contains a comma cannot be masked — Iceberg permits one, this annotation cannot express it, and the honest answer is to say so rather than to invent an escaping rule no Cedar tool would render.

Two behaviours are worth knowing before you write one.

A mask on a struct covers everything inside it. @column_mask("user") withholds user.ssn and every other field beneath user. You do not have to enumerate them, and a field added to the struct later is covered without a policy change. A sibling that merely starts with the same characters is not covered: user does not reach a separate top-level username, because the match is on a path segment boundary.

A mask naming a column the table never had is simply skipped. A broad permit is the ordinary way to say "mask ssn wherever it appears":

@column_mask("ssn")
permit(principal in Rustberg::Group::"analysts",
       action == Rustberg::Action::"Read",
       resource in Rustberg::Tenant::"acme");

Most tables in a tenant have no ssn. There is nothing to withhold there and nothing is disclosed, so those tables are unaffected.

But a mask naming a column the table used to have refuses the request. If the column was renamed or dropped, a scan plan and a table load both answer:

403 Forbidden
Policy withholds the column 'ssn', which this table had and no longer has — it
was renamed or dropped. The mask now withholds nothing, so the request is refused
rather than served with the restriction quietly missing. Update the policy to the
current name.

The two cases look identical in the policy — a name that does not resolve — and the table's schema history is what separates them. The distinction matters: a renamed column is still there, still readable, and a mask that silently stops matching is a policy that reads as though it protects a column and does not.

The operational consequence is worth planning for: renaming a masked column breaks reads of that table until the policy is updated. Change the annotation in the same commit as the rename, or address the column by field id, which does not move.

How they compose

A caller receives the union of what the matching permits allow, so the two annotations compose in opposite directions:

AnnotationCompositionWhy
@row_filterOREach permit grants rows; the caller sees all of them
@column_maskAND (intersection)A column is withheld only if every matching permit withholds it

If one permit masks ssn and another does not, the second permit grants ssn — so it is not masked. Unioning masks would withhold a column the caller was granted.

An unannotated permit is unrestricted — and it voids both annotations. A broad permit(principal in Group::"staff", …) carrying neither annotation removes every restriction for anyone who is also in staff. The two get there by opposite routes:

  • Row filters are OR-ed, and unrestricted OR anything is unrestricted.
  • Column masks are intersected, and a permit that withholds no column intersects every mask down to nothing.

Both are correct, and together they are the most likely way a deployment accidentally grants everything.

Because nothing looks wrong in the policy file when that happens, Rustberg says so twice:

WhenWhat it reportsPrecision
Policy loadEvery permit missing an annotation, asked once per annotation kind, when the set also carries that kindOver-reports — it does not check whether they can ever meet
A requestWhich permits voided which restriction, @row_filter and @column_mask alikeExact — no false positives

"Unannotated" is per restriction, not per permit. This is the shape most deployments hit first, and it is invisible if you look at permits rather than at restrictions.

Both permits below are annotated, so neither looks broad — but each is unannotated for the other's kind:

@row_filter("{\"type\":\"eq\",\"term\":\"region\",\"value\":\"EU\"}")
permit(principal in Rustberg::Group::"analysts",
       action == Rustberg::Action::"Read",
       resource in Rustberg::Tenant::"acme");

@column_mask("ssn")
permit(principal in Rustberg::Group::"analysts",
       action == Rustberg::Action::"Read",
       resource in Rustberg::Tenant::"acme");

That is how anyone writes "EU rows, and hide ssn". The first permit grants every column, the second grants every row, and permits grant: the analyst sees every row and every column. Rustberg names both at load, and again on the first request that demonstrates it.

Write it as one permit carrying both annotations instead:

@row_filter("{\"type\":\"eq\",\"term\":\"region\",\"value\":\"EU\"}")
@column_mask("ssn")
permit(principal in Rustberg::Group::"analysts",
       action == Rustberg::Action::"Read",
       resource in Rustberg::Tenant::"acme");

The load-time check is deliberately crude: it reports that some permit carries an annotation while some other permit does not, without asking whether the two can ever match the same request. That question is decidable — Cedar is designed to be analyzable, and the symbolic compiler answers it exactly, with a counterexample request — but it needs an SMT solver, which is an external binary this server deliberately does not carry. Running it belongs in a policy pipeline before a revision is installed, not in the request path of a catalog.

The request-time warning needs no analysis at all: Cedar has already reported which policies matched that request, so if one carried an annotation and another did not, the restriction was voided — as a fact, with no false positives.

Both name the offending permits by policy id. The request-time warning is emitted once per resource per restriction per policy set; editing the policies reports again, since the thing being warned about has changed.

A worked example of the mask half, which is the quieter one:

// Intended: analysts see everything except ssn.
@column_mask("ssn")
permit(
  principal in Rustberg::Group::"analysts",
  action == Rustberg::Action::"Read",
  resource in Rustberg::Tenant::"acme"
);

// Also grants analysts, and withholds nothing. `ssn` is now visible.
permit(
  principal in Rustberg::Group::"staff",
  action == Rustberg::Action::"Read",
  resource in Rustberg::Tenant::"acme"
);

An analyst who is also in staff matches both, the intersection of {ssn} and {} is {}, and no column is withheld. The fix is to narrow the second permit, or to annotate it — not to add a forbid, which would deny the read outright rather than mask one column.

What an annotation actually does

Two things, and they are worth separating.

It withholds every broad form of storage access. A storage credential is prefix-shaped — the narrowest one Rustberg can mint covers the table's location — so an engine holding it reads every row and every column under that prefix whatever the policy says. A signature is table-shaped for the same reason. Given the choice between granting one while calling the filter enforced, and declining, Rustberg declines — and delegates through the plan instead, where the scope can be exact:

RequestResult
loadTable on an annotated table200, metadata returned, no storage-credentials and no signer configuration; config sets scan-planning-mode: server
GET .../credentials on an annotated table403, naming the restriction
POST .../sign on an annotated table403, naming the restriction
POST .../plan on an annotated table200, with a pre-signed URL per file the filter selected (details)
POST .../plan, @row_filter naming a column this table lacks200, selecting no files — the same false the table's read-restrictions publish
POST .../plan, @column_mask over a partition column403, naming the column
Any of these, on an unannotated tableAccess granted normally

The refusal names masked columns but never quotes a filter expression — a filter embeds the values it compares against, and echoing it to a caller that was just refused would leak the policy's contents.

It prunes the scan plan. A @row_filter is an Iceberg predicate, so planTableScan conjoins it with the client's own filter: a restricted caller is told about fewer files, and the residual-filter on each task carries both halves. stats-fields naming a masked column is refused, because column bounds are the column's minimum and maximum values — and that refusal is matched on the resolved column without regard to case, so case-sensitive: false is not a way around it.

A mask names a column by its full dotted path, and every check compares that path: @column_mask("user.ssn") withholds user.ssn and leaves an unrelated top-level ssn alone. Comparing the leaf would get both wrong in opposite directions, and the dangerous one is the nested column going on publishing its bounds while the policy file reads as though it were masked.

One mask a plan cannot carry, and there the plan is refused. A mask over a column the table is partitioned on leaks twice, and neither leak can be gated: every file carries its partition tuple, and Iceberg writes partition values into the object key, so …/region=EU/00000-0-….parquet names the value again in the file-path the plan exists to hand over. planTableScan answers 403 naming the column — an answer that cannot carry the restriction is withheld rather than served with the restriction quietly missing.

Every transform counts, not only identity: a tuple naming bucket 7 of 16 still narrows the value. So does every partition spec the table has had, since a snapshot holds files written under specs it has evolved away from. A mask over any other column plans normally.

So an engine that plans through Rustberg reads only permitted rows. An engine carrying its own storage credentials reads the table unfiltered, and nothing here changes that — which is why the filter is selection rather than enforcement until the two are tied together. See security.

Partition on the security boundary

This is the most important practical guidance on this page, and it decides whether a filter can ever become real enforcement.

A catalog enforces a row filter by not handing over files. That works exactly when the filter's columns are partitioned with an identity transform: if tenant_id is an identity partition field, another tenant's rows live in different files, and withholding those files is enforcement that holds against any engine — hostile or not.

Otherwise permitted and forbidden rows share Parquet row groups. No file-level decision can separate them, and the best any catalog can do is deliver the file and a residual predicate. Enforcement is then cooperative: it holds only because the engine chose to apply it. AWS Lake Formation is explicit about the same limit.

A transform is not a boundary. days(ts) puts a whole day in one file, bucket(16, id) puts a sixteenth of all ids in one, truncate(4, region) puts EU and EUROPE in one. A filter of ts = '2024-01-01T05:00' against days(ts) selects a file whose other rows are forbidden — so pruning helps and enforcement does not follow. Only identity makes the partition value and the column value the same thing.

Both look identical in the policy file, so Rustberg tells you which you have:

WARN Row filter references columns this table does not partition on by identity,
     so it cannot be enforced by withholding files — a transformed partition puts
     permitted and forbidden rows in the same file. A scan plan applies the filter
     and returns it as the residual, so a cooperating engine honours it, but an
     engine using its own storage credentials reads the table unfiltered.
     table=analytics.events columns=["email"] policy_set_version=9f2c41ab7d0e5163

The columns are read exactly, not guessed at: a filter is a JSON predicate, so the grammar says where a column reference can appear.

Emitted at most once per table per policy set — editing the policies reports again, since you have changed the thing the warning is about.

FilterTable partitioned onEnforceable by withholding files?
tenant_id == "acme"identity(tenant_id)Yes — architectural
region == "EU"identity(region), field named regYes — the source column is compared, not the field's name
ts == "2024-01-01T05:00"days(ts)No — warned; the day's other rows are in the same file
id == 42bucket(16, id)No — warned; a sixteenth of all ids share the file
anythingnothing, or another columnNo — warned

The rule is deliberately conservative: a range filter whose bounds fall exactly on a transform's boundaries — ts >= '2024-01-01' AND ts < '2024-01-02' against days(ts) — is enforceable and is warned about anyway. Proving that needs a predicate model over transforms the Iceberg expression types do not carry, and the error is worth making in this direction: an over-warning costs you a sentence, an under-warning costs you a boundary you believed you had.


Protecting a resource from deletion

rustberg.protected = "true" on a table, view or namespace refuses dropTable, dropView, dropNamespace and a purge with 409 until it is cleared. See the API reference.

It is an ordinary property, so whoever can set it can clear it: it stops the accident, not the adversary. The rule that stops an adversary is a forbid, which the holder of the property cannot edit:

forbid(
  principal,
  action == Rustberg::Action::"Delete",
  resource in Rustberg::Namespace::"acme\u{1F}prod"
) unless {
  principal in Rustberg::Group::"platform-admin"
};

The two compose: the property catches the mistake before it reaches the authorizer, and the policy catches it when someone means it.


Administering policy at runtime

Policy is stored as a versioned, append-only log and can be changed without restarting anything. A change is a new revision; the old one is never edited, which is what keeps an audit record from last month reproducible — its policy_set_version still names something that exists.

GET /management/v1/policiesThe policy set in force
PUT /management/v1/policiesReplace it, as a new revision
GET /management/v1/policies/historyWho changed it, when, and why
POST /management/v1/policies/rollbackRe-apply an earlier revision

These live under /management/v1, not /v1: GET /v1/config claims to describe the Iceberg API completely, and administration is not part of that contract.

Changing the rules

curl -X PUT https://rustberg.example.com/management/v1/policies \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "source": "permit(principal in Rustberg::Group::\"admin\", action, resource) when { resource.tenant == principal.tenant };",
        "note": "revoke contractor access"
      }'

The submitted text replaces the policy set; nothing is merged. Silently unioning your policy with rules you did not write is how an authorization system comes to permit more than its operator believes.

The change takes effect immediately on the replica that received it, and within a few seconds on the others — each polls the store and swaps atomically. A request already being evaluated finishes against the set it started with, so no decision ever sees a half-applied change.

Two guardrails

A policy set that does not typecheck is refused (400), not installed. An invalid permit is access that silently disappears; an invalid forbid is a restriction that silently does not apply.

A policy set that would lock you out is refused (400). If the submitted rules would leave you unable to change policy again, you could not undo the change, and the only way back is a restart against a seed file. Deliberate handover is still possible — grant someone else Manage first — but doing it by accident is not.

What a replica reports

GET /management/v1/policies answers for the replica that received the request, which during a rolling change is not always the newest revision:

{
  "sequence": 7,
  "latest_sequence": 8,
  "version": "9f2c41ab7d0e5163",
  "source": "...",
  "author": "alice"
}
FieldMeans
sequenceThe revision this replica is enforcing
latest_sequenceThe newest revision in the store

Equal means converged. latest_sequence higher means this replica has not caught up yet — ordinarily for a second or two after a change, and indefinitely if it cannot reach the store, which is exactly the case worth being able to see.

Reporting the store's newest as though it were in force would answer a question nobody asked: an operator checking a pod wants to know what that pod does, and a replica that had stopped converging would look identical to one that had.

# Which replicas have converged? The image is distroless — no shell and no curl
# to `kubectl exec` — so ask each pod from outside, by its own address.
for pod in $(kubectl get pods -l app=rustberg -o name); do
  ip=$(kubectl get "$pod" -o jsonpath='{.status.podIP}')
  curl -sf -H "Authorization: Bearer $ADMIN_KEY" \
    "http://$ip:8000/management/v1/policies" \
    | jq -c '{pod: "'"$pod"'", enforcing: .sequence, latest: .latest_sequence}'
done

Run that from inside the cluster — a debug pod, or a shell with kubectl port-forward per pod. Asking the Service would answer for whichever replica the load balancer picked, which is the one thing this question is not about.

Sequence and version

Two identifiers, and they answer different questions:

MeansChanges when
sequenceWhen: revision 7 came after 6Every write, including a rollback
versionWhat: a content hash of the rulesOnly when the rules differ

A rollback appends a new sequence carrying an old version — the log records that a rollback happened, while the version correctly says the rules are the ones from before. Neither identifier alone could express that.

version is the same string audit records carry, so a decision can be traced to the exact text that produced it.

Where policy comes from at startup

server.auth.policy_file seeds an empty store and is then no longer authoritative. If the file won on every start, every change made through the API would vanish the moment a pod restarted.

When the two diverge, startup says so:

WARN The configured policy file differs from the stored policy set, and the
     STORE is authoritative. The file seeds an empty store only. Change policy
     through PUT /management/v1/policies.

A server whose effective policy set contains no policies refuses to start: it would accept nobody, including anyone trying to repair it.

Policy administration requires a deployment that evaluates policy and has somewhere to store revisions. Both are automatic for a server Rustberg starts itself. The endpoints answer 501 when either is missing:

  • under --no-auth, where no policy is consulted;
  • when the library's with_catalog supplies a catalog but with_policy_store is not also given — a catalog from outside is not required to store policy. A redb or Postgres catalog implements both, so one object can serve as both.

When a policy cannot be evaluated

Validation at startup catches a policy that cannot be typed. It does not catch one that types fine and raises when a request reaches it — Long arithmetic that overflows is the plainest case, and it typechecks because Long + Long is a Long.

Cedar's rule for such a policy is that it contributes nothing to the decision. Taken literally that fails open: a forbid that raises is not applied, and a request somebody wrote a rule to stop is allowed by whatever permit still stands.

So Rustberg reads the evaluation diagnostics, and any policy that failed to evaluate denies the request. It is an ordinary denial, so the caller sees 404 or 403 by the visibility rule like any other — the cause is in the policy set, not in what the caller may see, and the server log is where it is named:

ERROR A policy failed to evaluate. Cedar skips such a policy, so a forbid that
      errors would not have been applied; denying instead.
      errors=["while evaluating policy `policy1`: integer overflow"]

The fix is always an edit to the named policy.


Visibility and error codes

Read determines whether a caller can see a resource, and that drives two behaviours.

Listings filter

listNamespaces, listTables and listViews return only what the caller may read. A table a caller has no grant on does not appear, so the caller never learns it exists — and the listing agrees with what a subsequent load would answer.

Filtering happens before the page is cut, so a page is never short and never comes back empty while permitted rows remain further on. The cost is one policy evaluation per row scanned rather than per row returned; evaluation is microseconds against an in-memory entity set, and no extra I/O is involved.

A denial you cannot see is a 404

Identifying a resource requires resolving which tenant owns its namespace, which happens before the policy decision. If a forbidden resource answered 403 while a missing one answered 404, the status code would let any authenticated caller enumerate other tenants' namespaces and tables.

Caller can read it?Action permitted?Answer
— (does not exist)—404
nono404
yesno403
yesyesproceeds

So 404 always means you cannot see this, which is equally true whether or not it exists. 403 only ever tells a caller something it already knew, which keeps ordinary permission errors diagnosable.

A resource named in a query string is still a resource. GET /v1/namespaces?parent=X reads as a filter rather than as naming something, and it is the one request that reaches the backend without a resource in the path. It goes through the same guard a path does: without it, a parent that does not exist answers 404 while a parent belonging to another tenant answers 200 with an empty list — the page reveals nothing and the status code reveals everything, one guess at a time.

When debugging, 404 may mean "no Read grant". If a table you know exists reports 404, check for a missing Read permit before checking for a typo in the name.

It never means "the backend is down". A catalog that cannot answer — an unreachable database, a mount whose remote is unavailable — is a 5xx. Only those four rows above produce a 404, so an intermittent 404 is a real change to the resource or to policy, not an outage. Nothing is given away by keeping the two apart: a store failure is the same failure for every caller, permitted or not, so unlike an existence check it is not an oracle.


Troubleshooting

Every request is denied. Default deny is working and no policy matched. Check that the principal's roles map to the groups your policies name — GET /auth/context reports the roles the credential actually carries, which is where a analyst vs analysts mismatch becomes visible.

A table exists but returns 404. Most likely no policy permits Read on it. See Visibility and error codes.

A policy seems to be ignored. Check the identifier encoding — segments join with \u{1F}, not .. A policy naming Namespace::"acme.analytics" matches a namespace literally called acme.analytics, not the nested one.

An address-conditioned policy never matches. Guard with context has source_ip, and set [server] trusted_proxies if Rustberg runs behind a proxy — without it the address is the proxy's, not the client's.

No credentials come back for one table. Check whether a matching permit carries @row_filter or @column_mask; annotated tables are deliberately not credentialed. See What an annotation actually does.

Startup fails with a validation error. A policy references an entity type, action or attribute the schema does not define. This is deliberate: such a policy would otherwise never match, silently granting nothing (for a permit) or restricting nothing (for a forbid).

Every request to one resource is denied, and the log names a policy that "failed to evaluate". See below — the policy typechecks but raises at run time, and the request is denied rather than decided without it.