feat(decision): add support to register api keys to proxy (#5168)

This commit is contained in:
Nishant Joshi
2024-07-09 13:05:20 +05:30
committed by GitHub
parent ffc79674e4
commit 071d5345b5
7 changed files with 329 additions and 2 deletions

View File

@ -394,5 +394,6 @@ pub(crate) async fn fetch_raw_secrets(
saved_payment_methods: conf.saved_payment_methods, saved_payment_methods: conf.saved_payment_methods,
multitenancy: conf.multitenancy, multitenancy: conf.multitenancy,
user_auth_methods, user_auth_methods,
decision: conf.decision,
} }
} }

View File

@ -125,6 +125,7 @@ pub struct Settings<S: SecretState> {
pub multitenancy: Multitenancy, pub multitenancy: Multitenancy,
pub saved_payment_methods: EligiblePaymentMethods, pub saved_payment_methods: EligiblePaymentMethods,
pub user_auth_methods: SecretStateContainer<UserAuthMethodSettings, S>, pub user_auth_methods: SecretStateContainer<UserAuthMethodSettings, S>,
pub decision: Option<DecisionConfig>,
} }
#[derive(Debug, Deserialize, Clone, Default)] #[derive(Debug, Deserialize, Clone, Default)]
@ -146,6 +147,11 @@ impl Multitenancy {
} }
} }
#[derive(Debug, Deserialize, Clone, Default)]
pub struct DecisionConfig {
pub base_url: String,
}
#[derive(Debug, Deserialize, Clone, Default)] #[derive(Debug, Deserialize, Clone, Default)]
#[serde(transparent)] #[serde(transparent)]
pub struct TenantConfig(pub HashMap<String, Tenant>); pub struct TenantConfig(pub HashMap<String, Tenant>);

View File

