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
GraphQL — GraphqlPlugin::owned_by
RLS — umbral-rls
Storage — media_access_owner
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 noscope/owned_by/owned_by_for_writes/owned_via. This also covers auto-exposed models — any model registered with.model::<T>()that never got an explicitResourceConfigat 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
mutablemodel with noowned_by. Read-only exposed models are never flagged. - Storage — a media route mounted with no
media_access*gate and nomedia_signed_urls.
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.
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.