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

Media access control

Gate the media GET route with a cache-first Decision closure - allow or deny per caller and key, with tag-based invalidation driven by your own models.

StoragePlugin::media_access_cached gates who can fetch an uploaded file. You write one closure that inspects the caller and the file key and returns a Decision; the wrapper checks the ambient TaggedCache before running your closure, so a repeated request for the same (caller, key) pair is served from cache instead of re-running your DB lookups. The framework owns the cache key (it length-prefixes each component - mediaacc:<len>:<file_key>:<len>:<caller_id> - so two distinct pairs can never collide onto one string) - you never supply one.

Example

Code
rust
use std::time::Duration;
use umbral::prelude::*;
use umbral_cache::{Cache, CachePlugin};
use umbral_storage::{Decision, MediaCaller, StoragePlugin};
 
App::builder()
.plugin(CachePlugin::new(Cache::memory())) // installs the ambient TaggedCache
.plugin(
StoragePlugin::new()
.media("/media", "./media")
.media_access_cached(|caller: MediaCaller, key: &str| async move {
let is_member = channel_membership(&caller, key).await;
Decision::of(is_member).depends_on(["channel-membership".into()])
})
// bust the decision whenever a Membership row changes
.media_invalidate_on::<Membership>(|m| vec!["channel-membership".into()]),
)
.build()?;

No ambient cache installed (no CachePlugin, or installed too late) - every request recomputes; the failure mode is always "recompute," never "silently allow."

Decision

Decision::allow() / Decision::deny() / Decision::of(bool) build a cacheable-by-default decision. Chain .depends_on([tags]) to tie it to cache tags, .ttl(duration) to override the default TTL, or .no_cache() to force a recompute on every request.

MediaCaller

The closure receives a MediaCaller resolved from the app's authentication backend: user_id(), is_authenticated(), is_superuser, is_staff, and has_role(&str) against roles. roles follows the extras["roles"] convention - a JSON array of strings on Identity::extras that a custom Authentication implementation populates for role-based gates.

Info
The cached path still resolves the caller's `Identity` (session/auth lookup) on every request - only the in-closure membership/DB work is skipped on a cache hit.
Warning

Cache keys are identity-keyed, not header-keyed. The wrapper derives mediaacc:{key.len()}:{key}:{caller_id.len()}:{caller_id} from the resolved Identity (user_id(), or "anon" for an unauthenticated caller) - never from raw request headers. If you need to gate and cache a non-session identity, such as an agent API key, don't reach for a raw-headers escape hatch: resolve that key to its own distinct Identity in a custom Authentication backend (see the auth extension seam, gaps4 #42), for example user_id = "agent:". That identity then flows through MediaCaller like any other and gets its own safe cache bucket - one agent key can never collide with, or be served, another caller's cached decision.

Presets

For the common cases, skip the closure entirely:

  • media_access_staff() - allow staff and superusers, deny everyone else.
  • media_access_roles(["editor", "reviewer"]) - allow a superuser or a caller whose roles contains any of the given roles.

Both are sugar over media_access_cached and get the same cache-first behavior.

Warning

media_access_roles reads roles from Identity::extras["roles"]. The built-in SessionAuthentication does not populate extras["roles"], so with the default auth backend this preset denies everyone except superusers. To use it, populate extras["roles"] (a JSON string array) from a custom Authentication implementation.

Invalidation and staleness

media_invalidate_on::<M>(...) wires post_save/post_delete signals per row. A bulk ORM write - Model::objects().filter(...).delete(), or the bulk-update equivalent - fires the bulk signal instead, which is not wired to invalidation, so a cached decision can stay stale for up to the TTL (default 60s) after a bulk change. If you need sub-TTL revocation freshness, revoke through per-row saves/deletes, or set a shorter .ttl(...).

Warning

On delete, the post_delete payload carries only the deleted row's primary key, not its other columns. So a media_invalidate_on map fn that derives its tags from a non-PK field (e.g. |m| vec![format!("chan:{}", m.channel)]) can't compute those tags on a delete and won't bust — the cached decision then rides its TTL. Key delete-invalidation tags on the PK (|m| vec![format!("member:{}", m.id)]), or bust from a post_save on a row that still carries the full data. Save/update paths carry the full row and are unaffected.

See also

Full design in docs/superpowers/specs/2026-09-20-media-access-redesign-design.md.

storagemediacachesecurityauthorization