Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,15 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz
# BUZZ_NIP_FI_PROVISION_BINDING and must not be combined with
# BUZZ_NIP_FI_COMMUNITIES). See docs/operator-nip-fi-bindings.md.
# BUZZ_NIP_FI_COMMUNITY_SOURCE=env
# Require FI for newly provisioned tenant communities (both operator creation
# paths). Needs source=database and BUZZ_NIP_FI_PROVISION_BINDING; new hosts
# outside that binding policy are refused. Deployment/control-host startup
# bootstrap is excluded and keeps its existing policy. Independent of
# BUZZ_NIP_FI_UPGRADES_ENABLED, which gates upgrades of existing communities.
# Disabling this default affects future creations only: born-required rows
# stay required, with no per-community rollback endpoint. MODE=off remains
# the explicit relay-wide FI bypass; Shadow enforces stored requirements.
# BUZZ_NIP_FI_REQUIRE_NEW_COMMUNITIES=false
# Most community-issuer rows one snapshot may hold (1..=10000000).
# BUZZ_NIP_FI_SNAPSHOT_MAX_ROWS=1000000
# Snapshot load timeout in ms; must be under the 30s refresh interval.
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-db/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ pub enum DbError {
#[error("access denied: {0}")]
AccessDenied(String),

/// A new required community has no issuer binding in its creation policy.
#[error("required community creation needs NIP-FI issuer bindings")]
NipFiBindingRequired,

/// Invitation admission or minting was denied by authoritative restrictions.
#[error("invitation principal is restricted")]
InviteRestricted,
Expand Down Expand Up @@ -176,6 +180,7 @@ impl DbError {
E::InvalidData(_) => "invalid_data",
E::NotFound(_) | E::ChannelNotFound(_) | E::MemberNotFound(_) => "not_found",
E::AccessDenied(_) => "access_denied",
E::NipFiBindingRequired => "nip_fi_binding_required",
_ => "other",
};
kind.to_owned()
Expand Down
69 changes: 63 additions & 6 deletions crates/buzz-db/src/store/community.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,9 @@ impl Db {

/// Like [`Db::ensure_configured_community`], binding `nip_fi_issuers` to
/// the community in the same transaction when this call creates its row.
/// An existing row keeps its bindings unchanged.
/// An existing row keeps its bindings unchanged. Operator tenant creation
/// must use [`Db::ensure_configured_community_with_nip_fi_policy`] to apply
/// the configured creation requirement.
#[datastore_span(
name = "ensure_configured_community_with_nip_fi_issuers",
system = "postgresql"
Expand All @@ -521,10 +523,28 @@ impl Db {
&self,
normalized_host: &str,
nip_fi_issuers: &[String],
) -> Result<EnsuredCommunityRecord> {
self.ensure_configured_community_with_nip_fi_policy(normalized_host, nip_fi_issuers, false)
.await
}

/// Ensure a community, setting its initial NIP-FI requirement and generation
/// atomically with its issuer bindings. Existing rows retain their policy.
/// Required creation without issuers is refused inside the transaction.
#[datastore_span(
name = "ensure_configured_community_with_nip_fi_policy",
system = "postgresql"
)]
pub async fn ensure_configured_community_with_nip_fi_policy(
&self,
normalized_host: &str,
nip_fi_issuers: &[String],
requires_nip_fi: bool,
) -> Result<EnsuredCommunityRecord> {
self.ensure_configured_community_with_operation(
normalized_host,
nip_fi_issuers,
requires_nip_fi,
crate::observability::WriterOperation::Authorization,
)
.await
Expand All @@ -544,6 +564,7 @@ impl Db {
self.ensure_configured_community_with_operation(
normalized_host,
nip_fi_issuers,
false,
crate::observability::WriterOperation::Bootstrap,
)
.await
Expand All @@ -553,21 +574,23 @@ impl Db {
&self,
normalized_host: &str,
nip_fi_issuers: &[String],
requires_nip_fi: bool,
operation: crate::observability::WriterOperation,
) -> Result<EnsuredCommunityRecord> {
let connection = crate::observability::acquire_writer(&self.pool, operation).await?;
let mut connection = sqlx::Transaction::begin(connection, None).await?;
let row = sqlx::query(
r#"
INSERT INTO communities (host)
VALUES ($1)
INSERT INTO communities (host, requires_nip_fi, policy_generation)
VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END)
ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host
WHERE communities.deletion_state = 'active'
AND communities.deleted_at IS NULL
RETURNING id, host, (xmax = 0) AS created
"#,
)
.bind(normalized_host)
.bind(requires_nip_fi)
.fetch_optional(&mut *connection)
.await?
.ok_or_else(|| {
Expand All @@ -580,6 +603,9 @@ impl Db {
let host: String = row.try_get("host")?;
let created: bool = row.try_get("created")?;
if created {
if requires_nip_fi && nip_fi_issuers.is_empty() {
return Err(DbError::NipFiBindingRequired);
}
self.insert_nip_fi_issuers_in_transaction(&mut connection, id, nip_fi_issuers)
.await?;
}
Expand Down Expand Up @@ -611,7 +637,9 @@ impl Db {

/// Like [`Db::create_community_with_owner`], binding `nip_fi_issuers` to
/// the new community in the same transaction. Identical retries return the
/// original record without touching its bindings.
/// original record without touching its bindings. Operator tenant creation
/// must use [`Db::create_community_with_owner_and_nip_fi_policy`] to apply
/// the configured creation requirement.
#[datastore_span(
name = "create_community_with_owner_and_nip_fi_issuers",
system = "postgresql"
Expand All @@ -622,6 +650,31 @@ impl Db {
owner_pubkey: &str,
nip_fi_issuers: &[String],
relay_keypair: &nostr::Keys,
) -> Result<relay_members::MembershipWrite<CreateCommunityWithOwnerResult>> {
self.create_community_with_owner_and_nip_fi_policy(
normalized_host,
owner_pubkey,
nip_fi_issuers,
false,
relay_keypair,
)
.await
}

/// Atomically create a community, owner, issuer bindings and initial NIP-FI
/// policy (generation 1 when required, otherwise 0). Identical retries keep
/// the original policy and bindings. Required creation needs issuers.
#[datastore_span(
name = "create_community_with_owner_and_nip_fi_policy",
system = "postgresql"
)]
pub async fn create_community_with_owner_and_nip_fi_policy(
&self,
normalized_host: &str,
owner_pubkey: &str,
nip_fi_issuers: &[String],
requires_nip_fi: bool,
relay_keypair: &nostr::Keys,
) -> Result<relay_members::MembershipWrite<CreateCommunityWithOwnerResult>> {
use relay_members::MembershipWrite;
let owner_pubkey = owner_pubkey.to_ascii_lowercase();
Expand All @@ -644,17 +697,21 @@ impl Db {

let row = sqlx::query(
r#"
INSERT INTO communities (host)
VALUES ($1)
INSERT INTO communities (host, requires_nip_fi, policy_generation)
VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END)
ON CONFLICT (lower(host)) DO NOTHING
RETURNING id, host
"#,
)
.bind(normalized_host)
.bind(requires_nip_fi)
.fetch_optional(&mut *tx)
.await?;

if let Some(row) = row {
if requires_nip_fi && nip_fi_issuers.is_empty() {
return Err(DbError::NipFiBindingRequired);
}
let id: Uuid = row.try_get("id")?;
let host: String = row.try_get("host")?;

Expand Down
25 changes: 14 additions & 11 deletions crates/buzz-db/src/store/community_auth/postgres_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,19 +420,22 @@ async fn migration_schema_expansion_preserves_existing_optional_rows_with_and_wi
.await
.unwrap();
let db = Db::from_pool(pool.clone());
let free = db
.ensure_configured_community("free-before.example")
// Model rows written by the pre-expansion relay. Today's creation writer
// also sets policy columns that this historical schema does not have yet.
let free = CommunityId::from_uuid(Uuid::new_v4());
let bound = CommunityId::from_uuid(Uuid::new_v4());
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, 'free-before.example'), ($2, 'bound-before.example')")
.bind(free.as_uuid())
.bind(bound.as_uuid())
.execute(&pool)
.await
.unwrap()
.id;
let bound = db
.ensure_configured_community_with_nip_fi_issuers(
"bound-before.example",
&[ISSUER.to_owned()],
)
.unwrap();
sqlx::query("INSERT INTO community_nip_fi_issuers (community_id, issuer) VALUES ($1, $2)")
.bind(bound.as_uuid())
.bind(ISSUER)
.execute(&pool)
.await
.unwrap()
.id;
.unwrap();
crate::migration::run_migrations(&pool).await.unwrap();
for community in [free, bound] {
assert_eq!(
Expand Down
Loading
Loading