invite: require owning account for generated codes

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-07-25 08:27:39 +03:00
committed by Tangled
parent 4f37ac26cd
commit 0c7cccb14c
8 changed files with 199 additions and 142 deletions
+19 -17
View File
@@ -43,7 +43,7 @@ pub async fn create_invite_code(
match state
.repos
.infra
.create_invite_code(&code, input.use_count, Some(&for_account))
.create_invite_code(&code, input.use_count, &for_account)
.await
{
Ok(true) => Ok(Json(CreateInviteCodeOutput { code })),
@@ -115,7 +115,7 @@ pub async fn create_invite_codes(
async move {
let codes: Vec<InviteCodeValue> = (0..code_count).map(|_| gen_invite_code()).collect();
infra_repo
.create_invite_codes_batch(&codes, use_count, admin_user_id, Some(&account))
.create_invite_codes_batch(&codes, use_count, admin_user_id, &account)
.await
.map(|_| AccountCodes { account, codes })
}
@@ -188,27 +188,24 @@ pub async fn get_account_invite_codes(
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
let infra_repo = state.repos.infra.clone();
async move {
let uses = infra_repo
let uses: Vec<InviteCodeUse> = infra_repo
.get_invite_code_uses(&info.code)
.await
.map(|use_rows| {
use_rows
.into_iter()
.map(|u| InviteCodeUse {
used_by: u.used_by_did.to_string(),
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
used_at: u.used_at.to_rfc3339(),
})
.collect::<Vec<_>>()
.log_db_err("fetching invite code uses")?
.into_iter()
.map(|u| InviteCodeUse {
used_by: u.used_by_did.to_string(),
used_by_handle: u.used_by_handle.map(|h| h.to_string()),
used_at: u.used_at.to_rfc3339(),
})
.unwrap_or_default();
.collect();
let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX);
if !include_used && use_count >= info.available_uses {
return None;
return Ok(None);
}
Some(InviteCode {
Ok(Some(InviteCode {
code: info.code,
available: info.available_uses,
disabled: false,
@@ -219,11 +216,16 @@ pub async fn get_account_invite_codes(
.unwrap_or_else(|| "admin".to_string()),
created_at: info.created_at.to_rfc3339(),
uses,
})
}))
}
}))
.await;
let codes: Vec<InviteCode> = codes.into_iter().flatten().collect();
let codes: Vec<InviteCode> = codes
.into_iter()
.collect::<Result<Vec<Option<InviteCode>>, ApiError>>()?
.into_iter()
.flatten()
.collect();
Ok(Json(GetAccountInviteCodesOutput { codes }))
}
+2 -2
View File
@@ -250,7 +250,7 @@ pub trait InfraRepository: Send + Sync {
&self,
code: &InviteCode,
use_count: i32,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<bool, DbError>;
async fn create_invite_codes_batch(
@@ -258,7 +258,7 @@ pub trait InfraRepository: Send + Sync {
codes: &[InviteCode],
use_count: i32,
created_by_user: Uuid,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<(), DbError>;
async fn get_invite_code_available_uses(
+121 -88
View File
@@ -7,10 +7,12 @@ use tranquil_db_traits::{
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
};
use tranquil_types::{CidLink, Did, Handle, InviteCode};
use tranquil_types::{CidLink, Did, InviteCode};
use uuid::Uuid;
use super::col;
use super::user::map_sqlx_error;
use super::{column, legacy_column, opt_column};
pub struct PostgresInfraRepository {
pool: PgPool,
@@ -153,9 +155,9 @@ impl InfraRepository for PostgresInfraRepository {
&self,
code: &InviteCode,
use_count: i32,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<bool, DbError> {
let for_account_str = for_account.map(|d| d.as_str());
let for_account_str = for_account.as_str();
let result = sqlx::query!(
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
SELECT $1, $2, id, $3 FROM users WHERE is_admin = true LIMIT 1"#,
@@ -175,9 +177,9 @@ impl InfraRepository for PostgresInfraRepository {
codes: &[InviteCode],
use_count: i32,
created_by_user: Uuid,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<(), DbError> {
let for_account_str = for_account.map(|d| d.as_str());
let for_account_str = for_account.as_str();
let code_strs: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
sqlx::query!(
r#"INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)
@@ -250,17 +252,19 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: None,
.map(|r| {
Ok(InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: None,
})
})
.collect())
.collect()
}
async fn get_invite_code_uses(&self, code: &InviteCode) -> Result<Vec<InviteCodeUse>, DbError> {
@@ -278,11 +282,13 @@ impl InfraRepository for PostgresInfraRepository {
Ok(results
.into_iter()
.map(|r| InviteCodeUse {
code: code.clone(),
used_by_did: Did::from(r.did),
used_by_handle: Some(Handle::from(r.handle)),
used_at: r.used_at,
.filter_map(|r| {
Some(InviteCodeUse {
code: code.clone(),
used_by_did: legacy_column(r.did, col::USERS_DID)?,
used_by_handle: legacy_column(r.handle, col::USERS_HANDLE),
used_at: r.used_at,
})
})
.collect())
}
@@ -436,10 +442,10 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| (r.id, Did::from(r.did)))
.collect())
.map(|r| Ok((r.id, column(r.did, col::USERS_DID)?)))
.collect()
}
async fn get_invite_code_uses_batch(
@@ -459,15 +465,17 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| InviteCodeUse {
code: InviteCode::from(r.code),
used_by_did: Did::from(r.did),
used_by_handle: None,
used_at: r.used_at,
.map(|r| {
Ok(InviteCodeUse {
code: InviteCode::from(r.code),
used_by_did: column(r.did, col::USERS_DID)?,
used_by_handle: None,
used_at: r.used_at,
})
})
.collect())
.collect()
}
async fn get_invites_created_by_user(
@@ -485,17 +493,19 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
.map(|r| {
Ok(InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: Some(column(r.created_by, col::USERS_DID)?),
})
})
.collect())
.collect()
}
async fn get_invite_code_info(
@@ -513,14 +523,18 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(|r| InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
}))
result
.map(|r| {
Ok(InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: Some(column(r.created_by, col::USERS_DID)?),
})
})
.transpose()
}
async fn get_invite_codes_by_users(
@@ -539,22 +553,22 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(results
results
.into_iter()
.map(|r| {
(
Ok((
r.created_by_user,
InviteCodeInfo {
code: InviteCode::from(r.code),
available_uses: r.available_uses,
state: InviteCodeState::from_optional_disabled_flag(r.disabled),
for_account: Some(Did::from(r.for_account)),
for_account: legacy_column(r.for_account, col::INVITE_CODES_FOR_ACCOUNT),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
created_by: Some(column(r.created_by, col::USERS_DID)?),
},
)
))
})
.collect())
.collect()
}
async fn get_invite_code_used_by_user(
@@ -683,10 +697,14 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(|r| DeletionRequest {
did: Did::from(r.did),
expires_at: r.expires_at,
}))
result
.map(|r| {
Ok(DeletionRequest {
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
expires_at: r.expires_at,
})
})
.transpose()
}
async fn delete_deletion_request(&self, token: &str) -> Result<(), DbError> {
@@ -1027,16 +1045,20 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(result.map(|r| AdminAccountInfo {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
}))
result
.map(|r| {
Ok(AdminAccountInfo {
id: r.id,
did: column(r.did, col::USERS_DID)?,
handle: column(r.handle, col::USERS_HANDLE)?,
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
})
})
.transpose()
}
async fn get_admin_account_infos_by_dids(
@@ -1058,15 +1080,17 @@ impl InfraRepository for PostgresInfraRepository {
Ok(results
.into_iter()
.map(|r| AdminAccountInfo {
id: r.id,
did: Did::from(r.did),
handle: Handle::from(r.handle),
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
.filter_map(|r| {
Some(AdminAccountInfo {
id: r.id,
did: legacy_column(r.did, col::USERS_DID)?,
handle: legacy_column(r.handle, col::USERS_HANDLE)?,
email: r.email,
created_at: r.created_at,
invites_disabled: r.invites_disabled.unwrap_or(false),
email_verified: r.email_verified,
deactivated_at: r.deactivated_at,
})
})
.collect())
}
@@ -1105,11 +1129,14 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| DeletionRequestWithToken {
token: r.token,
did: Did::new(r.did).expect("valid DID in database"),
expires_at: r.expires_at,
}))
row.map(|r| {
Ok(DeletionRequestWithToken {
token: r.token,
did: column(r.did, col::ACCOUNT_DELETION_REQUESTS_DID)?,
expires_at: r.expires_at,
})
})
.transpose()
}
async fn get_latest_comms_for_user(
@@ -1202,14 +1229,20 @@ impl InfraRepository for PostgresInfraRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| ReservedSigningKeyFull {
id: r.id,
did: r.did.map(|d| Did::new(d).expect("valid DID in database")),
public_key_did_key: Did::from(r.public_key_did_key),
private_key_bytes: r.private_key_bytes,
expires_at: r.expires_at,
used_at: r.used_at,
}))
row.map(|r| {
Ok(ReservedSigningKeyFull {
id: r.id,
did: opt_column(r.did, col::RESERVED_SIGNING_KEYS_DID)?,
public_key_did_key: column(
r.public_key_did_key,
col::RESERVED_SIGNING_KEYS_PUBLIC_KEY_DID_KEY,
)?,
private_key_bytes: r.private_key_bytes,
expires_at: r.expires_at,
used_at: r.used_at,
})
})
.transpose()
}
async fn get_plc_tokens_by_did(&self, did: &Did) -> Result<Vec<PlcTokenInfo>, DbError> {
+9 -13
View File
@@ -117,7 +117,7 @@ fn test_handle(suffix: &str) -> Handle {
}
fn test_cid(seed: u8) -> CidLink {
CidLink::from(helpers::make_cid(&[seed]))
CidLink::from_cid(&helpers::make_cid(&[seed]))
}
fn test_nsid(name: &str) -> Nsid {
@@ -139,7 +139,7 @@ fn test_at_uri(did: &Did, collection: &Nsid, rkey: &Rkey) -> AtUri {
}
async fn seed_user(repos: &PostgresRepositories, did: &Did, handle: &Handle) -> Uuid {
let commit_cid = CidLink::from(helpers::make_cid(did.as_str().as_bytes()).to_string());
let commit_cid = CidLink::from_cid(&helpers::make_cid(did.as_str().as_bytes()));
let input = tranquil_db_traits::CreatePasswordAccountInput {
handle: handle.clone(),
email: None,
@@ -155,7 +155,7 @@ async fn seed_user(repos: &PostgresRepositories, did: &Did, handle: &Handle) ->
encryption_version: 0,
reserved_key_id: None,
commit_cid,
repo_rev: Tid::from("rev0".to_string()),
repo_rev: Tid::new("3k2aaaaaaaaaa").expect("valid TID"),
genesis_block_cids: vec![],
invite_code: None,
birthdate_pref: None,
@@ -190,7 +190,7 @@ async fn seed_records(
&collections,
&rkeys,
&cids,
&Tid::from("rev1".to_string()),
&Tid::new("3k2aaaaaaaaab").expect("valid TID"),
)
.await
.unwrap();
@@ -1109,15 +1109,11 @@ async fn parity_invite_codes() {
let _ = seed_repos(&f, &did, &handle).await;
let code = tranquil_types::InviteCode::from(format!("parity-invite-{}", Uuid::new_v4()));
let pg_created =
f.pg.infra
.create_invite_code(&code, 5, Some(&did))
.await
.unwrap();
let pg_created = f.pg.infra.create_invite_code(&code, 5, &did).await.unwrap();
let store_created = f
.store
.infra
.create_invite_code(&code, 5, Some(&did))
.create_invite_code(&code, 5, &did)
.await
.unwrap();
assert_eq!(pg_created, store_created);
@@ -1359,7 +1355,7 @@ async fn parity_signing_key_reservation() {
let f = ParityFixture::new().await;
let did = test_did("sigkey");
let expires = chrono::Utc::now() + chrono::Duration::hours(1);
let pub_key = Did::from(format!("did:key:z6Mk{}", Uuid::new_v4().simple()));
let pub_key = Did::new(format!("did:key:z6Mk{}", Uuid::new_v4().simple())).expect("valid DID");
let priv_bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
f.pg.infra
@@ -1418,7 +1414,7 @@ async fn parity_repo_root_operations() {
assert_eq!(pg_root, store_root);
let new_root = test_cid(99);
let rev1 = Tid::from("rev1".to_string());
let rev1 = Tid::new("3k2aaaaaaaaab").expect("valid TID");
f.pg.repo
.update_repo_root(pg_uid, &new_root, &rev1)
.await
@@ -1632,7 +1628,7 @@ async fn parity_prune_events_older_than() {
blobs: None,
blocks: None,
prev_data_cid: None,
rev: Some(Tid::from("rev0".to_string())),
rev: Some(Tid::new("3k2aaaaaaaaaa").expect("valid TID")),
};
let baseline = f.pg.repo.get_max_seq().await.unwrap();
@@ -1836,14 +1836,14 @@ impl<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
&self,
code: &InviteCode,
use_count: i32,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<bool, DbError> {
let (tx, rx) = oneshot::channel();
self.pool
.send(MetastoreRequest::Infra(InfraRequest::CreateInviteCode {
code: code.clone(),
use_count,
for_account: for_account.cloned(),
for_account: for_account.clone(),
tx,
}))?;
recv(rx).await
@@ -1854,7 +1854,7 @@ impl<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
codes: &[InviteCode],
use_count: i32,
created_by_user: Uuid,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<(), DbError> {
let (tx, rx) = oneshot::channel();
self.pool.send(MetastoreRequest::Infra(
@@ -1862,7 +1862,7 @@ impl<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
codes: codes.to_vec(),
use_count,
created_by_user,
for_account: for_account.cloned(),
for_account: for_account.clone(),
tx,
},
))?;
@@ -1828,14 +1828,14 @@ pub enum InfraRequest {
CreateInviteCode {
code: InviteCode,
use_count: i32,
for_account: Option<Did>,
for_account: Did,
tx: Tx<bool>,
},
CreateInviteCodesBatch {
codes: Vec<InviteCode>,
use_count: i32,
created_by_user: Uuid,
for_account: Option<Did>,
for_account: Did,
tx: Tx<()>,
},
GetInviteCodeAvailableUses {
@@ -3911,7 +3911,7 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, req: InfraRequest) {
let result = state
.metastore
.infra_ops()
.create_invite_code(&code, use_count, for_account.as_ref())
.create_invite_code(&code, use_count, &for_account)
.map_err(metastore_to_db);
let _ = tx.send(result);
}
@@ -3925,7 +3925,7 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, req: InfraRequest) {
let result = state
.metastore
.infra_ops()
.create_invite_codes_batch(&codes, use_count, created_by_user, for_account.as_ref())
.create_invite_codes_batch(&codes, use_count, created_by_user, &for_account)
.map_err(metastore_to_db);
let _ = tx.send(result);
}
@@ -6206,7 +6206,7 @@ mod tests {
let owner = Did::new("did:plc:whelk").expect("valid DID");
let whelk = InviteCode::new("whelk");
assert!(infra.create_invite_code(&squid, 1, Some(&owner)).unwrap());
assert!(infra.create_invite_code(&squid, 1, &owner).unwrap());
infra.reserve_invite_code(&squid).unwrap();
assert_eq!(
@@ -6245,7 +6245,7 @@ mod tests {
let owner = Did::new("did:plc:whelk").expect("valid DID");
let whelk = InviteCode::new("whelk");
assert!(infra.create_invite_code(&squid, 1, Some(&owner)).unwrap());
assert!(infra.create_invite_code(&squid, 1, &owner).unwrap());
infra.reserve_invite_code(&squid).unwrap();
infra.refund_invite_code(&squid).unwrap();
@@ -362,7 +362,7 @@ impl InfraOps {
&self,
code: &InviteCode,
use_count: i32,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<bool, MetastoreError> {
let key = invite_code_key(code);
let existing = self
@@ -377,7 +377,7 @@ impl InfraOps {
code: code.as_str().to_owned(),
available_uses: use_count,
disabled: false,
for_account: for_account.map(|d| d.to_string()),
for_account: Some(for_account.to_string()),
created_by: None,
created_at_ms: Utc::now().timestamp_millis(),
};
@@ -393,7 +393,7 @@ impl InfraOps {
codes: &[InviteCode],
use_count: i32,
created_by_user: Uuid,
for_account: Option<&Did>,
for_account: &Did,
) -> Result<(), MetastoreError> {
let now_ms = Utc::now().timestamp_millis();
let mut batch = self.db.batch();
@@ -403,7 +403,7 @@ impl InfraOps {
code: code.as_str().to_owned(),
available_uses: use_count,
disabled: false,
for_account: for_account.map(|d| d.to_string()),
for_account: Some(for_account.to_string()),
created_by: Some(created_by_user),
created_at_ms: now_ms,
};
@@ -1458,14 +1458,19 @@ impl InfraOps {
SigningKeyValue::deserialize,
"corrupt signing key",
)?;
Ok(val.map(|v| ReservedSigningKeyFull {
id: v.id,
did: v.did.and_then(|d| Did::new(d).ok()),
public_key_did_key: Did::from(v.public_key_did_key),
private_key_bytes: v.private_key_bytes,
expires_at: DateTime::from_timestamp_millis(v.expires_at_ms).unwrap_or_default(),
used_at: v.used_at_ms.and_then(DateTime::from_timestamp_millis),
}))
val.map(|v| {
Ok(ReservedSigningKeyFull {
id: v.id,
did: v.did.and_then(|d| Did::new(d).ok()),
public_key_did_key: Did::new(v.public_key_did_key).map_err(|_| {
MetastoreError::CorruptData("corrupt reserved signing key public_key_did_key")
})?,
private_key_bytes: v.private_key_bytes,
expires_at: DateTime::from_timestamp_millis(v.expires_at_ms).unwrap_or_default(),
used_at: v.used_at_ms.and_then(DateTime::from_timestamp_millis),
})
})
.transpose()
}
pub fn get_plc_tokens_for_user(
@@ -0,0 +1,21 @@
-- The old insert path wrote for_account through without validating it
-- so an older row can contain any string at all
-- most often a handle or the literal 'admin' the column defaulted to
-- where the read path warns and drops whatever the Did newtype won't parse
-- so an unconverted row comes back with no for_account at all.
-- The first statement resolves the bare handles that still match a user
-- and the second covers every 'admin' row, since created_by_user is NOT NULL.
-- Nothing else converts
-- so a prefixed handle, a handle whose user has renamed since,
-- and anything that was never an identifier all stay as they are.
UPDATE invite_codes ic
SET for_account = u.did
FROM users u
WHERE LOWER(u.handle) = LOWER(ic.for_account)
AND ic.for_account NOT LIKE 'did:%';
UPDATE invite_codes ic
SET for_account = u.did
FROM users u
WHERE u.id = ic.created_by_user
AND ic.for_account = 'admin';