This version is in beta. Some features may change before release.

Object-level authorization (IDOR)

The defense-in-depth story for row ownership across REST, GraphQL, RLS, and storage - which layer scopes which surface, the boot check that flags an unscoped write endpoint, and how to silence it deliberately.

An Insecure Direct Object Reference (IDOR) is the bug where a caller who is allowed to use an endpoint can reach rows they don't own by supplying someone else's id. Model-level permission answers "may this caller write orders?"; it does not answer "may this caller write this order?". Object-level authorization is the second question, and umbral answers it per surface.

The framework already ships the scoping tools. What this page adds is one mental model for all of them, plus a boot check that tells you when a write surface has none.

Which layer scopes which surface

REST — ResourceConfig::owned_by / scope

Restricts every built-in CRUD action to the rows the caller owns. App-level, per resource.

GraphQL — GraphqlPlugin::owned_by

Adds `WHERE owner = caller` to every mutation on a model. App-level, per model.

RLS — umbral-rls

Postgres row-level security: the database itself refuses out-of-scope rows. Defense in depth, enforced below the app.

Storage — media_access_owner

Gates the media GET route so a file is served only to its recorded owner. Otherwise any key is a direct, unauthenticated fetch.

Reach for the app-level scope (owned_by) as the baseline on every write surface. Add RLS when you want the database to enforce ownership even against a bug in the app or a second service hitting the same tables. Gate storage whenever uploads are private (invoices, IDs, tenant documents) rather than public assets.

One example per layer

The boot check: security.object_scope

Because forgetting a scope is silent, umbral runs a boot-time system check that walks your REST resources, GraphQL mutations, and media routes and flags every write-enabled surface that has no object scope and no acknowledgement marker. It shares the id security.object_scope, so you can grep the whole family out of the boot log.

  • REST — a resource that exposes create/update/delete (the default, or a .views([...]) set containing a write) with no scope / owned_by / owned_by_for_writes / owned_via. This also covers auto-exposed models — any model registered with .model::<T>() that never got an explicit ResourceConfig at all — since those are served under the same write-enabled, unscoped default and are the largest slice of the IDOR surface in practice.
  • GraphQL — a mutable model with no owned_by. Read-only exposed models are never flagged.
  • Storage — a media route mounted with no media_access* gate and no media_signed_urls.

Info
The check cannot read your RLS policies: umbral-rest and umbral-graphql do not (and cannot) depend on umbral-rls. So "row security is handled by RLS" has to be declared, not inferred - that is what the acknowledgement markers below are for.

Silencing it deliberately

Two surfaces are legitimately left without an app-level scope: rows secured by an RLS policy one layer down, and genuinely public data. Declare the decision so the warning goes quiet for exactly that surface - and leaves a greppable record of why.

Strict mode

By default a missing scope is a Warning: boot continues and the message lands in the log, matching umbral's other security nudges. Set UMBRAL_STRICT_OBJECT_SCOPE=true (or strict_object_scope = true in umbral.toml) to escalate every security.object_scope finding to a boot-blocking Error - App::build() then returns BuildError::SystemCheckFailed until every write surface is either scoped or acknowledged.

Info
Strict mode is an explicit opt-in today. When the EnterprisePreset lands it will turn strict mode on for you.

Design rationale

The full design - the fn-to-closure change on SystemCheck that lets a plugin validate its own configuration, the three checks, and the deferred RLS-coverage auto-detection - is in docs/superpowers/specs/2026-08-10-idor-object-level-authorization-design.md and the REST object-scope work in arch.md.

securityrestgraphqlstorageidor