use anyhow::{Context, Result};
use fraiseql_core::schema::CompiledSchema;
use fraiseql_db::postgres::PostgresTlsConfig;
use tokio_postgres::config::Host;
pub const FLAG: &str = "--admin-database-url";
pub const ENV_VAR: &str = "FRAISEQL_ADMIN_DATABASE_URL";
pub fn admin_url(flag: Option<String>) -> Option<String> {
flag.or_else(|| std::env::var(ENV_VAR).ok()).filter(|url| !url.is_empty())
}
pub fn refuse_outside_development() -> Result<()> {
if fraiseql_server::ServerConfig::is_production_mode() {
anyhow::bail!(
"{FLAG} ({ENV_VAR}) creates database roles, and only in development: set \
FRAISEQL_ENV=development, or create the roles yourself (see docs/auth/database.md)"
);
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
struct Place {
hosts: Vec<String>,
ports: Vec<u16>,
database: String,
}
struct Login {
user: String,
password: Option<String>,
place: Place,
}
fn login(url: &str, source: &str) -> Result<Login> {
let config: tokio_postgres::Config = url
.parse()
.map_err(|_| anyhow::anyhow!("{source} is not a PostgreSQL connection string"))?;
let user = config
.get_user()
.filter(|user| !user.is_empty())
.ok_or_else(|| anyhow::anyhow!("{source} names no user, so {FLAG} has no role to create"))?
.to_string();
let password = config
.get_password()
.map(|bytes| {
String::from_utf8(bytes.to_vec())
.map_err(|_| anyhow::anyhow!("{source}'s password is not UTF-8"))
})
.transpose()?;
let hosts = config
.get_hosts()
.iter()
.map(|host| match host {
Host::Tcp(name) => name.clone(),
Host::Unix(path) => path.display().to_string(),
})
.collect();
let ports = match config.get_ports() {
[] => vec![5432],
ports => ports.to_vec(),
};
let database = config.get_dbname().unwrap_or(&user).to_string();
Ok(Login {
user,
password,
place: Place {
hosts,
ports,
database,
},
})
}
struct Target {
login: Login,
source: String,
engine: bool,
}
pub async fn provision(
admin_url: &str,
engine_url: &str,
engine_source: &str,
schema: &CompiledSchema,
) -> Result<()> {
let admin = login(admin_url, FLAG)?;
let mut targets = vec![Target {
login: login(engine_url, engine_source)?,
source: engine_source.to_string(),
engine: true,
}];
let auth_env = schema
.auth
.as_ref()
.and_then(|auth| auth.database.as_ref())
.map(|db| &db.url_env);
if let Some(name) = auth_env {
if let Some(url) = std::env::var(name).ok().filter(|url| !url.is_empty()) {
targets.push(Target {
login: login(&url, name)?,
source: name.clone(),
engine: false,
});
}
}
for target in &targets {
if target.login.place != admin.place {
anyhow::bail!(
"{FLAG} must name the same database as {}: host, port and database. It creates \
roles for the database the server connects to, and no other",
target.source
);
}
}
let targets = merge_same_user(targets)?;
let pool = crate::connection::postgres_pool(
admin_url,
"provisioning roles (--admin-database-url)",
&PostgresTlsConfig::default(),
)
.await?;
let client = pool.get().await.context("no connection for provisioning roles")?;
for target in targets.iter().filter(|target| target.login.user != admin.user) {
provision_role(&client, target, &admin.place.database).await?;
let what = if target.engine {
"the engine"
} else {
"first-party auth"
};
println!(" Role {} ready for {what}", target.login.user);
}
Ok(())
}
fn merge_same_user(targets: Vec<Target>) -> Result<Vec<Target>> {
let mut merged: Vec<Target> = Vec::new();
for target in targets {
if let Some(seen) = merged.iter_mut().find(|seen| seen.login.user == target.login.user) {
if seen.login.password != target.login.password {
anyhow::bail!(
"{} and {} name the same user with different passwords",
seen.source,
target.source
);
}
seen.engine |= target.engine;
} else {
merged.push(target);
}
}
Ok(merged)
}
async fn provision_role(
client: &deadpool_postgres::Object,
target: &Target,
database: &str,
) -> Result<()> {
let role = target.login.user.as_str();
let password = target.login.password.as_deref();
let exists: bool = client
.query_one("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", &[&role])
.await
.context("could not read pg_roles")?
.get(0);
let mut statements = Vec::new();
if !exists {
statements.push(
quoted(client, "SELECT format('CREATE ROLE %I LOGIN NOSUPERUSER', $1::text)", &[&role])
.await?,
);
}
if let Some(password) = password {
statements.push(
quoted(
client,
"SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text)",
&[&role, &password],
)
.await?,
);
}
statements.push(
quoted(
client,
"SELECT format('GRANT CONNECT, CREATE ON DATABASE %I TO %I', $1::text, $2::text)",
&[&database, &role],
)
.await?,
);
if target.engine {
statements.push(
quoted(
client,
"SELECT format('GRANT CREATE ON SCHEMA public TO %I', $1::text)",
&[&role],
)
.await?,
);
}
for statement in statements {
client
.batch_execute(&statement)
.await
.map_err(|e| anyhow::anyhow!("could not provision role {role}: {}", db_message(&e)))?;
}
Ok(())
}
async fn quoted(
client: &deadpool_postgres::Object,
format: &str,
args: &[&(dyn tokio_postgres::types::ToSql + Sync)],
) -> Result<String> {
Ok(client
.query_one(format, args)
.await
.map_err(|e| anyhow::anyhow!("could not build a role statement: {}", db_message(&e)))?
.get(0))
}
fn db_message(error: &tokio_postgres::Error) -> String {
error
.as_db_error()
.map_or_else(|| error.to_string(), |db| db.message().to_string())
}
#[cfg(test)]
#[path = "run_provision_tests.rs"]
mod tests;