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
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.
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 whoserolescontains any of the given roles.
Both are sugar over media_access_cached and get the same cache-first behavior.
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(...).
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.