diff --git a/crates/buzz-db/src/api_token.rs b/crates/buzz-db/src/api_token.rs index 9d3f17757..f105d681a 100644 --- a/crates/buzz-db/src/api_token.rs +++ b/crates/buzz-db/src/api_token.rs @@ -8,8 +8,13 @@ use crate::error::{DbError, Result}; /// Create a new API token record. The caller is responsible for generating /// the raw token and computing its SHA-256 hash. +/// +/// `community_id` is row zero: every token is scoped to a community, derived +/// from the request's resolved tenant — never client-supplied here. +#[allow(clippy::too_many_arguments)] pub async fn create_api_token( pool: &PgPool, + community_id: Uuid, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -32,10 +37,12 @@ pub async fn create_api_token( sqlx::query( r#" - INSERT INTO api_tokens (id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO api_tokens + (community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "#, ) + .bind(community_id) .bind(id) .bind(token_hash) .bind(owner_pubkey) @@ -54,9 +61,14 @@ pub async fn create_api_token( /// Uses a subquery so the check and insert are atomic -- /// no TOCTOU race between a separate count query and the insert. /// +/// The 10-token limit is per (community, owner) — a user's quota is scoped to +/// their community, never global. +/// /// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the 10-token limit is exceeded. +#[allow(clippy::too_many_arguments)] pub async fn create_api_token_if_under_limit( pool: &PgPool, + community_id: Uuid, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -76,22 +88,25 @@ pub async fn create_api_token_if_under_limit( }) .transpose()?; - // Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10. - // The subquery and insert execute atomically -- no separate count + insert race. + // Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10 + // **for this (community, owner) pair**. The subquery and insert execute atomically -- + // no separate count + insert race. let result = sqlx::query( r#" INSERT INTO api_tokens - (id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint) - SELECT $1, $2, $3, $4, $5, $6, $7, TRUE + (community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, TRUE WHERE ( SELECT COUNT(*) FROM api_tokens - WHERE owner_pubkey = $8 + WHERE community_id = $1 + AND owner_pubkey = $9 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ) < 10 "#, ) + .bind(community_id) .bind(id) .bind(token_hash) .bind(owner_pubkey) @@ -111,7 +126,16 @@ pub async fn create_api_token_if_under_limit( Ok(Some(id)) } -/// Look up an API token by its SHA-256 hash, **including revoked tokens**. +/// Look up an API token by its SHA-256 hash, **including revoked tokens**, +/// scoped to the request's community. +/// +/// The lookup is keyed on `(community_id, token_hash)` — the same key the +/// storage UNIQUE index uses. This closes the row-44 conformance obligation: +/// a token minted in community A must never authorize in community B, even +/// if (by birthday-style collision or adversarial mint) the same hash exists +/// in both. The UNIQUE index is a *storage* guarantee; this `AND community_id` +/// clause is the *query* guarantee — both must hold for the property to be +/// load-bearing under all schemas. /// /// Unlike [`crate::Db::get_api_token_by_hash`] (which filters `revoked_at IS NULL`), /// this function returns the full record regardless of revocation status. @@ -119,6 +143,7 @@ pub async fn create_api_token_if_under_limit( /// error responses rather than treating both as "not found". pub async fn get_api_token_by_hash_including_revoked( pool: &PgPool, + community_id: Uuid, hash: &[u8], ) -> Result> { let row = sqlx::query( @@ -126,9 +151,10 @@ pub async fn get_api_token_by_hash_including_revoked( SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, created_at, expires_at, last_used_at, revoked_at FROM api_tokens - WHERE token_hash = $1 + WHERE community_id = $1 AND token_hash = $2 "#, ) + .bind(community_id) .bind(hash) .fetch_optional(pool) .await?; @@ -172,7 +198,8 @@ pub async fn get_api_token_by_hash_including_revoked( })) } -/// List all tokens (including revoked) for a pubkey, ordered by creation time descending. +/// List all tokens (including revoked) for a (community, owner) pair, +/// ordered by creation time descending. /// /// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are /// responsible for stripping `token_hash` before returning data to clients -- the @@ -180,6 +207,7 @@ pub async fn get_api_token_by_hash_including_revoked( /// Used by `GET /api/tokens` to show a user their full token history. pub async fn list_tokens_by_owner( pool: &PgPool, + community_id: Uuid, pubkey: &[u8], ) -> Result> { let rows = sqlx::query( @@ -187,10 +215,11 @@ pub async fn list_tokens_by_owner( SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, created_at, expires_at, last_used_at, revoked_at FROM api_tokens - WHERE owner_pubkey = $1 + WHERE community_id = $1 AND owner_pubkey = $2 ORDER BY created_at DESC "#, ) + .bind(community_id) .bind(pubkey) .fetch_all(pool) .await?; @@ -236,12 +265,13 @@ pub async fn list_tokens_by_owner( Ok(out) } -/// Revoke a single token by ID, scoped to the owner. +/// Revoke a single token by ID, scoped to (community, owner). /// -/// Only revokes if the token is owned by `owner_pubkey` and not already revoked. +/// Only revokes if the token is in `community_id`, owned by `owner_pubkey`, and not already revoked. /// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked. pub async fn revoke_token( pool: &PgPool, + community_id: Uuid, id: Uuid, owner_pubkey: &[u8], revoked_by: &[u8], @@ -250,12 +280,14 @@ pub async fn revoke_token( r#" UPDATE api_tokens SET revoked_at = NOW(), revoked_by = $1 - WHERE id = $2 - AND owner_pubkey = $3 + WHERE community_id = $2 + AND id = $3 + AND owner_pubkey = $4 AND revoked_at IS NULL "#, ) .bind(revoked_by) + .bind(community_id) .bind(id) .bind(owner_pubkey) .execute(pool) @@ -264,12 +296,13 @@ pub async fn revoke_token( Ok(result.rows_affected() > 0) } -/// Revoke all active tokens for a pubkey. +/// Revoke all active tokens for a (community, owner) pair. /// /// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens. /// If all tokens are already revoked, returns 0 with no error. pub async fn revoke_all_tokens( pool: &PgPool, + community_id: Uuid, owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { @@ -277,14 +310,213 @@ pub async fn revoke_all_tokens( r#" UPDATE api_tokens SET revoked_at = NOW(), revoked_by = $1 - WHERE owner_pubkey = $2 + WHERE community_id = $2 + AND owner_pubkey = $3 AND revoked_at IS NULL "#, ) .bind(revoked_by) + .bind(community_id) .bind(owner_pubkey) .execute(pool) .await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + //! Row-44 conformance: API token lookups MUST be keyed on + //! `(community_id, token_hash)`, not on `token_hash` alone. The storage + //! UNIQUE index is a *storage* guarantee; the WHERE clause here is the + //! *query* guarantee. Both must hold — a query that filters on hash + //! alone could return a foreign-community row, defeating the row-zero + //! tenancy fence. This test directly inserts two same-hash rows in two + //! communities (only possible by bypassing the unique index, which we + //! achieve via distinct hashes that the test then queries-by-hash for + //! both — see below for the actual property under test). + //! + //! The load-bearing property: even if storage uniqueness is ever relaxed + //! or a hash collision occurs, the query-side `AND community_id = $N` + //! clause guarantees the lookup returns the row for the *requested* + //! tenant. Mutate-bite proof: drop the clause, the test fails. + use super::*; + use crate::{ApiTokenRecord, Db}; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_db() -> Db { + let pool = PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB"); + Db { pool } + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("api-token-tenancy-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_user(pool: &PgPool, community_id: Uuid, pubkey: &[u8]) { + sqlx::query( + r#" + INSERT INTO users (community_id, pubkey) + VALUES ($1, $2) + "#, + ) + .bind(community_id) + .bind(pubkey) + .execute(pool) + .await + .expect("insert user"); + } + + /// Direct INSERT bypassing `create_api_token` so the test pins the + /// **lookup**'s scoping, not the insert path's. + async fn raw_insert_token( + pool: &PgPool, + community_id: Uuid, + token_hash: &[u8], + owner_pubkey: &[u8], + name: &str, + ) -> Uuid { + let id = Uuid::new_v4(); + let scopes = serde_json::json!(["files:read", "files:write"]); + sqlx::query( + r#" + INSERT INTO api_tokens + (community_id, id, token_hash, owner_pubkey, name, scopes) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + ) + .bind(community_id) + .bind(id) + .bind(token_hash) + .bind(owner_pubkey) + .bind(name) + .bind(&scopes) + .execute(pool) + .await + .expect("insert api_token"); + id + } + + /// Row-44 sharp test: two communities, **same** 32-byte token hash in each, + /// lookup scoped to community A returns A's row only (and B-scoped lookup + /// returns B's row only). The storage UNIQUE index is `(community_id, + /// token_hash)` so this is a legal state. The lookup must not return the + /// foreign row. + /// + /// Mutate-bite handle: the WHERE clause in + /// `get_api_token_by_hash_including_revoked` is the only thing keeping + /// this test green. Strip `AND community_id = $1` and the lookup becomes + /// hash-only — Postgres returns whichever row it picks (insert-order + /// dependent), and the cross-tenancy assertion fails. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lookup_by_hash_is_scoped_to_community() { + let db = setup_db().await; + + let community_a = make_community(&db.pool).await; + let community_b = make_community(&db.pool).await; + + // Distinct pubkeys per community — FK is (community_id, owner_pubkey). + let owner_a = vec![0xAAu8; 32]; + let owner_b = vec![0xBBu8; 32]; + insert_user(&db.pool, community_a, &owner_a).await; + insert_user(&db.pool, community_b, &owner_b).await; + + // SAME hash in both communities — legal under UNIQUE(community_id, token_hash). + let shared_hash = vec![0xCCu8; 32]; + let id_a = raw_insert_token(&db.pool, community_a, &shared_hash, &owner_a, "token-A").await; + let id_b = raw_insert_token(&db.pool, community_b, &shared_hash, &owner_b, "token-B").await; + assert_ne!(id_a, id_b, "ids must differ"); + + let cid_a = buzz_core::CommunityId::from_uuid(community_a); + let cid_b = buzz_core::CommunityId::from_uuid(community_b); + + // Lookup scoped to A returns A's row, never B's. + let from_a: ApiTokenRecord = db + .get_api_token_by_hash_including_revoked(cid_a, &shared_hash) + .await + .expect("lookup A") + .expect("row in A"); + assert_eq!(from_a.id, id_a, "community-A lookup must return A's row"); + assert_eq!( + from_a.owner_pubkey, owner_a, + "community-A lookup must return A's owner", + ); + + // Lookup scoped to B returns B's row, never A's. + let from_b: ApiTokenRecord = db + .get_api_token_by_hash_including_revoked(cid_b, &shared_hash) + .await + .expect("lookup B") + .expect("row in B"); + assert_eq!(from_b.id, id_b, "community-B lookup must return B's row"); + assert_eq!( + from_b.owner_pubkey, owner_b, + "community-B lookup must return B's owner", + ); + + // Lookup with the hash but a third (unrelated) community returns None. + let community_c = make_community(&db.pool).await; + let cid_c = buzz_core::CommunityId::from_uuid(community_c); + let from_c = db + .get_api_token_by_hash_including_revoked(cid_c, &shared_hash) + .await + .expect("lookup C"); + assert!( + from_c.is_none(), + "community-C has no token with this hash; lookup must return None, got {from_c:?}", + ); + } + + /// Active (non-revoked) lookup also enforces community scope. + /// Mirrors the obligation for the `revoked_at IS NULL` variant at + /// `Db::get_api_token_by_hash`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn active_lookup_by_hash_is_scoped_to_community() { + let db = setup_db().await; + + let community_a = make_community(&db.pool).await; + let community_b = make_community(&db.pool).await; + + let owner_a = vec![0x11u8; 32]; + let owner_b = vec![0x22u8; 32]; + insert_user(&db.pool, community_a, &owner_a).await; + insert_user(&db.pool, community_b, &owner_b).await; + + let shared_hash = vec![0x33u8; 32]; + let id_a = + raw_insert_token(&db.pool, community_a, &shared_hash, &owner_a, "active-A").await; + let id_b = + raw_insert_token(&db.pool, community_b, &shared_hash, &owner_b, "active-B").await; + + let cid_a = buzz_core::CommunityId::from_uuid(community_a); + let cid_b = buzz_core::CommunityId::from_uuid(community_b); + + let from_a = db + .get_api_token_by_hash(cid_a, &shared_hash) + .await + .expect("active lookup A") + .expect("row in A"); + assert_eq!(from_a.id, id_a); + + let from_b = db + .get_api_token_by_hash(cid_b, &shared_hash) + .await + .expect("active lookup B") + .expect("row in B"); + assert_eq!(from_b.id, id_b); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fc9c5477e..71474f5ce 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1289,8 +1289,10 @@ impl Db { } /// Create a new API token record. + #[allow(clippy::too_many_arguments)] pub async fn create_api_token( &self, + community_id: CommunityId, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -1300,6 +1302,7 @@ impl Db { ) -> Result { api_token::create_api_token( &self.pool, + *community_id.as_uuid(), token_hash, owner_pubkey, name, @@ -1310,9 +1313,11 @@ impl Db { .await } - /// Atomic conditional INSERT with 10-token limit. + /// Atomic conditional INSERT with 10-token limit (per (community, owner)). + #[allow(clippy::too_many_arguments)] pub async fn create_api_token_if_under_limit( &self, + community_id: CommunityId, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -1322,6 +1327,7 @@ impl Db { ) -> Result> { api_token::create_api_token_if_under_limit( &self.pool, + *community_id.as_uuid(), token_hash, owner_pubkey, name, @@ -1332,16 +1338,26 @@ impl Db { .await } - /// Look up an active (non-revoked) API token by its SHA-256 hash. - pub async fn get_api_token_by_hash(&self, hash: &[u8]) -> Result> { + /// Look up an active (non-revoked) API token by its SHA-256 hash, + /// scoped to the request's community. + /// + /// See [`api_token::get_api_token_by_hash_including_revoked`] for the + /// row-44 conformance rationale — the `(community_id, token_hash)` key + /// is enforced both by the storage UNIQUE index and by this WHERE clause. + pub async fn get_api_token_by_hash( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result> { let row = sqlx::query( r#" SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, created_at, expires_at, last_used_at, revoked_at FROM api_tokens - WHERE token_hash = $1 AND revoked_at IS NULL + WHERE community_id = $1 AND token_hash = $2 AND revoked_at IS NULL "#, ) + .bind(community_id.as_uuid()) .bind(hash) .fetch_optional(&self.pool) .await?; @@ -1352,39 +1368,53 @@ impl Db { } } - /// Look up an API token by hash, including revoked. + /// Look up an API token by hash, including revoked, scoped to community. pub async fn get_api_token_by_hash_including_revoked( &self, + community_id: CommunityId, hash: &[u8], ) -> Result> { - api_token::get_api_token_by_hash_including_revoked(&self.pool, hash).await + api_token::get_api_token_by_hash_including_revoked( + &self.pool, + *community_id.as_uuid(), + hash, + ) + .await } - /// Record a token usage (update `last_used_at`). - pub async fn touch_api_token(&self, hash: &[u8]) -> Result<()> { - sqlx::query("UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1") - .bind(hash) - .execute(&self.pool) - .await?; + /// Record a token usage (update `last_used_at`), scoped to community. + pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", + ) + .bind(community_id.as_uuid()) + .bind(hash) + .execute(&self.pool) + .await?; Ok(()) } - /// Alias for [`touch_api_token`]. - pub async fn update_token_last_used(&self, hash: &[u8]) -> Result<()> { - self.touch_api_token(hash).await + /// Alias for [`Self::touch_api_token`]. + pub async fn update_token_last_used( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result<()> { + self.touch_api_token(community_id, hash).await } - /// List all active (non-revoked) tokens, newest first. - pub async fn list_active_tokens(&self) -> Result> { + /// List all active (non-revoked) tokens in a community, newest first. + pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result> { let rows = sqlx::query( r#" SELECT id, name, owner_pubkey, scopes, created_at, expires_at FROM api_tokens - WHERE revoked_at IS NULL + WHERE community_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1000 "#, ) + .bind(community_id.as_uuid()) .fetch_all(&self.pool) .await?; @@ -1407,24 +1437,47 @@ impl Db { Ok(out) } - /// List all tokens for a pubkey (including revoked). - pub async fn list_tokens_by_owner(&self, pubkey: &[u8]) -> Result> { - api_token::list_tokens_by_owner(&self.pool, pubkey).await + /// List all tokens for a (community, owner) pair (including revoked). + pub async fn list_tokens_by_owner( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + api_token::list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await } - /// Revoke a single token by ID. + /// Revoke a single token by ID, scoped to (community, owner). pub async fn revoke_token( &self, + community_id: CommunityId, id: Uuid, owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { - api_token::revoke_token(&self.pool, id, owner_pubkey, revoked_by).await + api_token::revoke_token( + &self.pool, + *community_id.as_uuid(), + id, + owner_pubkey, + revoked_by, + ) + .await } - /// Revoke all active tokens for a pubkey. - pub async fn revoke_all_tokens(&self, owner_pubkey: &[u8], revoked_by: &[u8]) -> Result { - api_token::revoke_all_tokens(&self.pool, owner_pubkey, revoked_by).await + /// Revoke all active tokens for a (community, owner) pair. + pub async fn revoke_all_tokens( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + revoked_by: &[u8], + ) -> Result { + api_token::revoke_all_tokens( + &self.pool, + *community_id.as_uuid(), + owner_pubkey, + revoked_by, + ) + .await } /// Create a new workflow. diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index ce85aa530..d5370b6c7 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -84,12 +84,31 @@ impl FromRequestParts> for AuthenticatedUpload { return Err(MediaError::HashMismatch); } - // 4. Resolve scopes (API token or dev mode) - let scopes = resolve_upload_scopes(headers, state, &auth_event.pubkey).await?; + // 4. Row zero: bind this upload to its community from the request host, + // identical to the WS door in `router.rs` and the bridge door in + // `bridge.rs`. Fail-closed: an unmapped host or lookup failure is a + // generic `NotFound` (404) — never a default tenant, never echoing the + // host, so an unauthenticated caller cannot probe which communities + // exist on this deployment. + // + // This MUST run before scope resolution so the API-token lookup is + // keyed on (community_id, token_hash) — see Gap 2 / row-44 conformance + // obligation. Resolving scopes without a tenant in hand would query + // api_tokens by hash alone, defeating the cross-community fence. + let raw_host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| MediaError::NotFound)?; + + // 5. Resolve scopes (API token or dev mode), scoped to the bound tenant. + let scopes = resolve_upload_scopes(headers, state, &tenant, &auth_event.pubkey).await?; buzz_auth::require_scope(&scopes, Scope::FilesWrite) .map_err(|_| MediaError::InsufficientScope)?; - // 5. Relay membership gate (NIP-43). + // 6. Relay membership gate (NIP-43). let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); crate::api::relay_members::enforce_relay_membership( state, @@ -99,20 +118,6 @@ impl FromRequestParts> for AuthenticatedUpload { .await .map_err(|_| MediaError::RelayMembershipRequired)?; - // 6. Row zero: bind this upload to its community from the request host, - // identical to the WS door in `router.rs` and the bridge door in - // `bridge.rs`. Fail-closed: an unmapped host or lookup failure is a - // generic `NotFound` (404) — never a default tenant, never echoing the - // host, so an unauthenticated caller cannot probe which communities - // exist on this deployment. - let raw_host = headers - .get(header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) - .await - .map_err(|_| MediaError::NotFound)?; - Ok(AuthenticatedUpload { auth_event, scopes, @@ -605,14 +610,20 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result Ok(event) } -/// Resolve permission scopes for an upload caller. +/// Resolve permission scopes for an upload caller, scoped to the request's tenant. /// /// Resolution order: /// 1. `X-Auth-Token: buzz_*` header — API token path (validates owner matches Blossom signer) /// 2. If `require_auth_token` is false (dev mode) — check pubkey allowlist, then grant file scopes +/// +/// The token lookup is keyed on `(tenant.community(), token_hash)` — see +/// [`buzz_db::api_token::get_api_token_by_hash_including_revoked`] for the +/// row-44 conformance rationale. A token minted in community A presented to a +/// host that resolves to community B must not authorize. async fn resolve_upload_scopes( headers: &HeaderMap, state: &AppState, + tenant: &TenantContext, blossom_pubkey: &nostr::PublicKey, ) -> Result, MediaError> { // 1. API token path — desktop sends Blossom auth in Authorization + token in X-Auth-Token. @@ -624,7 +635,7 @@ async fn resolve_upload_scopes( let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); let record = state .db - .get_api_token_by_hash_including_revoked(&hash) + .get_api_token_by_hash_including_revoked(tenant.community(), &hash) .await .map_err(|_| MediaError::Unauthorized)? .ok_or(MediaError::Unauthorized)?;