#![allow(dead_code)]
pub(crate) mod compile;
pub(crate) mod dialect;
pub(crate) mod introspect;
pub(crate) mod policy;
pub(crate) mod runner;
pub(crate) mod schema;
pub(crate) mod sdl;
#[cfg(feature = "oidc")]
pub(crate) mod token;
use boatramp_core::config::HandlerGraphqlDataConfig;
use boatramp_core::sql::SqlValue;
use policy::{Claims, DataPolicy, RowOp, RowPredicate, RowTerm, RowValue, TablePolicy};
use std::collections::BTreeMap;
pub(crate) fn policy_from_config(cfg: &HandlerGraphqlDataConfig) -> DataPolicy {
let mut policy = DataPolicy::new();
for (table, table_cfg) in &cfg.tables {
let mut table_policy = TablePolicy::columns(table_cfg.columns.iter().cloned());
if !table_cfg.row_filter.is_empty() {
table_policy = table_policy.with_rows(RowPredicate {
terms: table_cfg
.row_filter
.iter()
.map(|term| RowTerm {
column: term.column.clone(),
op: RowOp::Eq,
value: RowValue::Claim(term.claim.clone()),
})
.collect(),
});
}
for (field, function) in &table_cfg.resolvers {
table_policy = table_policy.with_resolver(field.clone(), function.clone());
}
policy = policy.with_table(table.clone(), table_policy);
}
policy
}
pub(crate) async fn request_claims(
project: &str,
bearer: Option<&str>,
cfg: &HandlerGraphqlDataConfig,
) -> Claims {
let mut map = token_claims(cfg, bearer).await;
map.insert("project".to_string(), SqlValue::Text(project.to_string()));
Claims::new(map)
}
#[cfg(feature = "oidc")]
async fn token_claims(
cfg: &HandlerGraphqlDataConfig,
bearer: Option<&str>,
) -> BTreeMap<String, SqlValue> {
let mut out = BTreeMap::new();
if let (Some(token_cfg), Some(bearer)) = (&cfg.claims_from_token, bearer) {
if let Some(claims) = token::verified_claims(token_cfg, bearer).await {
for (name, value) in &claims {
if let Some(sql) = scalar_claim(value) {
out.insert(name.clone(), sql);
}
}
}
}
out
}
#[cfg(not(feature = "oidc"))]
async fn token_claims(
_cfg: &HandlerGraphqlDataConfig,
_bearer: Option<&str>,
) -> BTreeMap<String, SqlValue> {
BTreeMap::new()
}
#[cfg(feature = "oidc")]
fn scalar_claim(value: &serde_json::Value) -> Option<SqlValue> {
match value {
serde_json::Value::String(s) => Some(SqlValue::Text(s.clone())),
serde_json::Value::Bool(b) => Some(SqlValue::Boolean(*b)),
serde_json::Value::Number(n) => n
.as_i64()
.map(SqlValue::Integer)
.or_else(|| n.as_f64().map(SqlValue::Real)),
_ => None,
}
}
pub(crate) async fn generate_sql_subgraph_sdl(
provider: &dyn boatramp_core::sql::SqlBackends,
project: &str,
site: &str,
cfg: &HandlerGraphqlDataConfig,
) -> Result<String, String> {
let backend = provider
.database(project, site, &cfg.source)
.await
.map_err(|e| format!("opening the `{site}` database: {e}"))?;
let schema = introspect::introspect_sqlite(backend.as_ref())
.await
.map_err(|e| format!("introspecting the `{site}` database: {e}"))?;
let exposed = policy_from_config(cfg).project_schema(&schema);
Ok(sdl::generate_federation_sdl(&exposed))
}
#[cfg(all(test, feature = "oidc"))]
mod tests {
use super::*;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
fn b64url(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn ed_token(key: &SigningKey, kid: &str, claims: serde_json::Value) -> String {
let header = b64url(
serde_json::json!({ "alg": "EdDSA", "typ": "JWT", "kid": kid })
.to_string()
.as_bytes(),
);
let payload = b64url(claims.to_string().as_bytes());
let signing_input = format!("{header}.{payload}");
format!(
"{signing_input}.{}",
b64url(&key.sign(signing_input.as_bytes()).to_bytes())
)
}
#[tokio::test]
async fn a_verified_token_claim_merges_but_cannot_spoof_project() {
let key = SigningKey::from_bytes(&[3u8; 32]);
let jwks = serde_json::json!({ "keys": [ {
"kty": "OKP", "crv": "Ed25519", "kid": "k",
"x": b64url(key.verifying_key().as_bytes()),
} ] })
.to_string();
std::env::set_var("TEST_GQL_IDP_JWKS_1", &jwks);
let cfg = HandlerGraphqlDataConfig {
enabled: true,
claims_from_token: Some(boatramp_core::config::HandlerGraphqlTokenClaims {
issuer: "https://idp.test".into(),
jwks_env: Some("TEST_GQL_IDP_JWKS_1".into()),
jwks_url: None,
audience: None,
}),
..Default::default()
};
let token = ed_token(
&key,
"k",
serde_json::json!({ "iss": "https://idp.test", "exp": 4_102_444_800_i64, "tid": "acme", "project": "evil" }),
);
let claims = request_claims("default", Some(&token), &cfg).await;
assert_eq!(claims.get("tid"), Some(&SqlValue::Text("acme".into())));
assert_eq!(
claims.get("project"),
Some(&SqlValue::Text("default".into()))
);
let none = request_claims("default", None, &cfg).await;
assert_eq!(none.get("project"), Some(&SqlValue::Text("default".into())));
assert_eq!(none.get("tid"), None);
let expired = ed_token(
&key,
"k",
serde_json::json!({ "iss": "https://idp.test", "exp": 1_000_000_000, "tid": "acme" }),
);
assert_eq!(
request_claims("default", Some(&expired), &cfg)
.await
.get("tid"),
None
);
}
}