use anyhow::{Result, anyhow};
use colored::Colorize;
use serde_json::{Value, json};
use rigg_client::arm::ArmClient;
use rigg_client::graph::GraphClient;
use rigg_core::binding::{BindingType, EnvBindings};
use rigg_core::registry::{self, X_RIGG_AUTH};
use rigg_core::resources::ResourceKind;
use rigg_core::store::Store;
use rigg_core::workspace::{ResolvedEnv, Workspace};
use crate::commands::ask::Question;
use crate::commands::{
CommandError, GlobalContext, auth_engine, confirm_protected_env, credentials, load_workspace,
resolve_env,
};
use crate::say;
pub struct Wiring {
pub site: String,
pub audience: String,
pub touched: Vec<String>,
pub applied: bool,
}
pub async fn run(
ctx: &GlobalContext,
binding: String,
client_id: Option<String>,
confirm_env: Option<&str>,
) -> Result<()> {
let ws = load_workspace()?;
let env = resolve_env(&ws, ctx)?;
let wiring = wire(ctx, &ws, &env, &binding, client_id.as_deref(), confirm_env).await?;
if !wiring.applied {
say!(ctx, "no changes made");
return Ok(());
}
say!(ctx);
say!(
ctx,
"{} Entra authentication is on for '{}' โ audience {}",
"โ".green().bold(),
wiring.site,
wiring.audience
);
if wiring.touched.is_empty() {
say!(
ctx,
" no skillset in '{}' calls this app yet; set \"authResourceId\": \"{}\" on the \
WebApiSkill when you add one",
env.name,
wiring.audience
);
} else {
for file in &wiring.touched {
say!(ctx, " updated {file}");
}
say!(
ctx,
" run `rigg push` to send the keyless skillset(s) to Azure"
);
}
Ok(())
}
pub async fn wire(
ctx: &GlobalContext,
ws: &Workspace,
env: &ResolvedEnv,
binding: &str,
client_id: Option<&str>,
confirm_env: Option<&str>,
) -> Result<Wiring> {
let declared = env.env.dependencies.get(binding).ok_or_else(|| {
anyhow!(CommandError::Validation(format!(
"environment '{}' has no dependency named '{binding}' โ declare the function app \
first: `rigg env bind {} {binding} function-app:<app-name>`",
env.name, env.name
)))
})?;
if declared.kind != BindingType::FunctionApp {
return Err(anyhow!(CommandError::Validation(format!(
"'{binding}' is a {} binding, not a function-app binding โ easy-auth wires Entra \
authentication onto a function app",
declared.kind
))));
}
let tenant = env.env.tenant.clone().ok_or_else(|| {
anyhow!(CommandError::Validation(format!(
"environment '{}' declares no `tenant:` โ Easy Auth's OpenID issuer \
(https://login.microsoftonline.com/<tenant>/v2.0) needs it; add it to rigg.yaml",
env.name
)))
})?;
let arm = ArmClient::for_tenant(Some(&tenant))
.map_err(|e| anyhow!(CommandError::AuthDenied(format!("{e}"))))?;
let bindings = auth_engine::bindings_for(ws, env, Some(&arm)).await;
let site_id = resolved_arm_id(&bindings, binding).ok_or_else(|| {
anyhow!(CommandError::AuthDenied(format!(
"binding '{binding}' does not resolve to a function app in Azure โ check the value \
in rigg.yaml, then `rigg env show {} --refresh` (or `rigg env bind {} --learn`)",
env.name, env.name
)))
})?;
let site = rigg_core::binding::arm_resource_name(&site_id)
.unwrap_or(binding)
.to_string();
let hostname = match arm.site_default_hostname(&site_id).await {
Ok(host) => host,
Err(e) => {
say!(ctx, " (could not read the app's default hostname: {e})");
format!("{site}.azurewebsites.net")
}
};
let mut targets = webapi_targets(ws, &env.name, &hostname)?;
let callers = caller_identities(ctx, &arm, &tenant, &bindings, &targets).await?;
let allowed: Vec<String> = callers.iter().map(|c| c.client_id.clone()).collect();
let current = arm.site_auth_settings(&site_id).await?;
let planned_client_id = client_id.unwrap_or(NEW_APP_PLACEHOLDER);
let planned = merge_auth_settings(
¤t,
planned_client_id,
&tenant,
&format!("api://{planned_client_id}"),
&allowed,
);
say!(ctx);
say!(
ctx,
"Easy Auth on '{site}' ({}) โ planned authsettingsV2:",
env.name
);
let diff = rigg_diff::semantic::diff(&planned, ¤t, "name");
for line in rigg_diff::output::format_text(
&diff,
"authsettingsV2",
&rigg_diff::output::SideLabels {
new_side: "planned".to_string(),
old_side: "current".to_string(),
},
)
.lines()
{
say!(ctx, " {line}");
}
for caller in &callers {
say!(
ctx,
" admitting the search identity {} ({})",
caller.client_id,
caller.label
);
}
if !targets.is_empty() {
say!(ctx, " skillset files that become keyless:");
for t in &targets {
say!(ctx, " {}", t.display);
}
}
if !confirm(ctx, env, &site, confirm_env)? {
return Ok(Wiring {
site,
audience: format!("api://{planned_client_id}"),
touched: Vec::new(),
applied: false,
});
}
let graph = GraphClient::for_tenant(Some(&tenant))
.map_err(|e| anyhow!(CommandError::AuthDenied(format!("{e}"))))?;
let app = match client_id {
Some(id) => graph.application_by_app_id(id).await?.ok_or_else(|| {
anyhow!(CommandError::Validation(format!(
"no application registration with client id '{id}' in tenant '{tenant}'"
)))
})?,
None => graph.create_application(&format!("rigg-{site}")).await?,
};
let audience = format!("api://{}", app.app_id);
let role_id = graph
.set_identifier_uri_and_role(&app.id, &audience)
.await?;
let sp = graph.ensure_service_principal(&app.app_id).await?;
let settings = merge_auth_settings(¤t, &app.app_id, &tenant, &audience, &allowed);
arm.put_site_auth_settings(&site_id, &settings).await?;
if sp.app_role_assignment_required {
for caller in &callers {
graph
.assign_app_role(&sp.id, &caller.object_id, &role_id)
.await?;
say!(
ctx,
" granted the '{}' identity the app role (assignment is required on this app)",
caller.label
);
}
}
let mut touched = Vec::new();
for target in &mut targets {
for idx in &target.skills {
let skill = &mut target.doc["skills"][*idx];
skill["authResourceId"] = Value::String(audience.clone());
skill["uri"] = Value::String(credentials::strip_code_param(
skill.get("uri").and_then(Value::as_str).unwrap_or_default(),
));
credentials::remove_function_key_header(skill);
if let Some(map) = skill.as_object_mut()
&& map
.get(X_RIGG_AUTH)
.and_then(Value::as_str)
.is_some_and(registry::is_known_auth_annotation)
{
map.remove(X_RIGG_AUTH);
}
}
let project = ws.project(&target.project)?;
Store::new(project, &env.name).write_exact(&target.r, &target.doc)?;
touched.push(target.display.clone());
}
Ok(Wiring {
site,
audience,
touched,
applied: true,
})
}
const NEW_APP_PLACEHOLDER: &str = "<app registration rigg will create>";
fn confirm(
ctx: &GlobalContext,
env: &ResolvedEnv,
site: &str,
confirm_env: Option<&str>,
) -> Result<bool> {
if !confirm_protected_env(
ctx,
env,
confirm_env,
"auth easy-auth",
"auth easy-auth",
json!({"env": env.name, "site": site}),
)? {
return Ok(false);
}
if ctx.yes {
return Ok(true);
}
let mut asker = ctx.asker("auth easy-auth", json!({"env": env.name, "site": site}));
Ok(asker
.ask(&Question::confirm(
format!("auth.easyauth.{site}"),
format!("Enable Microsoft Entra authentication on '{site}'?"),
true,
))?
.as_bool()
== Some(true))
}
fn resolved_arm_id(bindings: &EnvBindings, name: &str) -> Option<String> {
bindings
.get(name)
.and_then(|e| e.resolved.as_ref())
.and_then(|r| r.arm_id.clone())
}
struct Caller {
object_id: String,
client_id: String,
label: String,
}
async fn caller_identities(
ctx: &GlobalContext,
arm: &ArmClient,
tenant: &str,
bindings: &EnvBindings,
targets: &[SkillsetTarget],
) -> Result<Vec<Caller>> {
let mut callers: Vec<Caller> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for uami in targets.iter().flat_map(|t| t.uamis.iter()) {
if seen.iter().any(|u| u.eq_ignore_ascii_case(uami)) {
continue;
}
seen.push(uami.clone());
let (object_id, client_id) = arm.managed_identity_ids(uami).await?;
callers.push(Caller {
object_id,
client_id,
label: rigg_core::binding::arm_resource_name(uami)
.unwrap_or("user-assigned identity")
.to_string(),
});
}
let needs_system = targets.is_empty() || targets.iter().any(|t| t.needs_system_identity);
if !needs_system {
return Ok(callers);
}
match system_identity(arm, tenant, bindings).await {
Ok(caller) => callers.push(caller),
Err(e) if !callers.is_empty() => say!(
ctx,
" {} the system-assigned identity could not be admitted ({e}) โ skills without an \
`authIdentity` will still be refused",
"!".yellow()
),
Err(e) => return Err(e),
}
Ok(callers)
}
async fn system_identity(arm: &ArmClient, tenant: &str, bindings: &EnvBindings) -> Result<Caller> {
let search_id = resolved_arm_id(bindings, "search").ok_or_else(|| {
anyhow!(CommandError::AuthDenied(
"the environment's search service does not resolve in Azure โ `rigg env show \
--refresh`"
.to_string()
))
})?;
let info = arm.get_search_service(&search_id).await?;
let object_id = info.identity.principal_id.clone().ok_or_else(|| {
anyhow!(CommandError::Validation(format!(
"search service '{}' has no system-assigned identity โ enable it first \
(`rigg auth doctor --fix`)",
info.name
)))
})?;
let graph = GraphClient::for_tenant(Some(tenant))
.map_err(|e| anyhow!(CommandError::AuthDenied(format!("{e}"))))?;
let client_id = graph.service_principal_app_id(&object_id).await?;
Ok(Caller {
object_id,
client_id,
label: format!("{} (system-assigned)", info.name),
})
}
struct SkillsetTarget {
project: String,
r: rigg_core::resources::ResourceRef,
display: String,
doc: Value,
skills: Vec<usize>,
uamis: Vec<String>,
needs_system_identity: bool,
}
fn webapi_targets(ws: &Workspace, env: &str, hostname: &str) -> Result<Vec<SkillsetTarget>> {
let mut out = Vec::new();
for project in &ws.projects {
if !Store::envs_of(project).contains(&env.to_string()) {
continue;
}
let store = Store::new(project, env);
for (r, path) in store.list()? {
if r.kind != ResourceKind::Skillset {
continue;
}
let doc = store.read(&r)?;
let Some(skills) = doc.get("skills").and_then(Value::as_array) else {
continue;
};
let mut indices = Vec::new();
let mut uamis: Vec<String> = Vec::new();
let mut needs_system_identity = false;
for (i, skill) in skills.iter().enumerate() {
let is_webapi = skill
.get("@odata.type")
.and_then(Value::as_str)
.is_some_and(|t| t.ends_with("WebApiSkill"));
if !is_webapi {
continue;
}
let uri = skill.get("uri").and_then(Value::as_str).unwrap_or_default();
if uri_host(uri).is_none_or(|h| !h.eq_ignore_ascii_case(hostname)) {
continue;
}
indices.push(i);
match skill
.pointer("/authIdentity/userAssignedIdentity")
.and_then(Value::as_str)
{
Some(uami) if !uamis.iter().any(|u| u.eq_ignore_ascii_case(uami)) => {
uamis.push(uami.to_string())
}
Some(_) => {}
None => needs_system_identity = true,
}
}
if indices.is_empty() {
continue;
}
out.push(SkillsetTarget {
project: project.name.clone(),
r,
display: path
.strip_prefix(&ws.root)
.unwrap_or(&path)
.display()
.to_string(),
doc,
skills: indices,
uamis,
needs_system_identity,
});
}
}
Ok(out)
}
fn uri_host(uri: &str) -> Option<&str> {
let rest = uri
.strip_prefix("https://")
.or_else(|| uri.strip_prefix("http://"))?;
let authority = rest.split(['/', '?', '#']).next()?;
let host = authority.rsplit('@').next()?;
let host = host.split(':').next()?.trim_end_matches('.');
(!host.is_empty()).then_some(host)
}
pub fn merge_auth_settings(
current: &Value,
client_id: &str,
tenant: &str,
audience: &str,
allowed_applications: &[String],
) -> Value {
let mut merged = current.clone();
if !merged.is_object() {
merged = json!({});
}
ensure_object(&mut merged, "properties");
let props = &mut merged["properties"];
ensure_object(props, "platform");
props["platform"]["enabled"] = json!(true);
ensure_object(props, "globalValidation");
props["globalValidation"]["requireAuthentication"] = json!(true);
props["globalValidation"]["unauthenticatedClientAction"] = json!("Return401");
ensure_object(props, "identityProviders");
ensure_object(&mut props["identityProviders"], "azureActiveDirectory");
let aad = &mut props["identityProviders"]["azureActiveDirectory"];
aad["enabled"] = json!(true);
ensure_object(aad, "registration");
let previous_client_id = aad
.pointer("/registration/clientId")
.and_then(Value::as_str)
.map(str::to_string);
aad["registration"]["clientId"] = json!(client_id);
aad["registration"]["openIdIssuer"] =
json!(format!("https://login.microsoftonline.com/{tenant}/v2.0"));
if previous_client_id.is_some_and(|p| p != client_id)
&& let Some(registration) = aad["registration"].as_object_mut()
{
registration.remove("clientSecretSettingName");
}
ensure_object(aad, "validation");
let audiences = union_with(
aad.pointer("/validation/allowedAudiences"),
std::slice::from_ref(&audience.to_string()),
);
aad["validation"]["allowedAudiences"] = audiences;
ensure_object(&mut aad["validation"], "defaultAuthorizationPolicy");
let allowed = union_with(
aad.pointer("/validation/defaultAuthorizationPolicy/allowedApplications"),
allowed_applications,
);
aad["validation"]["defaultAuthorizationPolicy"]["allowedApplications"] = allowed;
merged
}
fn ensure_object(value: &mut Value, key: &str) {
if !value.get(key).is_some_and(Value::is_object) {
value[key] = json!({});
}
}
fn union_with(existing: Option<&Value>, entries: &[String]) -> Value {
let mut items: Vec<String> = existing
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
for entry in entries {
if !items.iter().any(|i| i == entry) {
items.push(entry.clone());
}
}
json!(items)
}
#[cfg(test)]
mod tests {
use super::*;
fn tenant() -> &'static str {
"11111111-2222-3333-4444-555555555555"
}
fn mi<const N: usize>(ids: [&str; N]) -> Vec<String> {
ids.iter().map(|s| s.to_string()).collect()
}
#[test]
fn merge_sets_every_field_the_spec_requires_on_an_empty_document() {
let merged =
merge_auth_settings(&json!({}), "app-1", tenant(), "api://app-1", &mi(["mi-1"]));
let props = &merged["properties"];
assert_eq!(props["platform"]["enabled"], json!(true));
assert_eq!(
props["globalValidation"]["requireAuthentication"],
json!(true)
);
assert_eq!(
props["globalValidation"]["unauthenticatedClientAction"],
json!("Return401")
);
let aad = &props["identityProviders"]["azureActiveDirectory"];
assert_eq!(aad["enabled"], json!(true));
assert_eq!(aad["registration"]["clientId"], json!("app-1"));
assert_eq!(
aad["registration"]["openIdIssuer"],
json!(format!(
"https://login.microsoftonline.com/{}/v2.0",
tenant()
))
);
assert_eq!(
aad["validation"]["allowedAudiences"],
json!(["api://app-1"])
);
assert_eq!(
aad["validation"]["defaultAuthorizationPolicy"]["allowedApplications"],
json!(["mi-1"])
);
}
#[test]
fn merge_keeps_other_providers_and_unrelated_fields_verbatim() {
let current = json!({
"properties": {
"platform": {"enabled": false, "runtimeVersion": "~1"},
"identityProviders": {
"google": {"enabled": true, "registration": {"clientId": "g"}},
"azureActiveDirectory": {"isAutoProvisioned": true}
},
"login": {"tokenStore": {"enabled": true}},
"httpSettings": {"requireHttps": true}
}
});
let merged = merge_auth_settings(¤t, "app-1", tenant(), "api://app-1", &mi(["mi-1"]));
let props = &merged["properties"];
assert_eq!(props["platform"]["runtimeVersion"], json!("~1"));
assert_eq!(props["platform"]["enabled"], json!(true), "switched on");
assert_eq!(
props["identityProviders"]["google"]["registration"]["clientId"],
json!("g")
);
assert_eq!(
props["identityProviders"]["azureActiveDirectory"]["isAutoProvisioned"],
json!(true)
);
assert_eq!(props["login"]["tokenStore"]["enabled"], json!(true));
assert_eq!(props["httpSettings"]["requireHttps"], json!(true));
}
#[test]
fn merge_unions_audiences_and_allowed_applications_never_replaces_them() {
let current = json!({"properties": {"identityProviders": {"azureActiveDirectory": {
"validation": {
"allowedAudiences": ["api://legacy"],
"defaultAuthorizationPolicy": {
"allowedApplications": ["other-mi"],
"allowedPrincipals": {"identities": ["keep-me"]}
}
}
}}}});
let merged = merge_auth_settings(¤t, "app-1", tenant(), "api://app-1", &mi(["mi-1"]));
let validation =
&merged["properties"]["identityProviders"]["azureActiveDirectory"]["validation"];
assert_eq!(
validation["allowedAudiences"],
json!(["api://legacy", "api://app-1"])
);
assert_eq!(
validation["defaultAuthorizationPolicy"]["allowedApplications"],
json!(["other-mi", "mi-1"])
);
assert_eq!(
validation["defaultAuthorizationPolicy"]["allowedPrincipals"]["identities"],
json!(["keep-me"])
);
}
#[test]
fn merge_is_idempotent() {
let once = merge_auth_settings(&json!({}), "app-1", tenant(), "api://app-1", &mi(["mi-1"]));
let twice = merge_auth_settings(&once, "app-1", tenant(), "api://app-1", &mi(["mi-1"]));
assert_eq!(once, twice);
}
#[test]
fn merge_admits_every_identity_it_is_given() {
let merged = merge_auth_settings(
&json!({}),
"app-1",
tenant(),
"api://app-1",
&mi(["mi-a", "mi-b", "search-system"]),
);
assert_eq!(
merged["properties"]["identityProviders"]["azureActiveDirectory"]["validation"]["defaultAuthorizationPolicy"]
["allowedApplications"],
json!(["mi-a", "mi-b", "search-system"])
);
}
#[test]
fn merge_drops_a_stale_client_secret_setting_when_the_client_id_changes() {
let current = json!({"properties": {"identityProviders": {"azureActiveDirectory": {
"registration": {"clientId": "old-app", "clientSecretSettingName": "OLD_SECRET"}
}}}});
let merged = merge_auth_settings(¤t, "app-1", tenant(), "api://app-1", &mi(["mi-1"]));
let registration =
&merged["properties"]["identityProviders"]["azureActiveDirectory"]["registration"];
assert_eq!(registration["clientId"], json!("app-1"));
assert!(
registration.get("clientSecretSettingName").is_none(),
"{registration}"
);
let same = merge_auth_settings(¤t, "old-app", tenant(), "api://old-app", &mi(["m"]));
assert_eq!(
same["properties"]["identityProviders"]["azureActiveDirectory"]["registration"]["clientSecretSettingName"],
json!("OLD_SECRET")
);
}
#[test]
fn uri_host_reads_the_host_only() {
assert_eq!(
uri_host("https://fn.azurewebsites.net/api/enrich?code=x"),
Some("fn.azurewebsites.net")
);
assert_eq!(
uri_host("https://fn.azurewebsites.net"),
Some("fn.azurewebsites.net")
);
assert_eq!(
uri_host("https://fn.azurewebsites.net:443/api/enrich"),
Some("fn.azurewebsites.net")
);
assert_eq!(
uri_host("https://fn.azurewebsites.net./api/enrich"),
Some("fn.azurewebsites.net")
);
assert_eq!(
uri_host("https://user@fn.azurewebsites.net:8080/api"),
Some("fn.azurewebsites.net")
);
assert_eq!(uri_host("https:///api"), None);
assert_eq!(uri_host("not a uri"), None);
}
}