mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-30 01:27:31 +08:00
feat(decision): add support to register api keys to proxy (#5168)
This commit is contained in:
@ -394,5 +394,6 @@ pub(crate) async fn fetch_raw_secrets(
|
||||
saved_payment_methods: conf.saved_payment_methods,
|
||||
multitenancy: conf.multitenancy,
|
||||
user_auth_methods,
|
||||
decision: conf.decision,
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,6 +125,7 @@ pub struct Settings<S: SecretState> {
|
||||
pub multitenancy: Multitenancy,
|
||||
pub saved_payment_methods: EligiblePaymentMethods,
|
||||
pub user_auth_methods: SecretStateContainer<UserAuthMethodSettings, S>,
|
||||
pub decision: Option<DecisionConfig>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
#[serde(transparent)]
|
||||
pub struct TenantConfig(pub HashMap<String, Tenant>);
|
||||
|
||||
@ -28,7 +28,7 @@ use crate::{
|
||||
},
|
||||
db::StorageInterface,
|
||||
routes::{metrics, SessionState},
|
||||
services::{self, api as service_api},
|
||||
services::{self, api as service_api, authentication},
|
||||
types::{
|
||||
self, api,
|
||||
domain::{
|
||||
@ -281,6 +281,25 @@ pub async fn create_merchant_account(
|
||||
.await
|
||||
.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 {
|
||||
key: format!("{}_requires_cvv", merchant_account.merchant_id),
|
||||
config: "true".to_string(),
|
||||
@ -650,6 +669,20 @@ pub async fn merchant_account_delete(
|
||||
) -> RouterResponse<api::MerchantAccountDeleteResponse> {
|
||||
let mut is_deleted = false;
|
||||
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
|
||||
.delete_merchant_account_by_merchant_id(&merchant_id)
|
||||
.await
|
||||
@ -662,6 +695,14 @@ pub async fn merchant_account_delete(
|
||||
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
|
||||
.delete_config_by_key(format!("{}_requires_cvv", merchant_id).as_str())
|
||||
.await
|
||||
|
||||
@ -10,7 +10,7 @@ use crate::{
|
||||
consts,
|
||||
core::errors::{self, RouterResponse, StorageErrorExt},
|
||||
routes::{metrics, SessionState},
|
||||
services::ApplicationResponse,
|
||||
services::{authentication, ApplicationResponse},
|
||||
types::{api, storage, transformers::ForeignInto},
|
||||
utils,
|
||||
};
|
||||
@ -148,6 +148,26 @@ pub async fn create_api_key(
|
||||
.change_context(errors::ApiErrorResponse::InternalServerError)
|
||||
.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::CONTEXT,
|
||||
1,
|
||||
@ -277,6 +297,25 @@ pub async fn update_api_key(
|
||||
.await
|
||||
.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")]
|
||||
{
|
||||
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,
|
||||
) -> RouterResponse<api::RevokeApiKeyResponse> {
|
||||
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
|
||||
.revoke_api_key(merchant_id, key_id)
|
||||
.await
|
||||
.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, &[]);
|
||||
|
||||
#[cfg(feature = "email")]
|
||||
|
||||
@ -83,3 +83,6 @@ counter_metric!(
|
||||
ROUTING_RETRIEVE_CONFIG_FOR_PROFILE_SUCCESS_RESPONSE,
|
||||
GLOBAL_METER
|
||||
);
|
||||
|
||||
counter_metric!(API_KEY_REQUEST_INITIATED, GLOBAL_METER);
|
||||
counter_metric!(API_KEY_REQUEST_COMPLETED, GLOBAL_METER);
|
||||
|
||||
@ -40,6 +40,7 @@ use crate::{
|
||||
};
|
||||
pub mod blacklist;
|
||||
pub mod cookies;
|
||||
pub mod decision;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticationData {
|
||||
|
||||
217
crates/router/src/services/authentication/decision.rs
Normal file
217
crates/router/src/services/authentication/decision.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user