@ -28,7 +28,7 @@ use crate::{
}, },
db::StorageInterface, db::StorageInterface,
routes::{metrics, SessionState}, routes::{metrics, SessionState},
services::{self, api as service_api}, services::{self, api as service_api, authentication},
types::{ types::{
self, api, self, api,
domain::{ domain::{
@ -281,6 +281,25 @@ pub async fn create_merchant_account(
.await .await
.to_duplicate_response(errors::ApiErrorResponse::DuplicateMerchantAccount)?; .to_duplicate_response(errors::ApiErrorResponse::DuplicateMerchantAccount)?;
if let Some(api_key) = merchant_account.publishable_key.as_ref() {
let state = state.clone();
let api_key = api_key.clone();
let merchant_id = merchant_account.merchant_id.clone();
authentication::decision::spawn_tracked_job(
async move {
authentication::decision::add_publishable_key(
&state,
api_key.into(),
merchant_id,
None,
)
.await
},
authentication::decision::ADD,
);
}
db.insert_config(configs::ConfigNew { db.insert_config(configs::ConfigNew {
key: format!("{}_requires_cvv", merchant_account.merchant_id), key: format!("{}_requires_cvv", merchant_account.merchant_id),
config: "true".to_string(), config: "true".to_string(),
@ -650,6 +669,20 @@ pub async fn merchant_account_delete(
) -> RouterResponse<api::MerchantAccountDeleteResponse> { ) -> RouterResponse<api::MerchantAccountDeleteResponse> {
let mut is_deleted = false; let mut is_deleted = false;
let db = state.store.as_ref(); let db = state.store.as_ref();
let merchant_key_store = db
.get_merchant_key_store_by_merchant_id(
&merchant_id,
&state.store.get_master_key().to_vec().into(),
)
.await
.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?;
let merchant_account = db
.find_merchant_account_by_merchant_id(&merchant_id, &merchant_key_store)
.await
.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?;
let is_merchant_account_deleted = db let is_merchant_account_deleted = db
.delete_merchant_account_by_merchant_id(&merchant_id) .delete_merchant_account_by_merchant_id(&merchant_id)
.await .await
@ -662,6 +695,14 @@ pub async fn merchant_account_delete(
is_deleted = is_merchant_account_deleted && is_merchant_key_store_deleted; is_deleted = is_merchant_account_deleted && is_merchant_key_store_deleted;
} }
if let Some(api_key) = merchant_account.publishable_key {
let state = state.clone();
authentication::decision::spawn_tracked_job(
async move { authentication::decision::revoke_api_key(&state, api_key.into()).await },
authentication::decision::REVOKE,
)
}
match db match db
.delete_config_by_key(format!("{}_requires_cvv", merchant_id).as_str()) .delete_config_by_key(format!("{}_requires_cvv", merchant_id).as_str())
.await .await

View File

@ -10,7 +10,7 @@ use crate::{
consts, consts,
core::errors::{self, RouterResponse, StorageErrorExt}, core::errors::{self, RouterResponse, StorageErrorExt},
routes::{metrics, SessionState}, routes::{metrics, SessionState},
services::ApplicationResponse, services::{authentication, ApplicationResponse},
types::{api, storage, transformers::ForeignInto}, types::{api, storage, transformers::ForeignInto},
utils, utils,
}; };
@ -148,6 +148,26 @@ pub async fn create_api_key(
.change_context(errors::ApiErrorResponse::InternalServerError) .change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to insert new API key")?; .attach_printable("Failed to insert new API key")?;
let state_inner = state.clone();
let hashed_api_key = api_key.hashed_api_key.clone();
let merchant_id_inner = merchant_id.clone();
let key_id = api_key.key_id.clone();
let expires_at = api_key.expires_at;
authentication::decision::spawn_tracked_job(
async move {
authentication::decision::add_api_key(
&state_inner,
hashed_api_key.into_inner().into(),
merchant_id_inner,
key_id,
expires_at.map(authentication::decision::convert_expiry),
)
.await
},
authentication::decision::ADD,
);
metrics::API_KEY_CREATED.add( metrics::API_KEY_CREATED.add(
&metrics::CONTEXT, &metrics::CONTEXT,
1, 1,
@ -277,6 +297,25 @@ pub async fn update_api_key(
.await .await
.to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?; .to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?;
let state_inner = state.clone();
let hashed_api_key = api_key.hashed_api_key.clone();
let key_id_inner = api_key.key_id.clone();
let expires_at = api_key.expires_at;
authentication::decision::spawn_tracked_job(
async move {
authentication::decision::add_api_key(
&state_inner,
hashed_api_key.into_inner().into(),
merchant_id.clone(),
key_id_inner,
expires_at.map(authentication::decision::convert_expiry),
)
.await
},
authentication::decision::ADD,
);
#[cfg(feature = "email")] #[cfg(feature = "email")]
{ {
let expiry_reminder_days = state.conf.api_keys.get_inner().expiry_reminder_days.clone(); let expiry_reminder_days = state.conf.api_keys.get_inner().expiry_reminder_days.clone();
@ -402,11 +441,30 @@ pub async fn revoke_api_key(
key_id: &str, key_id: &str,
) -> RouterResponse<api::RevokeApiKeyResponse> { ) -> RouterResponse<api::RevokeApiKeyResponse> {
let store = state.store.as_ref(); let store = state.store.as_ref();
let api_key = store
.find_api_key_by_merchant_id_key_id_optional(merchant_id, key_id)
.await
.to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?;
let revoked = store let revoked = store
.revoke_api_key(merchant_id, key_id) .revoke_api_key(merchant_id, key_id)
.await .await
.to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?; .to_not_found_response(errors::ApiErrorResponse::ApiKeyNotFound)?;
if let Some(api_key) = api_key {
let hashed_api_key = api_key.hashed_api_key;
let state = state.clone();
authentication::decision::spawn_tracked_job(
async move {
authentication::decision::revoke_api_key(&state, hashed_api_key.into_inner().into())
.await
},
authentication::decision::REVOKE,
);
}
metrics::API_KEY_REVOKED.add(&metrics::CONTEXT, 1, &[]); metrics::API_KEY_REVOKED.add(&metrics::CONTEXT, 1, &[]);
#[cfg(feature = "email")] #[cfg(feature = "email")]

View File

@ -83,3 +83,6 @@ counter_metric!(
ROUTING_RETRIEVE_CONFIG_FOR_PROFILE_SUCCESS_RESPONSE, ROUTING_RETRIEVE_CONFIG_FOR_PROFILE_SUCCESS_RESPONSE,
GLOBAL_METER GLOBAL_METER
); );
counter_metric!(API_KEY_REQUEST_INITIATED, GLOBAL_METER);
counter_metric!(API_KEY_REQUEST_COMPLETED, GLOBAL_METER);

View File

@ -40,6 +40,7 @@ use crate::{
}; };
pub mod blacklist; pub mod blacklist;
pub mod cookies; pub mod cookies;
pub mod decision;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AuthenticationData { pub struct AuthenticationData {

View File

@ -0,0 +1,217 @@
use common_utils::{errors::CustomResult, request::RequestContent};
use masking::{ErasedMaskSerialize, Secret};
use router_env::opentelemetry::KeyValue;
use serde::Serialize;
use storage_impl::errors::ApiClientError;
use crate::{
core::metrics,
routes::{app::settings::DecisionConfig, SessionState},
};
// # Consts
//
const DECISION_ENDPOINT: &str = "/rule";
const RULE_ADD_METHOD: common_utils::request::Method = common_utils::request::Method::Post;
const RULE_DELETE_METHOD: common_utils::request::Method = common_utils::request::Method::Delete;
pub const REVOKE: &str = "REVOKE";
pub const ADD: &str = "ADD";
// # Types
//
/// [`RuleRequest`] is a request body used to register a new authentication method in the proxy.
#[derive(Debug, Serialize)]
pub struct RuleRequest {
/// [`tag`] similar to a partition key, which can be used by the decision service to tag rules
/// by partitioning identifiers. (e.g. `tenant_id`)
pub tag: String,
/// [`variant`] is the type of authentication method to be registered.
#[serde(flatten)]
pub variant: AuthRuleType,
/// [`expiry`] is the time **in seconds** after which the rule should be removed
pub expiry: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct RuleDeleteRequest {
pub tag: String,
#[serde(flatten)]
pub variant: AuthType,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthType {
/// [`ApiKey`] is an authentication method that uses an API key. This is used with [`ApiKey`]
ApiKey { api_key: Secret<String> },
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthRuleType {
/// [`ApiKey`] is an authentication method that uses an API key. This is used with [`ApiKey`]
/// and [`PublishableKey`] authentication methods.
ApiKey {
api_key: Secret<String>,
identifiers: Identifiers,
},
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Identifiers {
/// [`ApiKey`] is an authentication method that uses an API key. This is used with [`ApiKey`]
ApiKey { merchant_id: String, key_id: String },
/// [`PublishableKey`] is an authentication method that uses a publishable key. This is used with [`PublishableKey`]
PublishableKey { merchant_id: String },
}
// # Decision Service
//
pub async fn add_api_key(
state: &SessionState,
api_key: Secret<String>,
merchant_id: String,
key_id: String,
expiry: Option<u64>,
) -> CustomResult<(), ApiClientError> {
let decision_config = if let Some(config) = &state.conf.decision {
config
} else {
return Ok(());
};
let rule = RuleRequest {
tag: state.tenant.schema.clone(),
expiry,
variant: AuthRuleType::ApiKey {
api_key,
identifiers: Identifiers::ApiKey {
merchant_id,
key_id,
},
},
};
call_decision_service(state, decision_config, rule, RULE_ADD_METHOD).await
}
pub async fn add_publishable_key(
state: &SessionState,
api_key: Secret<String>,
merchant_id: String,
expiry: Option<u64>,
) -> CustomResult<(), ApiClientError> {
let decision_config = if let Some(config) = &state.conf.decision {
config
} else {
return Ok(());
};
let rule = RuleRequest {
tag: state.tenant.schema.clone(),
expiry,
variant: AuthRuleType::ApiKey {
api_key,
identifiers: Identifiers::PublishableKey { merchant_id },
},
};
call_decision_service(state, decision_config, rule, RULE_ADD_METHOD).await
}
async fn call_decision_service<T: ErasedMaskSerialize + Send + 'static>(
state: &SessionState,
decision_config: &DecisionConfig,
rule: T,
method: common_utils::request::Method,
) -> CustomResult<(), ApiClientError> {
let mut request = common_utils::request::Request::new(
method,
&(decision_config.base_url.clone() + DECISION_ENDPOINT),
);
request.set_body(RequestContent::Json(Box::new(rule)));
request.add_default_headers();
let response = state
.api_client
.send_request(state, request, None, false)
.await;
match response {
Err(error) => {
router_env::error!("Failed while calling the decision service: {:?}", error);
Err(error)
}
Ok(response) => {
router_env::info!("Decision service response: {:?}", response);
Ok(())
}
}
}
pub async fn revoke_api_key(
state: &SessionState,
api_key: Secret<String>,
) -> CustomResult<(), ApiClientError> {
let decision_config = if let Some(config) = &state.conf.decision {
config
} else {
return Ok(());
};
let rule = RuleDeleteRequest {
tag: state.tenant.schema.clone(),
variant: AuthType::ApiKey { api_key },
};
call_decision_service(state, decision_config, rule, RULE_DELETE_METHOD).await
}
///
///
/// Safety: i64::MAX < u64::MAX
///
#[allow(clippy::as_conversions)]
pub fn convert_expiry(expiry: time::PrimitiveDateTime) -> u64 {
let now = common_utils::date_time::now();
let duration = expiry - now;
let output = duration.whole_seconds();
match output {
i64::MIN..=0 => 0,
_ => output as u64,
}
}
pub fn spawn_tracked_job<E, F>(future: F, request_type: &'static str)
where
E: std::fmt::Debug,
F: futures::Future<Output = Result<(), E>> + Send + 'static,
{
metrics::API_KEY_REQUEST_INITIATED.add(
&metrics::CONTEXT,
1,
&[KeyValue::new("type", request_type)],
);
tokio::spawn(async move {
match future.await {
Ok(_) => {
metrics::API_KEY_REQUEST_COMPLETED.add(
&metrics::CONTEXT,
1,
&[KeyValue::new("type", request_type)],
);
}
Err(e) => {
router_env::error!("Error in tracked job: {:?}", e);
}
}
});
}