mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
## Summary This makes every serving writer of a community-write-fenced table take community admission in application code. That's the "Dual enforcement" step of the Supported Writer Contract in `ARCHITECTURE.md`. Admission means the shared community deletion lock plus the active/not-deleted check from `deletion::guard_community_write`. It's taken before the transaction's first tenant write or row lock and held until commit. The 32 `community_write_fence_*` triggers, the six fence SQL functions and `validate_serving_catalog` are unchanged. They stay authoritative. Removing them is a separate migration that will be checked table by table. ## Changes - **New admission entry point.** `begin_community_write_transaction` (`runtime/mod.rs`) builds an `AdmittedTx` for non-event writers. It sits next to the existing event-write entry points and shares their implementation. - **Writers moved onto it:** - `api_tokens`, `archived_identities`, `channels`, `channel_members` (including DM and admin paths), `community_bans`, `git_repo_names` - `moderation_actions` and `moderation_reports`, `personal_read_accounts` and `personal_read_frontiers`, `pubkey_allowlist` - `push_leases`, `push_match_queue`, `push_wake_outbox` - `relay_invites`, `relay_members` (including the new-community owner bootstrap and the startup `pubkey_allowlist` backfill), `join_policy_acceptances` - `thread_metadata`, `users`, `workflows`, `workflow_runs`, `workflow_approvals`, `scheduled_workflow_fires` - Personal-read helpers now require `&mut AdmittedTx`. - **`buzz-audit`.** An append now passes admission first, then takes a transaction-scoped audit-chain lock (`pg_advisory_xact_lock`). This replaces the session lock and the `catch_unwind` unlock cleanup. The relay audit worker retries a `lock_timeout` on either lock and keeps the entry; an admission refusal is terminal, as the trigger's refusal was. - **Fleet-wide statements.** These keep `community_write_allowed(...)` in the same SQL statement, because there's no single community to admit up front: - scheduled-fire prune - invite expiry - push matcher claim and exhausted-job cleanup - startup `d_tag` backfill - channel TTL cleanup - **Relay-member removal.** `remove_relay_member` and `remove_relay_member_if_role` run `delete_member_with_acceptances` inside their admitted transaction. The helper no longer opens its own transaction, so the member lock, the `join_policy_acceptances` delete and the member delete all hold admission until commit. `delete_workflow` and `delete_workflow_for_owner` are gone on `main`, and the coordinate deletion path they used to back was already admitted. - **Unchanged exceptions.** The deletion-executor generation exception and the serving-lease path work as before. - **Tables with no production writer.** `delivery_log` and `subscriptions` are only written by a historical cutover script and a test fixture. Their triggers stay. - **No cross-community moves.** No production `community_id` move exists on a fenced table. The only `SET community_id = NULL` is the deletion-executor purge. So no dual-community lock ordering was needed. ## What got simpler - Per-store ad-hoc writer helpers are gone: `begin_event_write_transaction` and `acquire_event_write_connection` in `channel.rs`, and `begin_operation_transaction` in `push.rs`. - Audit append drops the session advisory unlock, the `catch_unwind`/`resume_unwind` panic handling, `log_inner` and the `futures-util` dependency. ## Notes for reviewers - **New crate dependency.** `buzz-audit` now depends on `buzz-db` only to call the free function `buzz_db::deletion::guard_community_write`. `ARCHITECTURE.md` names this as the one sanctioned subsystem edge, and its audit section is updated to match. - **Hot paths.** `ensure_user` first checks whether the user exists and returns without admission when it does (no write happens). Only a real insert takes admission. `touch_api_token` has no production caller today. - **No-op writers stay read-only.** The startup allowlist backfill returns early on plain reads when the legacy table is gone, it has no rows for the community, or the community already has members, so a quiescing deployment community still starts. It re-checks under admission before inserting. - **Owner mutations.** When admission refuses a transfer or a provisioning and a deletion request exists, both report `DeletionPending`, as before. For a transfer, the operator API keeps its coded `deletion_lifecycle_conflict` 409. For provisioning, it keeps the existing uncoded 409, `owner_conflict: community deletion is pending`. - **Per-community worker loops.** `claim_due_push_wakes` and similar now return `AccessDenied` for a community that goes quiescing between the active-host scan and the claim. The worker logs it at `debug` and the next scan skips the community. - **Kick side effects.** `membership_removal_fence` admits before the membership lock to keep the lock order of `add_member`. A community that leaves `active` between the kick and the fence skips live subscription eviction; the membership cache is already invalidated and fan-out re-checks membership. This is documented on the function. ## Testing - **New refusal tests.** 19 tests: the 18 in `store::admission_postgres_tests`, plus `audit_append_requires_application_admission_with_triggers_bypassed`. Each runs with ordinary triggers disabled (`session_replication_role = replica`), checks that a raw fenced insert would succeed, and asserts that the writer is refused for both `quiescing` and `fenced` communities. Active writes still succeed. - **The tests catch a missing check.** Measured in review at `6fc073c3f`: with the guard in `AdmittedTx::admit` bypassed, 25 tests fail. That includes 16 of the 18 database admission tests. The audit test stays green there, because audit calls `guard_community_write` directly. - **Affected PG suites, with triggers on:** 183/183 passed across 16 store suites, push 18/18 run isolated, audit 7/7. The existing artifact, reaction and replaceable admission tests pass. - **Review fixes, each checked to fail without its fix:** - `allowlist_backfill_requires_application_admission` and `owner_mutations_report_pending_deletion_when_admission_refuses` fail with the old `relay_members.rs`. - `empty_allowlist_backfill_skips_admission` fails (`AccessDenied … write-fenced (quiescing)`) without the allowlist-rows early return. - `audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once` holds the community deletion lock exclusive and fails without the admission retry arm. - The existing audit-chain retry test now looks for the transaction-scoped lock and passes. - The two owner-revoke mini-schema tests (`bridge.rs`, `ingest.rs`) now include `communities` and `community_deletion_lock_key`, which admission reads; both failed without that. - **Static checks:** `cargo fmt --check` and workspace `cargo clippy --all-targets -D warnings` pass. - **Not run locally:** the `mobile-check` step of `just ci` (the build machine couldn't reach pub.dev, and clean base fails the same way) and the full `just test` lane. CI on this PR covers both. `mesh_demo::demo_join_forwarded_arm_round_trips_echo` fails on the build machine at base `394d4a434` as well; it uses no database. Refs BUZZ-314. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
834 lines
28 KiB
Rust
834 lines
28 KiB
Rust
//! API token CRUD operations.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::{PgPool, Row};
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::{DbError, Result};
|
|
use crate::Db;
|
|
use buzz_core::CommunityId;
|
|
use buzz_datastore_tracing::datastore_span;
|
|
|
|
/// 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,
|
|
scopes: &[String],
|
|
channel_ids: Option<&[Uuid]>,
|
|
expires_at: Option<DateTime<Utc>>,
|
|
) -> Result<Uuid> {
|
|
let id = Uuid::new_v4();
|
|
|
|
let scopes_json =
|
|
serde_json::to_value(scopes).map_err(|e| DbError::InvalidData(e.to_string()))?;
|
|
|
|
// Serialize channel_ids; propagate errors rather than silently dropping to NULL.
|
|
let channel_ids_json: Option<serde_json::Value> = channel_ids
|
|
.map(|ids| {
|
|
serde_json::to_value(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
|
|
.map_err(|e| DbError::InvalidData(format!("channel_ids serialization: {e}")))
|
|
})
|
|
.transpose()?;
|
|
|
|
let mut tx = crate::begin_community_write_transaction(
|
|
pool,
|
|
CommunityId::from_uuid(community_id),
|
|
crate::observability::WriterOperation::Authentication,
|
|
)
|
|
.await?;
|
|
sqlx::query(
|
|
r#"
|
|
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)
|
|
.bind(name)
|
|
.bind(&scopes_json)
|
|
.bind(&channel_ids_json)
|
|
.bind(expires_at)
|
|
.execute(tx.conn())
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Atomic conditional INSERT: create a token only if the owner has fewer than 10 active tokens.
|
|
///
|
|
/// 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,
|
|
scopes: &[String],
|
|
channel_ids: Option<&[Uuid]>,
|
|
expires_at: Option<DateTime<Utc>>,
|
|
) -> Result<Option<Uuid>> {
|
|
let id = Uuid::new_v4();
|
|
|
|
let scopes_json =
|
|
serde_json::to_value(scopes).map_err(|e| DbError::InvalidData(e.to_string()))?;
|
|
|
|
let channel_ids_json: Option<serde_json::Value> = channel_ids
|
|
.map(|ids| {
|
|
serde_json::to_value(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
|
|
.map_err(|e| DbError::InvalidData(format!("channel_ids serialization: {e}")))
|
|
})
|
|
.transpose()?;
|
|
|
|
// 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 mut tx = crate::begin_community_write_transaction(
|
|
pool,
|
|
CommunityId::from_uuid(community_id),
|
|
crate::observability::WriterOperation::Authentication,
|
|
)
|
|
.await?;
|
|
let result = sqlx::query(
|
|
r#"
|
|
INSERT INTO api_tokens
|
|
(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 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)
|
|
.bind(name)
|
|
.bind(&scopes_json)
|
|
.bind(&channel_ids_json)
|
|
.bind(expires_at)
|
|
.bind(owner_pubkey)
|
|
.execute(tx.conn())
|
|
.await?;
|
|
tx.commit().await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
// Limit exceeded -- the WHERE clause prevented the INSERT.
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(id))
|
|
}
|
|
|
|
/// 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.
|
|
/// The relay layer uses this to return distinct `token_revoked` vs `invalid_token`
|
|
/// 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<Option<crate::ApiTokenRecord>> {
|
|
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 community_id = $1 AND token_hash = $2
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(hash)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
|
|
let row = match row {
|
|
None => return Ok(None),
|
|
Some(r) => r,
|
|
};
|
|
|
|
let id: Uuid = row.try_get("id")?;
|
|
|
|
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
|
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
|
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
|
|
|
let channel_ids: Option<Vec<Uuid>> = {
|
|
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
|
match raw {
|
|
None => None,
|
|
Some(v) => {
|
|
let strings: Vec<String> = serde_json::from_value(v)
|
|
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
|
let uuids: std::result::Result<Vec<Uuid>, _> =
|
|
strings.iter().map(|s| s.parse::<Uuid>()).collect();
|
|
Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?)
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(Some(crate::ApiTokenRecord {
|
|
id,
|
|
token_hash: row.try_get("token_hash")?,
|
|
owner_pubkey: row.try_get("owner_pubkey")?,
|
|
name: row.try_get("name")?,
|
|
scopes,
|
|
channel_ids,
|
|
created_at: row.try_get("created_at")?,
|
|
expires_at: row.try_get("expires_at")?,
|
|
last_used_at: row.try_get("last_used_at")?,
|
|
revoked_at: row.try_get("revoked_at")?,
|
|
}))
|
|
}
|
|
|
|
/// 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
|
|
/// raw token value is never exposed after the initial mint response.
|
|
/// 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<Vec<crate::ApiTokenRecord>> {
|
|
let rows = 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 community_id = $1 AND owner_pubkey = $2
|
|
ORDER BY created_at DESC
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(pubkey)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for row in rows {
|
|
let id: Uuid = row.try_get("id")?;
|
|
|
|
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
|
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
|
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
|
|
|
let channel_ids: Option<Vec<Uuid>> = {
|
|
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
|
match raw {
|
|
None => None,
|
|
Some(v) => {
|
|
let strings: Vec<String> = serde_json::from_value(v)
|
|
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
|
let uuids: std::result::Result<Vec<Uuid>, _> =
|
|
strings.iter().map(|s| s.parse::<Uuid>()).collect();
|
|
Some(
|
|
uuids
|
|
.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?,
|
|
)
|
|
}
|
|
}
|
|
};
|
|
|
|
out.push(crate::ApiTokenRecord {
|
|
id,
|
|
token_hash: row.try_get("token_hash")?,
|
|
owner_pubkey: row.try_get("owner_pubkey")?,
|
|
name: row.try_get("name")?,
|
|
scopes,
|
|
channel_ids,
|
|
created_at: row.try_get("created_at")?,
|
|
expires_at: row.try_get("expires_at")?,
|
|
last_used_at: row.try_get("last_used_at")?,
|
|
revoked_at: row.try_get("revoked_at")?,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Revoke a single token by ID, scoped to (community, owner).
|
|
///
|
|
/// 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],
|
|
) -> Result<bool> {
|
|
let mut tx = crate::begin_community_write_transaction(
|
|
pool,
|
|
CommunityId::from_uuid(community_id),
|
|
crate::observability::WriterOperation::Authentication,
|
|
)
|
|
.await?;
|
|
let result = sqlx::query(
|
|
r#"
|
|
UPDATE api_tokens
|
|
SET revoked_at = NOW(), revoked_by = $1
|
|
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(tx.conn())
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(result.rows_affected() > 0)
|
|
}
|
|
|
|
/// 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<u64> {
|
|
let mut tx = crate::begin_community_write_transaction(
|
|
pool,
|
|
CommunityId::from_uuid(community_id),
|
|
crate::observability::WriterOperation::Authentication,
|
|
)
|
|
.await?;
|
|
let result = sqlx::query(
|
|
r#"
|
|
UPDATE api_tokens
|
|
SET revoked_at = NOW(), revoked_by = $1
|
|
WHERE community_id = $2
|
|
AND owner_pubkey = $3
|
|
AND revoked_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(revoked_by)
|
|
.bind(community_id)
|
|
.bind(owner_pubkey)
|
|
.execute(tx.conn())
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(result.rows_affected())
|
|
}
|
|
|
|
/// Token summary returned by [`Db::list_active_tokens`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct TokenSummary {
|
|
/// Unique token identifier.
|
|
pub id: Uuid,
|
|
/// Human-readable token name.
|
|
pub name: String,
|
|
/// Compressed public key bytes of the token owner.
|
|
pub owner_pubkey: Vec<u8>,
|
|
/// Permission scopes granted to this token.
|
|
pub scopes: Vec<String>,
|
|
/// When the token was created.
|
|
pub created_at: DateTime<Utc>,
|
|
/// Optional expiry timestamp; `None` means no expiry.
|
|
pub expires_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// A full API token record.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ApiTokenRecord {
|
|
/// Unique token identifier.
|
|
pub id: Uuid,
|
|
/// SHA-256 hash of the raw token value.
|
|
pub token_hash: Vec<u8>,
|
|
/// Compressed public key bytes of the token owner.
|
|
pub owner_pubkey: Vec<u8>,
|
|
/// Human-readable token name.
|
|
pub name: String,
|
|
/// Permission scopes granted to this token.
|
|
pub scopes: Vec<String>,
|
|
/// Optional channel ID restrictions.
|
|
pub channel_ids: Option<Vec<Uuid>>,
|
|
/// When the token was created.
|
|
pub created_at: DateTime<Utc>,
|
|
/// Optional expiry timestamp.
|
|
pub expires_at: Option<DateTime<Utc>>,
|
|
/// When the token was last used.
|
|
pub last_used_at: Option<DateTime<Utc>>,
|
|
/// When the token was revoked.
|
|
pub revoked_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result<ApiTokenRecord> {
|
|
let id: Uuid = row.try_get("id")?;
|
|
|
|
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
|
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
|
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
|
|
|
let channel_ids: Option<Vec<Uuid>> = {
|
|
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
|
match raw {
|
|
None => None,
|
|
Some(v) => {
|
|
let strings: Vec<String> = serde_json::from_value(v)
|
|
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
|
let uuids: std::result::Result<Vec<Uuid>, _> =
|
|
strings.iter().map(|s| s.parse::<Uuid>()).collect();
|
|
Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?)
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(ApiTokenRecord {
|
|
id,
|
|
token_hash: row.try_get("token_hash")?,
|
|
owner_pubkey: row.try_get("owner_pubkey")?,
|
|
name: row.try_get("name")?,
|
|
scopes,
|
|
channel_ids,
|
|
created_at: row.try_get("created_at")?,
|
|
expires_at: row.try_get("expires_at")?,
|
|
last_used_at: row.try_get("last_used_at")?,
|
|
revoked_at: row.try_get("revoked_at")?,
|
|
})
|
|
}
|
|
|
|
impl Db {
|
|
/// Create a new API token record.
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[datastore_span(name = "create_api_token", system = "postgresql")]
|
|
pub async fn create_api_token(
|
|
&self,
|
|
community_id: CommunityId,
|
|
token_hash: &[u8],
|
|
owner_pubkey: &[u8],
|
|
name: &str,
|
|
scopes: &[String],
|
|
channel_ids: Option<&[Uuid]>,
|
|
expires_at: Option<DateTime<Utc>>,
|
|
) -> Result<Uuid> {
|
|
create_api_token(
|
|
&self.pool,
|
|
*community_id.as_uuid(),
|
|
token_hash,
|
|
owner_pubkey,
|
|
name,
|
|
scopes,
|
|
channel_ids,
|
|
expires_at,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Atomic conditional INSERT with 10-token limit (per (community, owner)).
|
|
#[allow(clippy::too_many_arguments)]
|
|
#[datastore_span(name = "create_api_token_if_under_limit", system = "postgresql")]
|
|
pub async fn create_api_token_if_under_limit(
|
|
&self,
|
|
community_id: CommunityId,
|
|
token_hash: &[u8],
|
|
owner_pubkey: &[u8],
|
|
name: &str,
|
|
scopes: &[String],
|
|
channel_ids: Option<&[Uuid]>,
|
|
expires_at: Option<DateTime<Utc>>,
|
|
) -> Result<Option<Uuid>> {
|
|
create_api_token_if_under_limit(
|
|
&self.pool,
|
|
*community_id.as_uuid(),
|
|
token_hash,
|
|
owner_pubkey,
|
|
name,
|
|
scopes,
|
|
channel_ids,
|
|
expires_at,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Look up an active (non-revoked) API token by its SHA-256 hash,
|
|
/// scoped to the request's community.
|
|
///
|
|
/// See [`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.
|
|
#[datastore_span(name = "get_api_token_by_hash", system = "postgresql")]
|
|
pub async fn get_api_token_by_hash(
|
|
&self,
|
|
community_id: CommunityId,
|
|
hash: &[u8],
|
|
) -> Result<Option<ApiTokenRecord>> {
|
|
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 community_id = $1 AND token_hash = $2 AND revoked_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(hash)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
match row {
|
|
None => Ok(None),
|
|
Some(r) => parse_api_token_row(r).map(Some),
|
|
}
|
|
}
|
|
|
|
/// Look up an API token by hash, including revoked, scoped to community.
|
|
#[datastore_span(
|
|
name = "get_api_token_by_hash_including_revoked",
|
|
system = "postgresql"
|
|
)]
|
|
pub async fn get_api_token_by_hash_including_revoked(
|
|
&self,
|
|
community_id: CommunityId,
|
|
hash: &[u8],
|
|
) -> Result<Option<ApiTokenRecord>> {
|
|
get_api_token_by_hash_including_revoked(&self.pool, *community_id.as_uuid(), hash).await
|
|
}
|
|
|
|
/// Record a token usage (update `last_used_at`), scoped to community.
|
|
#[datastore_span(name = "touch_api_token", system = "postgresql")]
|
|
pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> {
|
|
let mut tx = crate::begin_community_write_transaction(
|
|
&self.pool,
|
|
community_id,
|
|
crate::observability::WriterOperation::Authentication,
|
|
)
|
|
.await?;
|
|
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(tx.conn())
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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 in a community, newest first.
|
|
#[datastore_span(name = "list_active_tokens", system = "postgresql")]
|
|
pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result<Vec<TokenSummary>> {
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT id, name, owner_pubkey, scopes, created_at, expires_at
|
|
FROM api_tokens
|
|
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?;
|
|
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for row in rows {
|
|
let id: Uuid = row.try_get("id")?;
|
|
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
|
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
|
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
|
|
|
out.push(TokenSummary {
|
|
id,
|
|
name: row.try_get("name")?,
|
|
owner_pubkey: row.try_get("owner_pubkey")?,
|
|
scopes,
|
|
created_at: row.try_get("created_at")?,
|
|
expires_at: row.try_get("expires_at")?,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// List all tokens for a (community, owner) pair (including revoked).
|
|
#[datastore_span(name = "list_tokens_by_owner", system = "postgresql")]
|
|
pub async fn list_tokens_by_owner(
|
|
&self,
|
|
community_id: CommunityId,
|
|
pubkey: &[u8],
|
|
) -> Result<Vec<ApiTokenRecord>> {
|
|
list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await
|
|
}
|
|
|
|
/// Revoke a single token by ID, scoped to (community, owner).
|
|
#[datastore_span(name = "revoke_token", system = "postgresql")]
|
|
pub async fn revoke_token(
|
|
&self,
|
|
community_id: CommunityId,
|
|
id: Uuid,
|
|
owner_pubkey: &[u8],
|
|
revoked_by: &[u8],
|
|
) -> Result<bool> {
|
|
revoke_token(
|
|
&self.pool,
|
|
*community_id.as_uuid(),
|
|
id,
|
|
owner_pubkey,
|
|
revoked_by,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Revoke all active tokens for a (community, owner) pair.
|
|
#[datastore_span(name = "revoke_all_tokens", system = "postgresql")]
|
|
pub async fn revoke_all_tokens(
|
|
&self,
|
|
community_id: CommunityId,
|
|
owner_pubkey: &[u8],
|
|
revoked_by: &[u8],
|
|
) -> Result<u64> {
|
|
revoke_all_tokens(
|
|
&self.pool,
|
|
*community_id.as_uuid(),
|
|
owner_pubkey,
|
|
revoked_by,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod postgres_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;
|
|
|
|
async fn setup_db() -> Db {
|
|
let pool = PgPool::connect(&crate::test_support::database_url())
|
|
.await
|
|
.expect("connect to test DB");
|
|
Db::from_pool(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);
|
|
}
|
|
}
|