use super::TableName;
use crate::accounts::Account;
use crate::accounts::AccountRepository;
use crate::authz::AccessHierarchy;
use crate::credentials::Credentials;
use crate::credentials::CredentialsVerifier;
use crate::errors::{Error, Result};
use crate::hashing::HashingService;
use crate::hashing::argon2::Argon2Hasher;
use crate::permissions::PermissionId;
use crate::permissions::mapping::PermissionMapping;
use crate::permissions::mapping::PermissionMappingRepository;
use crate::repositories::{DatabaseError, DatabaseOperation};
use crate::secrets::{Secret, SecretRepository};
use crate::verification_result::VerificationResult;
use std::default::Default;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use surrealdb::{Connection, RecordId, RecordIdKey, Surreal};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct DatabaseScope {
pub accounts: String,
pub credentials: String,
pub permission_mappings: String,
pub namespace: String,
pub database: String,
}
impl Default for DatabaseScope {
fn default() -> Self {
Self {
accounts: TableName::AxumGateAccounts.to_string(),
credentials: TableName::AxumGateCredentials.to_string(),
permission_mappings: TableName::AxumGatePermissionMappings.to_string(),
namespace: "axumGate".to_string(),
database: "axumGate".to_string(),
}
}
}
#[derive(Clone)]
pub struct SurrealDbRepository<S>
where
S: Connection,
{
db: Surreal<S>,
scope_settings: DatabaseScope,
dummy_hash: String,
}
impl<S> SurrealDbRepository<S>
where
S: Connection,
{
pub fn new(db: Surreal<S>, scope_settings: DatabaseScope) -> Result<Self> {
let hasher = Argon2Hasher::new_recommended()?;
let dummy_hash = hasher.hash_value("dummy_password")?;
Ok(Self {
db,
scope_settings,
dummy_hash,
})
}
async fn use_ns_db(&self) -> Result<()> {
self.db
.use_ns(&self.scope_settings.namespace)
.use_db(&self.scope_settings.database)
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Connect,
format!("Failed to set namespace/database: {}", e),
None,
None,
))
})
}
}
impl<R, G, S> AccountRepository<R, G> for SurrealDbRepository<S>
where
R: AccessHierarchy + Eq + DeserializeOwned + Serialize + Send + Sync + 'static,
G: Serialize + DeserializeOwned + Eq + Clone + Send + Sync + 'static,
S: Connection,
{
async fn query_account_by_user_id(&self, user_id: &str) -> Result<Option<Account<R, G>>> {
self.use_ns_db().await?;
let db_account: Option<Account<R, G>> = self
.db
.select(RecordId::from_table_key(
&self.scope_settings.accounts,
user_id,
))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to query account by user_id: {}", e),
Some(self.scope_settings.accounts.clone()),
Some(user_id.to_string()),
))
})?;
Ok(db_account)
}
async fn store_account(&self, account: Account<R, G>) -> Result<Option<Account<R, G>>> {
self.use_ns_db().await?;
let record_id =
RecordId::from_table_key(self.scope_settings.accounts.clone(), &account.user_id);
let user_id = account.user_id.clone();
let db_account: Option<Account<R, G>> = self
.db
.insert(record_id)
.content(account)
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Insert,
format!("Could not insert account: {}", e),
Some(self.scope_settings.accounts.clone()),
Some(user_id),
))
})?;
Ok(db_account)
}
async fn delete_account(&self, user_id: &str) -> Result<Option<Account<R, G>>> {
self.use_ns_db().await?;
let db_account: Option<Account<R, G>> = self
.db
.delete(RecordId::from_table_key(
self.scope_settings.accounts.clone(),
user_id,
))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to delete account: {}", e),
Some(self.scope_settings.accounts.clone()),
Some(user_id.to_string()),
))
})?;
Ok(db_account)
}
async fn update_account(&self, account: Account<R, G>) -> Result<Option<Account<R, G>>> {
self.use_ns_db().await?;
let record_id =
RecordId::from_table_key(self.scope_settings.accounts.clone(), &account.user_id);
let db_account: Option<Account<R, G>> = self.db.update(&record_id).content(account).await?;
Ok(db_account)
}
}
impl<S> SecretRepository for SurrealDbRepository<S>
where
S: Connection,
{
async fn store_secret(&self, secret: Secret) -> Result<bool> {
self.use_ns_db().await?;
let record_id =
RecordId::from_table_key(self.scope_settings.credentials.clone(), secret.account_id);
let account_id = secret.account_id;
let db_credentials: Option<Secret> = self
.db
.insert(&record_id)
.content(secret)
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Insert,
format!("Failed to store secret: {}", e),
Some(self.scope_settings.credentials.clone()),
Some(account_id.to_string()),
))
})?;
Ok(db_credentials.is_some())
}
async fn delete_secret(&self, id: &Uuid) -> Result<Option<Secret>> {
self.use_ns_db().await?;
let record_id = RecordId::from_table_key(self.scope_settings.credentials.clone(), *id);
let result: Option<Secret> = self.db.delete(record_id).await.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to delete and return secret: {}", e),
Some(self.scope_settings.credentials.clone()),
Some(id.to_string()),
))
})?;
Ok(result)
}
async fn update_secret(&self, secret: Secret) -> Result<()> {
self.use_ns_db().await?;
let record_id =
RecordId::from_table_key(self.scope_settings.credentials.clone(), secret.account_id);
let account_id = secret.account_id;
let _: Option<Secret> = self
.db
.update(record_id)
.content(secret)
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Update,
format!("Failed to update secret: {}", e),
Some(self.scope_settings.credentials.clone()),
Some(account_id.to_string()),
))
})?;
Ok(())
}
}
impl<S, Id> CredentialsVerifier<Id> for SurrealDbRepository<S>
where
S: Connection,
Id: Into<RecordIdKey>,
{
async fn verify_credentials(&self, credentials: Credentials<Id>) -> Result<VerificationResult> {
use subtle::Choice;
self.use_ns_db().await?;
let record_id =
RecordId::from_table_key(self.scope_settings.credentials.clone(), credentials.id);
let exists_query = "SELECT VALUE secret FROM only $record_id".to_string();
let mut exists_response = self
.db
.query(exists_query)
.bind(("record_id", record_id))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to check user existence: {}", e),
Some(self.scope_settings.credentials.clone()),
None,
))
})?;
let stored_secret: Option<String> = exists_response.take(0).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to extract secret: {}", e),
Some(self.scope_settings.credentials.clone()),
None,
))
})?;
let (hash_for_verification, user_exists_choice) = match stored_secret {
Some(secret) => (secret, Choice::from(1u8)),
None => (self.dummy_hash.clone(), Choice::from(0u8)),
};
let verify_query =
"crypto::argon2::compare(type::string($stored_hash), type::string($request_secret))"
.to_string();
let mut verify_response = self
.db
.query(verify_query)
.bind(("stored_hash", hash_for_verification))
.bind(("request_secret", credentials.secret))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to verify credentials: {}", e),
Some(self.scope_settings.credentials.clone()),
None,
))
})?;
let hash_matches: Option<bool> = verify_response.take(0).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to extract verification result: {}", e),
Some(self.scope_settings.credentials.clone()),
None,
))
})?;
let hash_matches_choice = Choice::from(if hash_matches.unwrap_or(false) {
1u8
} else {
0u8
});
let final_success_choice = user_exists_choice & hash_matches_choice;
let final_result = if bool::from(final_success_choice) {
VerificationResult::Ok
} else {
VerificationResult::Unauthorized
};
Ok(final_result)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct SurrealPermissionMapping {
normalized_string: String,
permission_id: String,
}
impl From<PermissionMapping> for SurrealPermissionMapping {
fn from(m: PermissionMapping) -> Self {
Self {
normalized_string: m.normalized_string().to_string(),
permission_id: m.permission_id().as_u64().to_string(),
}
}
}
impl std::convert::TryFrom<SurrealPermissionMapping> for PermissionMapping {
type Error = String;
fn try_from(value: SurrealPermissionMapping) -> std::result::Result<Self, Self::Error> {
let id_u64 = value.permission_id.parse::<u64>().map_err(|e| {
format!(
"invalid permission_id string '{}': {}",
value.permission_id, e
)
})?;
let id = PermissionId::from_u64(id_u64);
PermissionMapping::new(value.normalized_string.clone(), id)
.map_err(|e| format!("failed to construct PermissionMapping: {}", e))
}
}
impl<S> PermissionMappingRepository for SurrealDbRepository<S>
where
S: Connection,
{
async fn store_mapping(&self, mapping: PermissionMapping) -> Result<Option<PermissionMapping>> {
if let Err(e) = mapping.validate() {
return Err(Error::Database(DatabaseError::with_context(
DatabaseOperation::Insert,
format!("Invalid permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
)));
}
self.use_ns_db().await?;
let query_id = "SELECT * FROM type::table($table) WHERE permission_id = $pid LIMIT 1";
let mut res_id = self
.db
.query(query_id)
.bind(("table", self.scope_settings.permission_mappings.clone()))
.bind(("pid", mapping.permission_id().as_u64().to_string()))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to check existing mapping by id: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
Some(mapping.permission_id().as_u64().to_string()),
))
})?;
let exists_by_id: Vec<SurrealPermissionMapping> = res_id.take(0).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to extract existing mapping by id: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
Some(mapping.permission_id().as_u64().to_string()),
))
})?;
if !exists_by_id.is_empty() {
return Ok(None);
}
let record_id = RecordId::from_table_key(
self.scope_settings.permission_mappings.clone(),
mapping.normalized_string(),
);
let exists_by_string: Option<SurrealPermissionMapping> =
self.db.select(&record_id).await.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to check existing mapping by string: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
if exists_by_string.is_some() {
return Ok(None);
}
let stored_spm: Option<SurrealPermissionMapping> = self
.db
.insert(&record_id)
.content(SurrealPermissionMapping::from(mapping))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Insert,
format!("Failed to store permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
let stored = match stored_spm {
Some(spm) => {
let dom = PermissionMapping::try_from(spm).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Insert,
format!("Failed to convert stored permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
Some(dom)
}
None => None,
};
Ok(stored)
}
async fn remove_mapping_by_id(&self, id: PermissionId) -> Result<Option<PermissionMapping>> {
self.use_ns_db().await?;
let query = "DELETE type::table($table) WHERE permission_id = $pid RETURN BEFORE";
let mut res = self
.db
.query(query)
.bind(("table", self.scope_settings.permission_mappings.clone()))
.bind(("pid", id.as_u64().to_string()))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to delete permission mapping by id: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
Some(id.as_u64().to_string()),
))
})?;
let removed: Vec<SurrealPermissionMapping> = res.take(0).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to extract deleted permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
Some(id.as_u64().to_string()),
))
})?;
removed
.into_iter()
.next()
.map(|spm| {
PermissionMapping::try_from(spm).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to convert deleted permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
Some(id.as_u64().to_string()),
))
})
})
.transpose()
}
async fn remove_mapping_by_string(
&self,
permission: &str,
) -> Result<Option<PermissionMapping>> {
self.use_ns_db().await?;
let normalized = PermissionMapping::from(permission)
.normalized_string()
.to_string();
let query = "DELETE type::table($table) WHERE normalized_string = $ns RETURN BEFORE";
let mut res = self
.db
.query(query)
.bind(("table", self.scope_settings.permission_mappings.clone()))
.bind(("ns", normalized))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to delete permission mapping by string: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
let removed: Vec<SurrealPermissionMapping> = res.take(0).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to extract deleted permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
removed
.into_iter()
.next()
.map(|spm| {
PermissionMapping::try_from(spm).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Delete,
format!("Failed to convert deleted permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})
})
.transpose()
}
async fn query_mapping_by_id(&self, id: PermissionId) -> Result<Option<PermissionMapping>> {
self.use_ns_db().await?;
let query = "SELECT * FROM type::table($table) WHERE permission_id = $pid LIMIT 1";
let mut res = self
.db
.query(query)
.bind(("table", self.scope_settings.permission_mappings.clone()))
.bind(("pid", id.as_u64().to_string()))
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to query permission mapping by id: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
let found: Vec<SurrealPermissionMapping> = res.take(0).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to extract permission mapping by id: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
Some(id.as_u64().to_string()),
))
})?;
found
.into_iter()
.next()
.map(|spm| {
PermissionMapping::try_from(spm).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to convert permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
Some(id.as_u64().to_string()),
))
})
})
.transpose()
}
async fn query_mapping_by_string(&self, permission: &str) -> Result<Option<PermissionMapping>> {
self.use_ns_db().await?;
let normalized = PermissionMapping::from(permission)
.normalized_string()
.to_string();
let record_id = RecordId::from_table_key(
self.scope_settings.permission_mappings.clone(),
normalized.clone(),
);
let mapping_spm: Option<SurrealPermissionMapping> =
self.db.select(record_id).await.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to query permission mapping by string: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
mapping_spm
.map(|spm| {
PermissionMapping::try_from(spm).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to convert permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})
})
.transpose()
}
async fn list_all_mappings(&self) -> Result<Vec<PermissionMapping>> {
self.use_ns_db().await?;
let all_spm: Vec<SurrealPermissionMapping> = self
.db
.select(self.scope_settings.permission_mappings.clone())
.await
.map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to list permission mappings: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
let mut out = Vec::with_capacity(all_spm.len());
for spm in all_spm {
let dom = PermissionMapping::try_from(spm).map_err(|e| {
Error::Database(DatabaseError::with_context(
DatabaseOperation::Query,
format!("Failed to convert permission mapping: {}", e),
Some(self.scope_settings.permission_mappings.clone()),
None,
))
})?;
out.push(dom);
}
Ok(out)
}
}
#[test]
#[allow(clippy::unwrap_used)]
fn secret_repository() {
tokio_test::block_on(async move {
use crate::hashing::argon2::Argon2Hasher;
use surrealdb::engine::local::Mem;
let db = Surreal::new::<Mem>(()).await.unwrap();
let scope = DatabaseScope::default();
let repo = SurrealDbRepository::new(db, scope).unwrap();
repo.use_ns_db().await.unwrap();
let hasher = Argon2Hasher::new_recommended().unwrap();
let secret = Secret::new(&Uuid::now_v7(), "my_secret", hasher).unwrap();
assert!(repo.store_secret(secret.clone()).await.unwrap());
let mut secret_new = secret.clone();
secret_new.secret = secret.secret.clone();
repo.update_secret(secret_new.clone()).await.unwrap();
let credentials = Credentials::new(&secret.account_id, "my_secret");
assert!(matches!(
repo.verify_credentials(credentials).await,
Ok(VerificationResult::Ok)
));
});
}