mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
buzz-db: scope api_tokens lookups to community_id (Gap 2 / row 44)
Conformance row 44 obligates that API token lookups key on (community_id, token_hash), not on token_hash alone. The storage UNIQUE index `idx_api_tokens_hash` already enforces this as a *storage* guarantee — but the query side was filtering on `token_hash = $1` only, relying on uniqueness as load-bearing for tenancy. That's a structural gap: any future relaxation of the index (or an adversarial mint that landed two rows via a tx race) would let a token minted in community A authorize against a request bound to community B. This change closes the query-side gap. All eight Db API surface methods now take a `CommunityId` first parameter, and the underlying SQL adds `AND community_id = $N` (or includes the column on INSERT). The `create_api_token*` family additionally INSERTs into the `community_id` column, which it previously omitted — schema declares it NOT NULL, so those functions would have failed at runtime if invoked. They have no callers today (token mint is staged but not wired), but fixing them in the same diff un-rots the public API and prevents the next caller from hitting a runtime FK error. The only live caller is `crates/buzz-relay/src/api/media.rs::resolve_ upload_scopes`, called from the `AuthenticatedUpload` extractor. The extractor previously resolved scopes BEFORE binding the request's tenant via `bind_community`, so threading the community through would have been impossible — the tenant didn't yet exist. Reordered: row-zero tenant bind moves to step 4 (immediately after header validation), scope resolution to step 5 with `&TenantContext` in hand. The lookup in `resolve_upload_scopes` now calls `get_api_token_by_hash_including_ revoked(tenant.community(), &hash)`. Sharp regression test added at `api_token::tests::lookup_by_hash_is_ scoped_to_community` (#[ignore = "requires Postgres"]): inserts two same-hash tokens in two communities (legal under UNIQUE(community_id, token_hash)) and asserts each lookup returns only its own community's row, and that a third unrelated community returns None. Mirror test `active_lookup_by_hash_is_scoped_to_community` covers the `revoked_at IS NULL` variant on `Db::get_api_token_by_hash`. Mutate-bite proof (verified manually before commit): stripping `AND community_id = $1` from the WHERE clause fails the test with `community-B lookup must return B's row` — Postgres returns the first-inserted (A's) row when filtering on hash alone, as expected. Restored the clause and re-ran clean. Verification: - cargo build --workspace --tests: clean (1.95.0) - cargo test -p buzz-db -- --include-ignored --test-threads=1: 101/101 - cargo test -p buzz-relay -- --test-threads=1: 399 + 1/1 - cargo clippy --workspace --tests -- -D warnings: clean - Line-read final diff for tenant provenance: every binding correctly threads the request-resolved CommunityId; no client-supplied path. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
481e623470
commit
4b6e1e43d9
+249
-17
@@ -8,8 +8,13 @@ use crate::error::{DbError, Result};
|
|||||||
|
|
||||||
/// Create a new API token record. The caller is responsible for generating
|
/// Create a new API token record. The caller is responsible for generating
|
||||||
/// the raw token and computing its SHA-256 hash.
|
/// 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(
|
pub async fn create_api_token(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
token_hash: &[u8],
|
token_hash: &[u8],
|
||||||
owner_pubkey: &[u8],
|
owner_pubkey: &[u8],
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -32,10 +37,12 @@ pub async fn create_api_token(
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO api_tokens (id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at)
|
INSERT INTO api_tokens
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
(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(id)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.bind(owner_pubkey)
|
.bind(owner_pubkey)
|
||||||
@@ -54,9 +61,14 @@ pub async fn create_api_token(
|
|||||||
/// Uses a subquery so the check and insert are atomic --
|
/// Uses a subquery so the check and insert are atomic --
|
||||||
/// no TOCTOU race between a separate count query and the insert.
|
/// 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.
|
/// 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(
|
pub async fn create_api_token_if_under_limit(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
token_hash: &[u8],
|
token_hash: &[u8],
|
||||||
owner_pubkey: &[u8],
|
owner_pubkey: &[u8],
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -76,22 +88,25 @@ pub async fn create_api_token_if_under_limit(
|
|||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
|
||||||
// Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10.
|
// Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10
|
||||||
// The subquery and insert execute atomically -- no separate count + insert race.
|
// **for this (community, owner) pair**. The subquery and insert execute atomically --
|
||||||
|
// no separate count + insert race.
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO api_tokens
|
INSERT INTO api_tokens
|
||||||
(id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint)
|
(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, TRUE
|
SELECT $1, $2, $3, $4, $5, $6, $7, $8, TRUE
|
||||||
WHERE (
|
WHERE (
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM api_tokens
|
FROM api_tokens
|
||||||
WHERE owner_pubkey = $8
|
WHERE community_id = $1
|
||||||
|
AND owner_pubkey = $9
|
||||||
AND revoked_at IS NULL
|
AND revoked_at IS NULL
|
||||||
AND (expires_at IS NULL OR expires_at > NOW())
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
) < 10
|
) < 10
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(community_id)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(token_hash)
|
.bind(token_hash)
|
||||||
.bind(owner_pubkey)
|
.bind(owner_pubkey)
|
||||||
@@ -111,7 +126,16 @@ pub async fn create_api_token_if_under_limit(
|
|||||||
Ok(Some(id))
|
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`),
|
/// Unlike [`crate::Db::get_api_token_by_hash`] (which filters `revoked_at IS NULL`),
|
||||||
/// this function returns the full record regardless of revocation status.
|
/// 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".
|
/// error responses rather than treating both as "not found".
|
||||||
pub async fn get_api_token_by_hash_including_revoked(
|
pub async fn get_api_token_by_hash_including_revoked(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
hash: &[u8],
|
hash: &[u8],
|
||||||
) -> Result<Option<crate::ApiTokenRecord>> {
|
) -> Result<Option<crate::ApiTokenRecord>> {
|
||||||
let row = sqlx::query(
|
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,
|
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||||
created_at, expires_at, last_used_at, revoked_at
|
created_at, expires_at, last_used_at, revoked_at
|
||||||
FROM api_tokens
|
FROM api_tokens
|
||||||
WHERE token_hash = $1
|
WHERE community_id = $1 AND token_hash = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(community_id)
|
||||||
.bind(hash)
|
.bind(hash)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.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
|
/// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are
|
||||||
/// responsible for stripping `token_hash` before returning data to clients -- the
|
/// 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.
|
/// Used by `GET /api/tokens` to show a user their full token history.
|
||||||
pub async fn list_tokens_by_owner(
|
pub async fn list_tokens_by_owner(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
pubkey: &[u8],
|
pubkey: &[u8],
|
||||||
) -> Result<Vec<crate::ApiTokenRecord>> {
|
) -> Result<Vec<crate::ApiTokenRecord>> {
|
||||||
let rows = sqlx::query(
|
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,
|
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||||
created_at, expires_at, last_used_at, revoked_at
|
created_at, expires_at, last_used_at, revoked_at
|
||||||
FROM api_tokens
|
FROM api_tokens
|
||||||
WHERE owner_pubkey = $1
|
WHERE community_id = $1 AND owner_pubkey = $2
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(community_id)
|
||||||
.bind(pubkey)
|
.bind(pubkey)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -236,12 +265,13 @@ pub async fn list_tokens_by_owner(
|
|||||||
Ok(out)
|
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.
|
/// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked.
|
||||||
pub async fn revoke_token(
|
pub async fn revoke_token(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
owner_pubkey: &[u8],
|
owner_pubkey: &[u8],
|
||||||
revoked_by: &[u8],
|
revoked_by: &[u8],
|
||||||
@@ -250,12 +280,14 @@ pub async fn revoke_token(
|
|||||||
r#"
|
r#"
|
||||||
UPDATE api_tokens
|
UPDATE api_tokens
|
||||||
SET revoked_at = NOW(), revoked_by = $1
|
SET revoked_at = NOW(), revoked_by = $1
|
||||||
WHERE id = $2
|
WHERE community_id = $2
|
||||||
AND owner_pubkey = $3
|
AND id = $3
|
||||||
|
AND owner_pubkey = $4
|
||||||
AND revoked_at IS NULL
|
AND revoked_at IS NULL
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(revoked_by)
|
.bind(revoked_by)
|
||||||
|
.bind(community_id)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(owner_pubkey)
|
.bind(owner_pubkey)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -264,12 +296,13 @@ pub async fn revoke_token(
|
|||||||
Ok(result.rows_affected() > 0)
|
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.
|
/// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens.
|
||||||
/// If all tokens are already revoked, returns 0 with no error.
|
/// If all tokens are already revoked, returns 0 with no error.
|
||||||
pub async fn revoke_all_tokens(
|
pub async fn revoke_all_tokens(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
owner_pubkey: &[u8],
|
owner_pubkey: &[u8],
|
||||||
revoked_by: &[u8],
|
revoked_by: &[u8],
|
||||||
) -> Result<u64> {
|
) -> Result<u64> {
|
||||||
@@ -277,14 +310,213 @@ pub async fn revoke_all_tokens(
|
|||||||
r#"
|
r#"
|
||||||
UPDATE api_tokens
|
UPDATE api_tokens
|
||||||
SET revoked_at = NOW(), revoked_by = $1
|
SET revoked_at = NOW(), revoked_by = $1
|
||||||
WHERE owner_pubkey = $2
|
WHERE community_id = $2
|
||||||
|
AND owner_pubkey = $3
|
||||||
AND revoked_at IS NULL
|
AND revoked_at IS NULL
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(revoked_by)
|
.bind(revoked_by)
|
||||||
|
.bind(community_id)
|
||||||
.bind(owner_pubkey)
|
.bind(owner_pubkey)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(result.rows_affected())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+79
-26
@@ -1289,8 +1289,10 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new API token record.
|
/// Create a new API token record.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn create_api_token(
|
pub async fn create_api_token(
|
||||||
&self,
|
&self,
|
||||||
|
community_id: CommunityId,
|
||||||
token_hash: &[u8],
|
token_hash: &[u8],
|
||||||
owner_pubkey: &[u8],
|
owner_pubkey: &[u8],
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -1300,6 +1302,7 @@ impl Db {
|
|||||||
) -> Result<Uuid> {
|
) -> Result<Uuid> {
|
||||||
api_token::create_api_token(
|
api_token::create_api_token(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
|
*community_id.as_uuid(),
|
||||||
token_hash,
|
token_hash,
|
||||||
owner_pubkey,
|
owner_pubkey,
|
||||||
name,
|
name,
|
||||||
@@ -1310,9 +1313,11 @@ impl Db {
|
|||||||
.await
|
.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(
|
pub async fn create_api_token_if_under_limit(
|
||||||
&self,
|
&self,
|
||||||
|
community_id: CommunityId,
|
||||||
token_hash: &[u8],
|
token_hash: &[u8],
|
||||||
owner_pubkey: &[u8],
|
owner_pubkey: &[u8],
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -1322,6 +1327,7 @@ impl Db {
|
|||||||
) -> Result<Option<Uuid>> {
|
) -> Result<Option<Uuid>> {
|
||||||
api_token::create_api_token_if_under_limit(
|
api_token::create_api_token_if_under_limit(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
|
*community_id.as_uuid(),
|
||||||
token_hash,
|
token_hash,
|
||||||
owner_pubkey,
|
owner_pubkey,
|
||||||
name,
|
name,
|
||||||
@@ -1332,16 +1338,26 @@ impl Db {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up an active (non-revoked) API token by its SHA-256 hash.
|
/// 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<Option<ApiTokenRecord>> {
|
/// 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<Option<ApiTokenRecord>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||||
created_at, expires_at, last_used_at, revoked_at
|
created_at, expires_at, last_used_at, revoked_at
|
||||||
FROM api_tokens
|
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)
|
.bind(hash)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.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(
|
pub async fn get_api_token_by_hash_including_revoked(
|
||||||
&self,
|
&self,
|
||||||
|
community_id: CommunityId,
|
||||||
hash: &[u8],
|
hash: &[u8],
|
||||||
) -> Result<Option<ApiTokenRecord>> {
|
) -> Result<Option<ApiTokenRecord>> {
|
||||||
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`).
|
/// Record a token usage (update `last_used_at`), scoped to community.
|
||||||
pub async fn touch_api_token(&self, hash: &[u8]) -> Result<()> {
|
pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> {
|
||||||
sqlx::query("UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1")
|
sqlx::query(
|
||||||
.bind(hash)
|
"UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2",
|
||||||
.execute(&self.pool)
|
)
|
||||||
.await?;
|
.bind(community_id.as_uuid())
|
||||||
|
.bind(hash)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias for [`touch_api_token`].
|
/// Alias for [`Self::touch_api_token`].
|
||||||
pub async fn update_token_last_used(&self, hash: &[u8]) -> Result<()> {
|
pub async fn update_token_last_used(
|
||||||
self.touch_api_token(hash).await
|
&self,
|
||||||
|
community_id: CommunityId,
|
||||||
|
hash: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.touch_api_token(community_id, hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all active (non-revoked) tokens, newest first.
|
/// List all active (non-revoked) tokens in a community, newest first.
|
||||||
pub async fn list_active_tokens(&self) -> Result<Vec<TokenSummary>> {
|
pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result<Vec<TokenSummary>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, name, owner_pubkey, scopes, created_at, expires_at
|
SELECT id, name, owner_pubkey, scopes, created_at, expires_at
|
||||||
FROM api_tokens
|
FROM api_tokens
|
||||||
WHERE revoked_at IS NULL
|
WHERE community_id = $1 AND revoked_at IS NULL
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1000
|
LIMIT 1000
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(community_id.as_uuid())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1407,24 +1437,47 @@ impl Db {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all tokens for a pubkey (including revoked).
|
/// List all tokens for a (community, owner) pair (including revoked).
|
||||||
pub async fn list_tokens_by_owner(&self, pubkey: &[u8]) -> Result<Vec<ApiTokenRecord>> {
|
pub async fn list_tokens_by_owner(
|
||||||
api_token::list_tokens_by_owner(&self.pool, pubkey).await
|
&self,
|
||||||
|
community_id: CommunityId,
|
||||||
|
pubkey: &[u8],
|
||||||
|
) -> Result<Vec<ApiTokenRecord>> {
|
||||||
|
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(
|
pub async fn revoke_token(
|
||||||
&self,
|
&self,
|
||||||
|
community_id: CommunityId,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
owner_pubkey: &[u8],
|
owner_pubkey: &[u8],
|
||||||
revoked_by: &[u8],
|
revoked_by: &[u8],
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
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.
|
/// Revoke all active tokens for a (community, owner) pair.
|
||||||
pub async fn revoke_all_tokens(&self, owner_pubkey: &[u8], revoked_by: &[u8]) -> Result<u64> {
|
pub async fn revoke_all_tokens(
|
||||||
api_token::revoke_all_tokens(&self.pool, owner_pubkey, revoked_by).await
|
&self,
|
||||||
|
community_id: CommunityId,
|
||||||
|
owner_pubkey: &[u8],
|
||||||
|
revoked_by: &[u8],
|
||||||
|
) -> Result<u64> {
|
||||||
|
api_token::revoke_all_tokens(
|
||||||
|
&self.pool,
|
||||||
|
*community_id.as_uuid(),
|
||||||
|
owner_pubkey,
|
||||||
|
revoked_by,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new workflow.
|
/// Create a new workflow.
|
||||||
|
|||||||
@@ -84,12 +84,31 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
|||||||
return Err(MediaError::HashMismatch);
|
return Err(MediaError::HashMismatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Resolve scopes (API token or dev mode)
|
// 4. Row zero: bind this upload to its community from the request host,
|
||||||
let scopes = resolve_upload_scopes(headers, state, &auth_event.pubkey).await?;
|
// 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)
|
buzz_auth::require_scope(&scopes, Scope::FilesWrite)
|
||||||
.map_err(|_| MediaError::InsufficientScope)?;
|
.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());
|
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||||
crate::api::relay_members::enforce_relay_membership(
|
crate::api::relay_members::enforce_relay_membership(
|
||||||
state,
|
state,
|
||||||
@@ -99,20 +118,6 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| MediaError::RelayMembershipRequired)?;
|
.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 {
|
Ok(AuthenticatedUpload {
|
||||||
auth_event,
|
auth_event,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -605,14 +610,20 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result<nostr::Event, MediaError>
|
|||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve permission scopes for an upload caller.
|
/// Resolve permission scopes for an upload caller, scoped to the request's tenant.
|
||||||
///
|
///
|
||||||
/// Resolution order:
|
/// Resolution order:
|
||||||
/// 1. `X-Auth-Token: buzz_*` header — API token path (validates owner matches Blossom signer)
|
/// 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
|
/// 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(
|
async fn resolve_upload_scopes(
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
tenant: &TenantContext,
|
||||||
blossom_pubkey: &nostr::PublicKey,
|
blossom_pubkey: &nostr::PublicKey,
|
||||||
) -> Result<Vec<Scope>, MediaError> {
|
) -> Result<Vec<Scope>, MediaError> {
|
||||||
// 1. API token path — desktop sends Blossom auth in Authorization + token in X-Auth-Token.
|
// 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 hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
|
||||||
let record = state
|
let record = state
|
||||||
.db
|
.db
|
||||||
.get_api_token_by_hash_including_revoked(&hash)
|
.get_api_token_by_hash_including_revoked(tenant.community(), &hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| MediaError::Unauthorized)?
|
.map_err(|_| MediaError::Unauthorized)?
|
||||||
.ok_or(MediaError::Unauthorized)?;
|
.ok_or(MediaError::Unauthorized)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user