diff --git a/services/identity/src/database/farcaster.rs b/services/identity/src/database/farcaster.rs index f4de0dcdb..5a12f79aa 100644 --- a/services/identity/src/database/farcaster.rs +++ b/services/identity/src/database/farcaster.rs @@ -1,153 +1,165 @@ use comm_lib::aws::ddb::types::AttributeValue; use comm_lib::aws::ddb::types::ReturnValue; use comm_lib::database::AttributeExtractor; use comm_lib::database::AttributeMap; use comm_lib::database::DBItemAttributeError; use comm_lib::database::DBItemError; use comm_lib::database::Value; use tracing::error; use crate::constants::error_types; use crate::constants::USERS_TABLE; use crate::constants::USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME; use crate::constants::USERS_TABLE_FARCASTER_ID_INDEX; use crate::constants::USERS_TABLE_PARTITION_KEY; use crate::constants::USERS_TABLE_USERNAME_ATTRIBUTE; use crate::constants::USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE; use crate::grpc_services::protos::unauth::FarcasterUser; use super::DatabaseClient; use super::Error; pub struct FarcasterUserData(pub FarcasterUser); impl DatabaseClient { #[tracing::instrument(skip_all)] pub async fn get_farcaster_users( &self, farcaster_ids: Vec, ) -> Result, Error> { let mut users: Vec = Vec::new(); for id in farcaster_ids { let query_response = self .client .query() .table_name(USERS_TABLE) .index_name(USERS_TABLE_FARCASTER_ID_INDEX) .key_condition_expression(format!( "{} = :val", USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME )) .expression_attribute_values(":val", AttributeValue::S(id)) .send() .await .map_err(|e| { error!( errorType = error_types::FARCASTER_DB_LOG, "Failed to query users by farcasterID: {:?}", e ); Error::AwsSdk(e.into()) })? .items .and_then(|mut items| items.pop()) .map(FarcasterUserData::try_from) .transpose() .map_err(Error::from)?; if let Some(data) = query_response { users.push(data); } } Ok(users) } pub async fn add_farcaster_id( &self, user_id: String, farcaster_id: String, ) -> Result<(), Error> { let update_expression = format!( "SET {0} = if_not_exists({0}, :val)", USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME, ); let response = self .client .update_item() .table_name(USERS_TABLE) .key(USERS_TABLE_PARTITION_KEY, AttributeValue::S(user_id)) .update_expression(update_expression) .expression_attribute_values( ":val", AttributeValue::S(farcaster_id.clone()), ) .return_values(ReturnValue::UpdatedNew) .send() .await - .map_err(|e| Error::AwsSdk(e.into()))?; + .map_err(|e| { + error!( + errorType = error_types::FARCASTER_DB_LOG, + "DDB client failed to add farcasterID: {:?}", e + ); + Error::AwsSdk(e.into()) + })?; match response.attributes { None => return Err(Error::MissingItem), Some(mut attrs) => { let farcaster_id_from_table: String = attrs.take_attr(USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME)?; if farcaster_id_from_table != farcaster_id { return Err(Error::CannotOverwrite); } } } Ok(()) } pub async fn remove_farcaster_id( &self, user_id: String, ) -> Result<(), Error> { let update_expression = format!("REMOVE {}", USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME); self .client .update_item() .table_name(USERS_TABLE) .key(USERS_TABLE_PARTITION_KEY, AttributeValue::S(user_id)) .update_expression(update_expression) .send() .await - .map_err(|e| Error::AwsSdk(e.into()))?; + .map_err(|e| { + error!( + errorType = error_types::FARCASTER_DB_LOG, + "DDB client failed to remove farcasterID: {:?}", e + ); + Error::AwsSdk(e.into()) + })?; Ok(()) } } impl TryFrom for FarcasterUserData { type Error = DBItemError; fn try_from(mut attrs: AttributeMap) -> Result { let user_id = attrs.take_attr(USERS_TABLE_PARTITION_KEY)?; let maybe_username = attrs.take_attr(USERS_TABLE_USERNAME_ATTRIBUTE)?; let maybe_wallet_address = attrs.take_attr(USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE)?; let username = match (maybe_username, maybe_wallet_address) { (Some(u), _) => u, (_, Some(w)) => w, (_, _) => { return Err(DBItemError { attribute_name: USERS_TABLE_USERNAME_ATTRIBUTE.to_string(), attribute_value: Value::AttributeValue(None), attribute_error: DBItemAttributeError::Missing, }); } }; let farcaster_id = attrs.take_attr(USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME)?; Ok(Self(FarcasterUser { user_id, username, farcaster_id, })) } } diff --git a/services/identity/src/database/one_time_keys.rs b/services/identity/src/database/one_time_keys.rs index 2ab4341b7..e7ca77377 100644 --- a/services/identity/src/database/one_time_keys.rs +++ b/services/identity/src/database/one_time_keys.rs @@ -1,499 +1,505 @@ use std::collections::HashSet; use comm_lib::{ aws::{ ddb::types::{ AttributeValue, Delete, DeleteRequest, TransactWriteItem, Update, WriteRequest, }, DynamoDBError, }, database::{ batch_operations::{batch_write, ExponentialBackoffConfig}, parse_int_attribute, AttributeExtractor, AttributeMap, DBItemAttributeError, DBItemError, }, }; use tracing::{debug, error, info}; use crate::{ constants::{ error_types, MAX_ONE_TIME_KEYS, ONE_TIME_KEY_UPLOAD_LIMIT_PER_ACCOUNT, }, database::DeviceIDAttribute, ddb_utils::{ create_one_time_key_partition_key, into_one_time_put_requests, into_one_time_update_and_delete_requests, is_transaction_retryable, OlmAccountType, }, error::{consume_error, Error}, olm::is_valid_olm_key, }; use super::DatabaseClient; impl DatabaseClient { /// Gets the next one-time key for the account and then, in a transaction, /// deletes the key and updates the key count /// /// Returns the retrieved one-time key if it exists and a boolean indicating /// whether the `spawn_refresh_keys_task`` was called #[tracing::instrument(skip_all)] pub(super) async fn get_one_time_key( &self, user_id: &str, device_id: &str, account_type: OlmAccountType, can_request_more_keys: bool, ) -> Result<(Option, bool), Error> { use crate::constants::devices_table; use crate::constants::retry; use crate::constants::ONE_TIME_KEY_MINIMUM_THRESHOLD; let attr_otk_count = match account_type { OlmAccountType::Content => devices_table::ATTR_CONTENT_OTK_COUNT, OlmAccountType::Notification => devices_table::ATTR_NOTIF_OTK_COUNT, }; fn spawn_refresh_keys_task(device_id: &str) { // Clone the string slice to move into the async block let device_id = device_id.to_string(); tokio::spawn(async move { debug!("Attempting to request more keys for device: {}", &device_id); let result = crate::tunnelbroker::send_refresh_keys_request(&device_id).await; consume_error(result); }); } // TODO: Introduce `transact_write_helper` similar to `batch_write_helper` // in `comm-lib` to handle transactions with retries let mut attempt = 0; // TODO: Introduce nanny task that handles calling `spawn_refresh_keys_task` let mut requested_more_keys = false; loop { attempt += 1; if attempt > retry::MAX_ATTEMPTS { return Err(Error::MaxRetriesExceeded); } let otk_count = self.get_otk_count(user_id, device_id, account_type).await?; if otk_count < ONE_TIME_KEY_MINIMUM_THRESHOLD && can_request_more_keys { spawn_refresh_keys_task(device_id); requested_more_keys = true; } if otk_count < 1 { return Ok((None, requested_more_keys)); } let Some(otk_row) = self .get_one_time_keys(user_id, device_id, account_type, Some(1)) .await? .pop() else { return Err(Error::NotEnoughOneTimeKeys); }; let delete_otk_operation = otk_row.as_delete_request(); let update_otk_count = Update::builder() .table_name(devices_table::NAME) .key( devices_table::ATTR_USER_ID, AttributeValue::S(user_id.to_string()), ) .key( devices_table::ATTR_ITEM_ID, DeviceIDAttribute(device_id.into()).into(), ) .update_expression(format!("ADD {} :decrement_val", attr_otk_count)) .expression_attribute_values( ":decrement_val", AttributeValue::N("-1".to_string()), ) .condition_expression(format!("{} = :old_val", attr_otk_count)) .expression_attribute_values( ":old_val", AttributeValue::N(otk_count.to_string()), ) .build(); let update_otk_count_operation = TransactWriteItem::builder() .update(update_otk_count) .build(); let transaction = self .client .transact_write_items() .set_transact_items(Some(vec![ delete_otk_operation, update_otk_count_operation, ])) .send() .await; match transaction { Ok(_) => return Ok((Some(otk_row.otk), requested_more_keys)), Err(e) => { let dynamo_db_error = DynamoDBError::from(e); let retryable_codes = HashSet::from([ retry::CONDITIONAL_CHECK_FAILED, retry::TRANSACTION_CONFLICT, ]); if is_transaction_retryable(&dynamo_db_error, &retryable_codes) { info!("Encountered transaction conflict while retrieving one-time key - retrying"); } else { error!( errorType = error_types::OTK_DB_LOG, "One-time key retrieval transaction failed: {:?}", dynamo_db_error ); return Err(Error::AwsSdk(dynamo_db_error)); } } } } } #[tracing::instrument(skip_all)] async fn get_one_time_keys( &self, user_id: &str, device_id: &str, account_type: OlmAccountType, num_keys: Option, ) -> Result, Error> { use crate::constants::one_time_keys_table::*; let partition_key = create_one_time_key_partition_key(user_id, device_id, account_type); let mut query = self .client .query() .table_name(NAME) .key_condition_expression("#pk = :pk") .expression_attribute_names("#pk", PARTITION_KEY) .expression_attribute_values(":pk", AttributeValue::S(partition_key)); if let Some(limit) = num_keys { // DynamoDB will reject the `query` request if `limit < 1` if limit < 1 { return Ok(Vec::new()); } query = query.limit(limit as i32); } let otk_rows = query .send() .await - .map_err(|e| Error::AwsSdk(e.into()))? + .map_err(|e| { + error!( + errorType = error_types::OTK_DB_LOG, + "DDB client failed to query OTK rows: {:?}", e + ); + Error::AwsSdk(e.into()) + })? .items .unwrap_or_default() .into_iter() .map(OTKRow::try_from) .collect::, _>>() .map_err(Error::from)?; if let Some(limit) = num_keys { if otk_rows.len() != limit { error!( errorType = error_types::OTK_DB_LOG, "There are fewer one-time keys than the number requested" ); return Err(Error::NotEnoughOneTimeKeys); } } Ok(otk_rows) } #[tracing::instrument(skip_all)] pub async fn append_one_time_prekeys( &self, user_id: &str, device_id: &str, content_one_time_keys: &Vec, notif_one_time_keys: &Vec, ) -> Result<(), Error> { use crate::constants::retry; let num_content_keys_to_append = content_one_time_keys.len(); let num_notif_keys_to_append = notif_one_time_keys.len(); if num_content_keys_to_append > ONE_TIME_KEY_UPLOAD_LIMIT_PER_ACCOUNT || num_notif_keys_to_append > ONE_TIME_KEY_UPLOAD_LIMIT_PER_ACCOUNT { return Err(Error::OneTimeKeyUploadLimitExceeded); } if content_one_time_keys .iter() .any(|otk| !is_valid_olm_key(otk)) || notif_one_time_keys.iter().any(|otk| !is_valid_olm_key(otk)) { debug!("Invalid one-time key format"); return Err(Error::InvalidFormat); } let current_time = chrono::Utc::now(); let content_otk_requests = into_one_time_put_requests( user_id, device_id, content_one_time_keys, OlmAccountType::Content, current_time, ); let notif_otk_requests = into_one_time_put_requests( user_id, device_id, notif_one_time_keys, OlmAccountType::Notification, current_time, ); let current_content_otk_count = self .get_otk_count(user_id, device_id, OlmAccountType::Content) .await?; let current_notif_otk_count = self .get_otk_count(user_id, device_id, OlmAccountType::Notification) .await?; let num_content_keys_to_delete = (num_content_keys_to_append + current_content_otk_count) .saturating_sub(MAX_ONE_TIME_KEYS); let num_notif_keys_to_delete = (num_notif_keys_to_append + current_notif_otk_count) .saturating_sub(MAX_ONE_TIME_KEYS); let content_keys_to_delete = self .get_one_time_keys( user_id, device_id, OlmAccountType::Content, Some(num_content_keys_to_delete), ) .await?; let notif_keys_to_delete = self .get_one_time_keys( user_id, device_id, OlmAccountType::Notification, Some(num_notif_keys_to_delete), ) .await?; let update_and_delete_otk_count_operation = into_one_time_update_and_delete_requests( user_id, device_id, num_content_keys_to_append, num_notif_keys_to_append, content_keys_to_delete, notif_keys_to_delete, ); let mut operations = Vec::new(); operations.extend_from_slice(&content_otk_requests); operations.extend_from_slice(¬if_otk_requests); operations.extend_from_slice(&update_and_delete_otk_count_operation); // TODO: Introduce `transact_write_helper` similar to `batch_write_helper` // in `comm-lib` to handle transactions with retries let mut attempt = 0; loop { attempt += 1; if attempt > retry::MAX_ATTEMPTS { return Err(Error::MaxRetriesExceeded); } let transaction = self .client .transact_write_items() .set_transact_items(Some(operations.clone())) .send() .await; match transaction { Ok(_) => break, Err(e) => { let dynamo_db_error = DynamoDBError::from(e); let retryable_codes = HashSet::from([retry::TRANSACTION_CONFLICT]); if is_transaction_retryable(&dynamo_db_error, &retryable_codes) { info!("Encountered transaction conflict while uploading one-time keys - retrying"); } else { error!( errorType = error_types::OTK_DB_LOG, "One-time key upload transaction failed: {:?}", dynamo_db_error ); return Err(Error::AwsSdk(dynamo_db_error)); } } } } Ok(()) } #[tracing::instrument(skip_all)] async fn get_otk_count( &self, user_id: &str, device_id: &str, account_type: OlmAccountType, ) -> Result { use crate::constants::devices_table; let attr_name = match account_type { OlmAccountType::Content => devices_table::ATTR_CONTENT_OTK_COUNT, OlmAccountType::Notification => devices_table::ATTR_NOTIF_OTK_COUNT, }; let response = self .client .get_item() .table_name(devices_table::NAME) .projection_expression(attr_name) .key( devices_table::ATTR_USER_ID, AttributeValue::S(user_id.to_string()), ) .key( devices_table::ATTR_ITEM_ID, DeviceIDAttribute(device_id.into()).into(), ) .send() .await .map_err(|e| { error!( errorType = error_types::OTK_DB_LOG, "Failed to get user's OTK count: {:?}", e ); Error::AwsSdk(e.into()) })?; let mut user_item = response.item.unwrap_or_default(); match parse_int_attribute(attr_name, user_item.remove(attr_name)) { Ok(num) => Ok(num), Err(DBItemError { attribute_error: DBItemAttributeError::Missing, .. }) => Ok(0), Err(e) => Err(Error::Attribute(e)), } } /// Deletes all data for a user's device from one-time keys table pub async fn delete_otks_table_rows_for_user_device( &self, user_id: &str, device_id: &str, ) -> Result<(), Error> { use crate::constants::one_time_keys_table::*; let content_otk_primary_keys = self .get_one_time_keys(user_id, device_id, OlmAccountType::Content, None) .await?; let notif_otk_primary_keys = self .get_one_time_keys(user_id, device_id, OlmAccountType::Notification, None) .await?; let delete_requests = content_otk_primary_keys .into_iter() .chain(notif_otk_primary_keys) .map(|otk_row| { let request = DeleteRequest::builder() .key(PARTITION_KEY, AttributeValue::S(otk_row.partition_key)) .key(SORT_KEY, AttributeValue::S(otk_row.sort_key)) .build(); WriteRequest::builder().delete_request(request).build() }) .collect::>(); batch_write( &self.client, NAME, delete_requests, ExponentialBackoffConfig::default(), ) .await .map_err(Error::from)?; Ok(()) } /// Deletes all data for a user from one-time keys table pub async fn delete_otks_table_rows_for_user( &self, user_id: &str, ) -> Result<(), Error> { let maybe_device_list_row = self.get_current_device_list(user_id).await?; let Some(device_list_row) = maybe_device_list_row else { info!("No devices associated with user. Skipping one-time key removal."); return Ok(()); }; for device_id in device_list_row.device_ids { self .delete_otks_table_rows_for_user_device(user_id, &device_id) .await?; } Ok(()) } } pub struct OTKRow { pub partition_key: String, pub sort_key: String, pub otk: String, } impl OTKRow { pub fn as_delete_request(&self) -> TransactWriteItem { use crate::constants::one_time_keys_table as otk_table; let delete_otk = Delete::builder() .table_name(otk_table::NAME) .key( otk_table::PARTITION_KEY, AttributeValue::S(self.partition_key.to_string()), ) .key( otk_table::SORT_KEY, AttributeValue::S(self.sort_key.to_string()), ) .condition_expression("attribute_exists(#otk)") .expression_attribute_names("#otk", otk_table::ATTR_ONE_TIME_KEY) .build(); TransactWriteItem::builder().delete(delete_otk).build() } } impl TryFrom for OTKRow { type Error = DBItemError; fn try_from(mut attrs: AttributeMap) -> Result { use crate::constants::one_time_keys_table as otk_table; let partition_key = attrs.take_attr(otk_table::PARTITION_KEY)?; let sort_key = attrs.take_attr(otk_table::SORT_KEY)?; let otk: String = attrs.take_attr(otk_table::ATTR_ONE_TIME_KEY)?; Ok(Self { partition_key, sort_key, otk, }) } } diff --git a/services/identity/src/database/token.rs b/services/identity/src/database/token.rs index 483ac10e4..e152bc806 100644 --- a/services/identity/src/database/token.rs +++ b/services/identity/src/database/token.rs @@ -1,285 +1,285 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use comm_lib::{ aws::ddb::{ operation::{get_item::GetItemOutput, put_item::PutItemOutput}, types::{AttributeValue, DeleteRequest, WriteRequest}, }, database::{ batch_operations::{batch_write, ExponentialBackoffConfig}, DBItemAttributeError, DBItemError, TryFromAttribute, }, }; use constant_time_eq::constant_time_eq; use tracing::{error, info}; use crate::{ constants::error_types, error::Error, token::{AccessTokenData, AuthType}, }; use super::{create_composite_primary_key, DatabaseClient}; impl DatabaseClient { #[tracing::instrument(skip_all)] pub async fn get_access_token_data( &self, user_id: String, signing_public_key: String, ) -> Result, Error> { use crate::constants::token_table::*; let primary_key = create_composite_primary_key( (PARTITION_KEY.to_string(), user_id.clone()), (SORT_KEY.to_string(), signing_public_key.clone()), ); let get_item_result = self .client .get_item() .table_name(NAME) .set_key(Some(primary_key)) .consistent_read(true) .send() .await; match get_item_result { Ok(GetItemOutput { item: Some(mut item), .. }) => { let created = DateTime::::try_from_attr( ATTR_CREATED, item.remove(ATTR_CREATED), )?; let auth_type = parse_auth_type_attribute(item.remove(ATTR_AUTH_TYPE))?; let valid = parse_valid_attribute(item.remove(ATTR_VALID))?; let access_token = parse_token_attribute(item.remove(ATTR_TOKEN))?; Ok(Some(AccessTokenData { user_id, signing_public_key, access_token, created, auth_type, valid, })) } Ok(_) => { info!( "No item found for user {} and signing public key {} in token table", user_id, signing_public_key ); Ok(None) } Err(e) => { error!( - errorType = error_types::TOKEN_DB_LOG, "DynamoDB client failed to get token for user {} with signing public key {}: {}", + errorType = error_types::TOKEN_DB_LOG, "DynamoDB client failed to get token for user {} with signing public key {}: {:?}", user_id, signing_public_key, e ); Err(Error::AwsSdk(e.into())) } } } pub async fn verify_access_token( &self, user_id: String, signing_public_key: String, access_token_to_verify: String, ) -> Result { let is_valid = self .get_access_token_data(user_id, signing_public_key) .await? .map(|access_token_data| { constant_time_eq( access_token_data.access_token.as_bytes(), access_token_to_verify.as_bytes(), ) && access_token_data.is_valid() }) .unwrap_or(false); Ok(is_valid) } pub async fn put_access_token_data( &self, access_token_data: AccessTokenData, ) -> Result { use crate::constants::token_table::*; let item = HashMap::from([ ( PARTITION_KEY.to_string(), AttributeValue::S(access_token_data.user_id), ), ( SORT_KEY.to_string(), AttributeValue::S(access_token_data.signing_public_key), ), ( ATTR_TOKEN.to_string(), AttributeValue::S(access_token_data.access_token), ), ( ATTR_CREATED.to_string(), AttributeValue::S(access_token_data.created.to_rfc3339()), ), ( ATTR_AUTH_TYPE.to_string(), AttributeValue::S(match access_token_data.auth_type { AuthType::Password => "password".to_string(), AuthType::Wallet => "wallet".to_string(), }), ), ( ATTR_VALID.to_string(), AttributeValue::Bool(access_token_data.valid), ), ]); self .client .put_item() .table_name(NAME) .set_item(Some(item)) .send() .await .map_err(|e| Error::AwsSdk(e.into())) } pub async fn delete_access_token_data( &self, user_id: impl Into, device_id_key: impl Into, ) -> Result<(), Error> { use crate::constants::token_table::*; let user_id = user_id.into(); let device_id = device_id_key.into(); self .client .delete_item() .table_name(NAME) .key(PARTITION_KEY.to_string(), AttributeValue::S(user_id)) .key(SORT_KEY.to_string(), AttributeValue::S(device_id)) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; Ok(()) } #[tracing::instrument(skip_all)] pub async fn delete_all_tokens_for_user( &self, user_id: &str, ) -> Result<(), Error> { use crate::constants::token_table::*; let primary_keys = self .client .query() .table_name(NAME) .projection_expression("#pk, #sk") .key_condition_expression("#pk = :pk") .expression_attribute_names("#pk", PARTITION_KEY) .expression_attribute_names("#sk", SORT_KEY) .expression_attribute_values( ":pk", AttributeValue::S(user_id.to_string()), ) .send() .await .map_err(|e| { error!( errorType = error_types::TOKEN_DB_LOG, "Failed to list user's items in tokens table: {:?}", e ); Error::AwsSdk(e.into()) })? .items .unwrap_or_default(); let delete_requests = primary_keys .into_iter() .map(|item| { let request = DeleteRequest::builder().set_key(Some(item)).build(); WriteRequest::builder().delete_request(request).build() }) .collect::>(); batch_write( &self.client, NAME, delete_requests, ExponentialBackoffConfig::default(), ) .await .map_err(Error::from)?; Ok(()) } } fn parse_auth_type_attribute( attribute: Option, ) -> Result { use crate::constants::token_table::ATTR_AUTH_TYPE; if let Some(AttributeValue::S(auth_type)) = &attribute { match auth_type.as_str() { "password" => Ok(AuthType::Password), "wallet" => Ok(AuthType::Wallet), _ => Err(DBItemError::new( ATTR_AUTH_TYPE.to_string(), attribute.into(), DBItemAttributeError::IncorrectType, )), } } else { Err(DBItemError::new( ATTR_AUTH_TYPE.to_string(), attribute.into(), DBItemAttributeError::Missing, )) } } fn parse_valid_attribute( attribute: Option, ) -> Result { use crate::constants::token_table::ATTR_VALID; match attribute { Some(AttributeValue::Bool(valid)) => Ok(valid), Some(_) => Err(DBItemError::new( ATTR_VALID.to_string(), attribute.into(), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( ATTR_VALID.to_string(), attribute.into(), DBItemAttributeError::Missing, )), } } fn parse_token_attribute( attribute: Option, ) -> Result { use crate::constants::token_table::ATTR_TOKEN; match attribute { Some(AttributeValue::S(token)) => Ok(token), Some(_) => Err(DBItemError::new( ATTR_TOKEN.to_string(), attribute.into(), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( ATTR_TOKEN.to_string(), attribute.into(), DBItemAttributeError::Missing, )), } }