use std::sync::Arc;
use clap::Subcommand;
use corium_authz::model::{action_from_name, action_names};
use corium_authz::source::MemoryPolicySource;
use corium_authz::{Policy, SystemDbAuthorizer, bootstrap, schema};
use corium_peer::{Admin, Connection};
use corium_protocol::authz::{Access, Action, Principal};
use corium_query::edn::Edn;
use crate::ClientFlags;
#[derive(Subcommand)]
pub enum AuthzCommand {
Init {
#[arg(long, default_value = schema::DEFAULT_AUTHZ_DB)]
db: String,
#[arg(long, default_value = "operator", conflicts_with = "no_admin")]
admin: String,
#[arg(long, default_value = "static-token", conflicts_with = "no_admin")]
provider: String,
#[arg(long)]
no_admin: bool,
#[command(flatten)]
client: ClientFlags,
},
Grant {
subject: String,
relation: String,
object: String,
#[arg(long, default_value = schema::DEFAULT_AUTHZ_DB)]
db: String,
#[command(flatten)]
client: ClientFlags,
},
Revoke {
subject: String,
relation: String,
object: String,
#[arg(long, default_value = schema::DEFAULT_AUTHZ_DB)]
db: String,
#[command(flatten)]
client: ClientFlags,
},
Check {
subject: String,
action: String,
#[arg(long)]
database: Option<String>,
#[arg(long, default_value = "oidc")]
provider: String,
#[arg(long = "role")]
roles: Vec<String>,
#[arg(long = "claim")]
claims: Vec<String>,
#[arg(long, default_value = schema::DEFAULT_AUTHZ_DB)]
db: String,
#[command(flatten)]
client: ClientFlags,
},
Status {
#[arg(long, default_value = schema::DEFAULT_AUTHZ_DB)]
db: String,
#[command(flatten)]
client: ClientFlags,
},
}
pub async fn run(command: AuthzCommand) -> Result<(), String> {
match command {
AuthzCommand::Init {
db,
admin,
provider,
no_admin,
client,
} => {
let admin = if no_admin { None } else { Some(admin) };
let provider = match provider.as_str() {
"any" | "*" => None,
provider => Some(provider.to_owned()),
};
init(&db, admin.as_deref(), provider.as_deref(), &client).await
}
AuthzCommand::Grant {
subject,
relation,
object,
db,
client,
} => grant(&db, &subject, &relation, &object, &client).await,
AuthzCommand::Revoke {
subject,
relation,
object,
db,
client,
} => revoke(&db, &subject, &relation, &object, &client).await,
AuthzCommand::Check {
subject,
action,
database,
provider,
roles,
claims,
db,
client,
} => {
check(
&db,
&subject,
&action,
database.as_deref(),
&provider,
&roles,
&claims,
&client,
)
.await
}
AuthzCommand::Status { db, client } => status(&db, &client).await,
}
}
async fn init(
db: &str,
admin: Option<&str>,
provider: Option<&str>,
client: &ClientFlags,
) -> Result<(), String> {
let mut catalog = Admin::connect(&client.primary(), client.token(), client.tls()?)
.await
.map_err(|error| format!("cannot connect to transactor: {error}"))?;
let created = catalog
.create_database(db, &schema::schema_forms())
.await
.map_err(|error| format!("cannot create {db:?}: {error}"))?;
let connection = connect(db, client).await?;
let mut forms = Vec::new();
if created {
forms.extend(schema::default_permission_forms());
}
if let Some(admin) = admin {
let snapshot = connection.db();
if let Some(provider) = provider {
forms.push(bootstrap::principal_form(admin, Some(provider), &[]));
}
for object in ["catalog:*", "database:*"] {
if bootstrap::find_tuple(&snapshot, admin, "owner", object).is_none() {
forms.push(bootstrap::tuple_form(admin, "owner", object));
}
}
}
if !forms.is_empty() {
connection
.transact(forms)
.await
.map_err(|error| format!("cannot install policy: {error}"))?;
}
let basis = connection
.sync()
.await
.map_err(|error| error.to_string())?
.basis_t();
println!(
"{{:authz-db {db:?} :created {created} :admin {} :provider {} :authz-t {basis}}}",
admin.map_or_else(|| "nil".to_owned(), |admin| format!("{admin:?}")),
provider.map_or_else(|| "nil".to_owned(), |provider| format!("{provider:?}"))
);
if created {
eprintln!(
"corium authz: {db:?} is ready; restart the transactor and peer servers \
with --authz-db {db} to enforce it"
);
match admin {
Some(admin) => eprintln!(
"corium authz: {admin:?} owns catalog:* and database:*; \
grant others with `corium authz grant <subject> <relation> <object>`"
),
None => eprintln!(
"corium authz: nobody holds any relation yet — every request will be denied \
once --authz-db is enabled"
),
}
}
Ok(())
}
async fn grant(
db: &str,
subject: &str,
relation: &str,
object: &str,
client: &ClientFlags,
) -> Result<(), String> {
let connection = connect(db, client).await?;
if bootstrap::find_tuple(&connection.db(), subject, relation, object).is_some() {
println!("{{:granted false :reason \"tuple already present\"}}");
return Ok(());
}
let result = connection
.transact(vec![bootstrap::tuple_form(subject, relation, object)])
.await
.map_err(|error| format!("cannot grant: {error}"))?;
println!(
"{{:granted true :subject {subject:?} :relation {relation:?} :object {object:?} :authz-t {}}}",
result.basis_t
);
Ok(())
}
async fn revoke(
db: &str,
subject: &str,
relation: &str,
object: &str,
client: &ClientFlags,
) -> Result<(), String> {
let connection = connect(db, client).await?;
let snapshot = connection
.sync()
.await
.map_err(|error| format!("cannot read {db:?}: {error}"))?;
let Some(entity) = bootstrap::find_tuple(&snapshot, subject, relation, object) else {
println!("{{:revoked false :reason \"no such tuple\"}}");
return Ok(());
};
let result = connection
.transact(vec![bootstrap::retract_entity_form(entity)])
.await
.map_err(|error| format!("cannot revoke: {error}"))?;
println!(
"{{:revoked true :subject {subject:?} :relation {relation:?} :object {object:?} :authz-t {}}}",
result.basis_t
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn check(
db: &str,
subject: &str,
action: &str,
database: Option<&str>,
provider: &str,
roles: &[String],
claims: &[String],
client: &ClientFlags,
) -> Result<(), String> {
let action = action_from_name(action).ok_or_else(|| {
format!(
"unknown action {action:?}; expected one of {}",
action_names().join(", ")
)
})?;
let mut principal = Principal::new(provider, subject);
for role in roles {
principal = principal.with_role(role.clone());
}
for claim in claims {
let (key, value) = claim
.split_once('=')
.ok_or_else(|| format!("claim {claim:?} is not key=value"))?;
principal = principal.with_claim(key, value);
}
let access = match database {
Some(database) => Access::on(action, database),
None => Access::catalog(action),
};
let snapshot = connect(db, client)
.await?
.sync()
.await
.map_err(|error| format!("cannot read {db:?}: {error}"))?;
let authorizer =
SystemDbAuthorizer::new(Arc::new(MemoryPolicySource::new(db.to_owned(), snapshot)));
let decision = authorizer.check(&principal, &access).await;
println!(
"{{:decision {} :subject {subject:?} :action {} :object {:?} :authz-t {} :path {} :views [{}]{}}}",
if decision.is_allowed() {
if decision.filter().is_some() {
":allow-filtered"
} else {
":allow"
}
} else {
":deny"
},
action_label(action),
decision.object,
decision.authz_t,
decision
.path
.as_ref()
.map_or_else(|| "nil".to_owned(), |path| format!("{path:?}")),
decision
.views
.iter()
.map(|view| format!("{view:?}"))
.collect::<Vec<_>>()
.join(" "),
decision
.reason
.as_ref()
.map_or_else(String::new, |reason| format!(" :reason {reason:?}"))
);
Ok(())
}
async fn status(db: &str, client: &ClientFlags) -> Result<(), String> {
let snapshot = connect(db, client)
.await?
.sync()
.await
.map_err(|error| format!("cannot read {db:?}: {error}"))?;
let policy =
Policy::compile(&snapshot).map_err(|error| format!("{db:?} is not usable: {error}"))?;
let stats = policy.stats();
println!(
"{{:authz-db {db:?} :authz-t {} :principals {} :objects {} :tuples {} :permissions {} :rewrites {} :views {} :bindings {}}}",
policy.basis_t(),
stats.principals,
stats.objects,
stats.tuples,
stats.permissions,
stats.rewrites,
stats.views,
stats.bindings
);
Ok(())
}
async fn connect(db: &str, client: &ClientFlags) -> Result<Connection, String> {
let config = client.connect_config(db.to_owned()).await?;
Connection::connect(config)
.await
.map_err(|error| format!("cannot open {db:?}: {error}"))
}
fn action_label(action: Action) -> Edn {
Edn::keyword(corium_authz::model::action_name(action))
}