use serde_json::{Map, Value as JsonValue};
use crate::control::catalog_entry::CatalogEntry;
use crate::control::metadata_proposer::propose_catalog_entry;
use crate::control::security::audit::AuditEvent;
use crate::control::security::catalog::StoredOidcProvider;
use crate::control::security::catalog::oidc_providers::StoredClaimMappingRule;
use crate::control::security::identity::AuthenticatedIdentity;
use crate::control::server::response_shape::types::ShapedRows;
use crate::control::state::SharedState;
use nodedb_sql::ddl_ast::statement::OidcClaimMappingClause;
use super::super::result::{DdlError, DdlResult};
fn status(command: &str) -> Vec<DdlResult> {
vec![DdlResult::Status {
command: command.to_string(),
rows_affected: None,
}]
}
fn require_superuser(
state: &SharedState,
identity: &AuthenticatedIdentity,
action: &str,
) -> Result<(), DdlError> {
if identity.is_superuser {
Ok(())
} else {
state.audit_record_with_db(
AuditEvent::PermissionDenied,
Some(identity.tenant_id),
None,
&identity.username,
action,
);
Err(DdlError {
sqlstate: "42501".to_string(),
message: format!("permission denied: {action} requires superuser"),
})
}
}
fn has_ambiguous_issuer_route(existing_audience: Option<&str>, audience: Option<&str>) -> bool {
let existing_audience = existing_audience.filter(|value| !value.is_empty());
let audience = audience.filter(|value| !value.is_empty());
match (existing_audience, audience) {
(Some(existing), Some(candidate)) => existing == candidate,
_ => true,
}
}
fn validate_claim_mapping_roles(claim_mappings: &[OidcClaimMappingClause]) -> Result<(), DdlError> {
if claim_mappings
.iter()
.flat_map(|mapping| mapping.add_roles.iter())
.any(|role| role == "superuser")
{
return Err(DdlError {
sqlstate: "22023".to_string(),
message: "OIDC claim mappings cannot grant the database-owned superuser role"
.to_string(),
});
}
Ok(())
}
pub struct CreateOidcProviderParams<'a> {
pub name: &'a str,
pub issuer: &'a str,
pub jwks_uri: &'a str,
pub tenant_id: u64,
pub audience: Option<&'a str>,
pub claim_mappings: &'a [OidcClaimMappingClause],
}
pub fn create_oidc_provider(
state: &SharedState,
identity: &AuthenticatedIdentity,
params: CreateOidcProviderParams<'_>,
) -> Result<Vec<DdlResult>, DdlError> {
let CreateOidcProviderParams {
name,
issuer,
jwks_uri,
tenant_id,
audience,
claim_mappings,
} = params;
require_superuser(state, identity, "create OIDC providers")?;
if issuer.is_empty() {
return Err(DdlError {
sqlstate: "22023".to_string(),
message: "ISSUER must not be empty".to_string(),
});
}
if jwks_uri.is_empty() {
return Err(DdlError {
sqlstate: "22023".to_string(),
message: "JWKS_URI must not be empty".to_string(),
});
}
validate_claim_mapping_roles(claim_mappings)?;
let catalog = state.credentials.catalog();
let tenant_exists = catalog
.load_all_tenants()
.map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("tenant lookup: {e}"),
})?
.iter()
.any(|tenant| tenant.tenant_id == tenant_id);
if !tenant_exists {
return Err(DdlError {
sqlstate: "42704".to_string(),
message: format!("tenant '{tenant_id}' does not exist"),
});
}
match catalog.get_oidc_provider(name) {
Ok(Some(_)) => {
return Err(DdlError {
sqlstate: "42710".to_string(),
message: format!("OIDC provider '{name}' already exists"),
});
}
Ok(None) => {}
Err(e) => {
return Err(DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog read: {e}"),
});
}
}
match catalog.list_oidc_providers() {
Ok(providers) => {
if providers.iter().any(|p| {
p.issuer == issuer && has_ambiguous_issuer_route(p.audience.as_deref(), audience)
}) {
return Err(DdlError {
sqlstate: "42710".to_string(),
message: format!(
"OIDC provider issuer/audience route for issuer '{issuer}' is ambiguous or already exists"
),
});
}
}
Err(e) => {
return Err(DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog list: {e}"),
});
}
}
let stored_mappings: Vec<StoredClaimMappingRule> = claim_mappings
.iter()
.map(|cm| StoredClaimMappingRule {
claim_name: cm.claim_name.clone(),
claim_value: cm.claim_value.clone(),
default_database: cm.default_database,
add_databases: cm.add_databases.clone(),
add_roles: cm.add_roles.clone(),
})
.collect();
let provider = StoredOidcProvider {
provider_name: name.to_string(),
issuer: issuer.to_string(),
jwks_uri: jwks_uri.to_string(),
audience: audience.map(str::to_string),
tenant_id: Some(tenant_id),
claim_mapping: stored_mappings,
created_at_lsn: 0,
};
let entry = CatalogEntry::PutOidcProvider(Box::new(provider.clone()));
let log_index = propose_catalog_entry(state, &entry).map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("metadata propose: {e}"),
})?;
if log_index == 0 {
catalog.put_oidc_provider(&provider).map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog write: {e}"),
})?;
}
state.audit_record(
AuditEvent::OidcProviderChanged,
Some(identity.tenant_id),
&identity.username,
&format!("CREATE OIDC PROVIDER {name} issuer={issuer}"),
);
Ok(status("CREATE OIDC PROVIDER"))
}
pub fn alter_oidc_provider_claim_mapping(
state: &SharedState,
identity: &AuthenticatedIdentity,
name: &str,
claim_mappings: &[OidcClaimMappingClause],
) -> Result<Vec<DdlResult>, DdlError> {
require_superuser(state, identity, "alter OIDC providers")?;
let catalog = state.credentials.catalog();
let mut provider = catalog
.get_oidc_provider(name)
.map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog read: {e}"),
})?
.ok_or(DdlError {
sqlstate: "42704".to_string(),
message: format!("OIDC provider '{name}' does not exist"),
})?;
validate_claim_mapping_roles(claim_mappings)?;
let stored_mappings: Vec<StoredClaimMappingRule> = claim_mappings
.iter()
.map(|cm| StoredClaimMappingRule {
claim_name: cm.claim_name.clone(),
claim_value: cm.claim_value.clone(),
default_database: cm.default_database,
add_databases: cm.add_databases.clone(),
add_roles: cm.add_roles.clone(),
})
.collect();
provider.claim_mapping = stored_mappings;
let entry = CatalogEntry::PutOidcProvider(Box::new(provider.clone()));
let log_index = propose_catalog_entry(state, &entry).map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("metadata propose: {e}"),
})?;
if log_index == 0 {
catalog.put_oidc_provider(&provider).map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog write: {e}"),
})?;
}
state.audit_record(
AuditEvent::OidcProviderChanged,
Some(identity.tenant_id),
&identity.username,
&format!(
"ALTER OIDC PROVIDER {name} SET CLAIM MAPPING ({} rules)",
provider.claim_mapping.len()
),
);
Ok(status("ALTER OIDC PROVIDER"))
}
pub fn drop_oidc_provider(
state: &SharedState,
identity: &AuthenticatedIdentity,
name: &str,
if_exists: bool,
) -> Result<Vec<DdlResult>, DdlError> {
require_superuser(state, identity, "drop OIDC providers")?;
let catalog = state.credentials.catalog();
if catalog
.get_oidc_provider(name)
.map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog read: {e}"),
})?
.is_none()
{
if if_exists {
return Ok(status("DROP OIDC PROVIDER"));
}
return Err(DdlError {
sqlstate: "42704".to_string(),
message: format!("OIDC provider '{name}' does not exist"),
});
}
let entry = CatalogEntry::DeleteOidcProvider {
name: name.to_string(),
};
let log_index = propose_catalog_entry(state, &entry).map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("metadata propose: {e}"),
})?;
if log_index == 0 {
catalog.delete_oidc_provider(name).map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog delete: {e}"),
})?;
}
state.audit_record(
AuditEvent::OidcProviderChanged,
Some(identity.tenant_id),
&identity.username,
&format!("DROP OIDC PROVIDER {name}"),
);
Ok(status("DROP OIDC PROVIDER"))
}
pub fn show_oidc_providers(
state: &SharedState,
identity: &AuthenticatedIdentity,
) -> Result<Vec<DdlResult>, DdlError> {
require_superuser(state, identity, "show OIDC providers")?;
let catalog = state.credentials.catalog();
let providers = catalog.list_oidc_providers().map_err(|e| DdlError {
sqlstate: "XX000".to_string(),
message: format!("catalog list: {e}"),
})?;
let columns = vec![
"name".to_string(),
"issuer".to_string(),
"jwks_uri".to_string(),
"tenant_id".to_string(),
"audience".to_string(),
"claim_mapping_rules".to_string(),
];
let mut rows = Vec::with_capacity(providers.len());
for p in &providers {
let mut row = Map::new();
row.insert(
"name".to_string(),
JsonValue::String(p.provider_name.clone()),
);
row.insert("issuer".to_string(), JsonValue::String(p.issuer.clone()));
row.insert(
"jwks_uri".to_string(),
JsonValue::String(p.jwks_uri.clone()),
);
row.insert(
"tenant_id".to_string(),
JsonValue::String(p.tenant_id.map(|id| id.to_string()).unwrap_or_default()),
);
let aud = p.audience.as_deref().unwrap_or("");
row.insert("audience".to_string(), JsonValue::String(aud.to_string()));
let rule_count = p.claim_mapping.len().to_string();
row.insert(
"claim_mapping_rules".to_string(),
JsonValue::String(rule_count),
);
rows.push(row);
}
let column_types = ShapedRows::text_types(columns.len());
Ok(vec![DdlResult::Rows(ShapedRows {
columns,
column_types,
rows,
notice: None,
})])
}