use std::time::{Duration, Instant, SystemTime};
use faucet_core::FaucetError;
use faucet_core::io::CallContext;
use tokio::sync::OnceCell;
use crate::config::{RedshiftAwsCredentials, RedshiftConnection, RedshiftCredentials};
pub const DURATION_RANGE: std::ops::RangeInclusive<u32> = 900..=3600;
pub const REFRESH_MARGIN: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct TempCredentials {
pub user: String,
pub password: String,
pub expires_at: SystemTime,
}
impl std::fmt::Debug for TempCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TempCredentials")
.field("user", &self.user)
.field("password", &"***")
.field("expires_at", &self.expires_at)
.finish()
}
}
pub const RETRY_RANGE: std::ops::RangeInclusive<Duration> =
Duration::from_secs(1)..=Duration::from_secs(60);
pub fn refresh_at(fetched: Instant, now: SystemTime, expires_at: SystemTime) -> Instant {
let lifetime = expires_at.duration_since(now).unwrap_or_default();
(fetched + lifetime - REFRESH_MARGIN.min(lifetime / 2)).max(fetched + *RETRY_RANGE.start())
}
pub fn retry_at(failed: Instant, now: SystemTime, expires_at: SystemTime) -> Instant {
let remaining = expires_at.duration_since(now).unwrap_or_default();
let wait = (remaining / 4)
.clamp(*RETRY_RANGE.start(), *RETRY_RANGE.end())
.min(remaining);
failed + wait
}
#[derive(Debug, Clone)]
enum Target {
Cluster {
id: String,
db_user: String,
auto_create: bool,
db_groups: Vec<String>,
},
Workgroup {
name: String,
},
}
enum Client {
Cluster(aws_sdk_redshift::Client),
Workgroup(aws_sdk_redshiftserverless::Client),
}
pub struct IamCredentials {
target: Target,
database: String,
region: Option<String>,
duration_secs: Option<u32>,
aws_credentials: RedshiftAwsCredentials,
endpoint_url: Option<String>,
client: OnceCell<Client>,
}
impl std::fmt::Debug for IamCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IamCredentials")
.field("target", &self.target)
.field("database", &self.database)
.finish_non_exhaustive()
}
}
impl IamCredentials {
pub fn from_connection(conn: &RedshiftConnection) -> Result<Option<Self>, FaucetError> {
let RedshiftCredentials::Iam {
region,
cluster_identifier,
db_user,
workgroup_name,
auto_create,
db_groups,
duration_secs,
aws_credentials,
endpoint_url,
} = &conn.credentials
else {
return Ok(None);
};
fn present(v: &Option<String>) -> Option<&str> {
v.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
let target = match (present(cluster_identifier), present(workgroup_name)) {
(Some(id), None) => {
let user = present(db_user).unwrap_or(conn.user.trim());
if user.is_empty() {
return Err(FaucetError::Config(
"redshift iam: a cluster needs `db_user` (or the connection's `user`)"
.into(),
));
}
Target::Cluster {
id: id.to_string(),
db_user: user.to_string(),
auto_create: *auto_create,
db_groups: db_groups.clone(),
}
}
(None, Some(name)) => {
if present(db_user).is_some() || *auto_create || !db_groups.is_empty() {
return Err(FaucetError::Config(
"redshift iam: `db_user`, `auto_create` and `db_groups` apply to a \
provisioned `cluster_identifier`; a serverless workgroup derives the \
user from the IAM identity"
.into(),
));
}
Target::Workgroup {
name: name.to_string(),
}
}
_ => {
return Err(FaucetError::Config(
"redshift iam: set exactly one of `cluster_identifier` (provisioned) or \
`workgroup_name` (serverless)"
.into(),
));
}
};
if let Some(d) = duration_secs
&& !DURATION_RANGE.contains(d)
{
return Err(FaucetError::Config(format!(
"redshift iam: `duration_secs` must be between {} and {} (got {d})",
DURATION_RANGE.start(),
DURATION_RANGE.end()
)));
}
Ok(Some(Self {
target,
database: conn.database.clone(),
region: region.clone(),
duration_secs: *duration_secs,
aws_credentials: aws_credentials.clone(),
endpoint_url: endpoint_url.clone(),
client: OnceCell::new(),
}))
}
async fn client(&self) -> &Client {
self.client
.get_or_init(|| async {
let sdk =
crate::aws::sdk_config(self.region.as_deref(), &self.aws_credentials).await;
match &self.target {
Target::Cluster { .. } => {
let mut b = aws_sdk_redshift::config::Builder::from(&sdk);
if let Some(e) = &self.endpoint_url {
b = b.endpoint_url(e);
}
Client::Cluster(aws_sdk_redshift::Client::from_conf(b.build()))
}
Target::Workgroup { .. } => {
let mut b = aws_sdk_redshiftserverless::config::Builder::from(&sdk);
if let Some(e) = &self.endpoint_url {
b = b.endpoint_url(e);
}
Client::Workgroup(aws_sdk_redshiftserverless::Client::from_conf(b.build()))
}
}
})
.await
}
pub async fn fetch(&self, calls: &CallContext) -> Result<TempCredentials, FaucetError> {
let client = self.client().await;
calls
.call("credentials", || async {
let (user, password, expires) = match (client, &self.target) {
(
Client::Cluster(c),
Target::Cluster {
id,
db_user,
auto_create,
db_groups,
},
) => {
let out = c
.get_cluster_credentials()
.cluster_identifier(id)
.db_user(db_user)
.db_name(&self.database)
.auto_create(*auto_create)
.set_db_groups((!db_groups.is_empty()).then(|| db_groups.clone()))
.set_duration_seconds(self.duration_secs.map(|d| d as i32))
.send()
.await
.map_err(|e| {
FaucetError::Auth(format!(
"redshift iam: GetClusterCredentials failed: {}",
crate::aws::describe(&e)
))
})?;
(
out.db_user().map(str::to_string),
out.db_password().map(str::to_string),
out.expiration().copied(),
)
}
(Client::Workgroup(c), Target::Workgroup { name }) => {
let out = c
.get_credentials()
.workgroup_name(name)
.db_name(&self.database)
.set_duration_seconds(self.duration_secs.map(|d| d as i32))
.send()
.await
.map_err(|e| {
FaucetError::Auth(format!(
"redshift iam: GetCredentials failed: {}",
crate::aws::describe(&e)
))
})?;
(
out.db_user().map(str::to_string),
out.db_password().map(str::to_string),
out.expiration().copied(),
)
}
_ => unreachable!("the client is built for its target"),
};
credentials_from(user, password, expires)
})
.await
}
}
fn credentials_from(
user: Option<String>,
password: Option<String>,
expires: Option<aws_sdk_redshift::primitives::DateTime>,
) -> Result<TempCredentials, FaucetError> {
let missing = |what: &str| {
FaucetError::Auth(format!(
"redshift iam: the credentials response has no {what}"
))
};
let user = user
.filter(|u| !u.is_empty())
.ok_or_else(|| missing("user"))?;
let password = password
.filter(|p| !p.is_empty())
.ok_or_else(|| missing("password"))?;
let expires = expires.ok_or_else(|| missing("expiration"))?;
let expires_at = SystemTime::try_from(expires)
.map_err(|_| FaucetError::Auth("redshift iam: the expiration is out of range".into()))?;
Ok(TempCredentials {
user,
password,
expires_at,
})
}
#[cfg(test)]
mod tests;