use serde_json::{Value, json};
use crate::cli::{GroupingArgs, GroupingCommand, PolicyArgs, PolicyCommand, RbacCommand};
use crate::context::Settings;
use crate::controlplane::{Api, Column, rows, segment};
use crate::error::{Exit, fail};
use crate::manage::ask;
use crate::output::{Output, table};
const POLICY_COLUMNS: &[Column] = &[
("SUBJECT", "/subject"),
("OBJECT", "/object"),
("ACTION", "/action"),
];
const GROUPING_COLUMNS: &[Column] = &[("USER", "/user"), ("ROLE", "/role")];
const KEY_ACTIONS: &[&str] = &["cache.read", "cache.write"];
pub(crate) async fn run(
command: &RbacCommand,
settings: &Settings,
out: &Output,
) -> anyhow::Result<()> {
let tenant = settings.tenant()?;
match command {
RbacCommand::Policy(command) => policy(command, tenant, settings, out).await,
RbacCommand::Grouping(command) => grouping(command, tenant, settings, out).await,
}
}
async fn policy(
command: &PolicyCommand,
tenant: &str,
settings: &Settings,
out: &Output,
) -> anyhow::Result<()> {
let path = format!("/v1/tenants/{}/rbac/policies", segment(tenant));
match command {
PolicyCommand::Ls { subject } => {
let api = Api::new(settings)?;
let policies = api
.list(&path, &[])
.await?
.into_iter()
.filter(|item| subject.as_deref().is_none_or(|s| item["subject"] == s))
.collect();
print_list(out, "policies", POLICY_COLUMNS, policies)
}
PolicyCommand::Add(policy) => {
check_policy(policy, tenant).map_err(|reason| fail(Exit::Usage, reason))?;
let body = policy_body(policy);
Api::new(settings)?
.send(reqwest::Method::POST, &path, &[], Some(&body))
.await?;
out.done(
&format!("added policy {}", describe_policy(policy)),
json!({ "added": body }),
)
}
PolicyCommand::Rm { policy, confirm } => {
ask(
&format!(
"Remove policy {} in tenant {tenant}?",
describe_policy(policy)
),
*confirm,
)?;
let body = policy_body(policy);
Api::new(settings)?
.send(reqwest::Method::DELETE, &path, &[], Some(&body))
.await?;
out.done(
&format!("removed policy {}", describe_policy(policy)),
json!({ "removed": body }),
)
}
}
}
async fn grouping(
command: &GroupingCommand,
tenant: &str,
settings: &Settings,
out: &Output,
) -> anyhow::Result<()> {
let path = format!("/v1/tenants/{}/rbac/groupings", segment(tenant));
match command {
GroupingCommand::Ls { user, role } => {
let api = Api::new(settings)?;
let groupings = api
.list(&path, &[])
.await?
.into_iter()
.filter(|item| user.as_deref().is_none_or(|u| item["user"] == u))
.filter(|item| role.as_deref().is_none_or(|r| item["role"] == r))
.collect();
print_list(out, "groupings", GROUPING_COLUMNS, groupings)
}
GroupingCommand::Add(grouping) => {
let body = grouping_body(grouping);
Api::new(settings)?
.send(reqwest::Method::POST, &path, &[], Some(&body))
.await?;
out.done(
&format!("assigned {} to {}", grouping.role, grouping.user),
json!({ "added": body }),
)
}
GroupingCommand::Rm { grouping, confirm } => {
ask(
&format!(
"Take {} away from {} in tenant {tenant}?",
grouping.role, grouping.user
),
*confirm,
)?;
let body = grouping_body(grouping);
Api::new(settings)?
.send(reqwest::Method::DELETE, &path, &[], Some(&body))
.await?;
out.done(
&format!("took {} away from {}", grouping.role, grouping.user),
json!({ "removed": body }),
)
}
}
}
pub(crate) fn policy_body(policy: &PolicyArgs) -> Value {
json!({
"subject": policy.subject,
"object": policy.object,
"action": policy.action,
})
}
pub(crate) fn grouping_body(grouping: &GroupingArgs) -> Value {
json!({ "user": grouping.user, "role": grouping.role })
}
fn describe_policy(policy: &PolicyArgs) -> String {
format!("{} {} {}", policy.subject, policy.object, policy.action)
}
fn print_list(
out: &Output,
kind: &str,
columns: &[Column],
items: Vec<Value>,
) -> anyhow::Result<()> {
if out.json {
return out.json_value(&json!({ kind: items }));
}
let headers: Vec<&str> = columns.iter().map(|(header, _)| *header).collect();
out.text(&table(&headers, rows(columns, &items)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ObjectKind {
Cluster,
Node,
Tenant,
Namespace,
Stream,
Cache,
CacheKey,
Group,
Other,
}
pub(crate) fn check_policy(policy: &PolicyArgs, tenant: &str) -> Result<ObjectKind, String> {
let kind = check_object(&policy.object, tenant)
.map_err(|why| format!("{:?}: {why}", policy.object))?;
if kind == ObjectKind::CacheKey && !KEY_ACTIONS.contains(&policy.action.as_str()) {
return Err(format!(
"{:?}: a cache key object only takes cache.read or cache.write, not {}",
policy.object, policy.action
));
}
Ok(kind)
}
pub(crate) fn check_object(raw: &str, tenant: &str) -> Result<ObjectKind, String> {
let Some((kind, rest)) = raw.split_once(':') else {
return Err("an object is kind:name, such as stream:t1/payments/orders".to_string());
};
match kind {
"cluster" if rest == "*" => Ok(ObjectKind::Cluster),
"cluster" => Err("the only cluster object is cluster:*".to_string()),
"node" if rest.is_empty() || rest == "*" => {
Err("a node object names one node; use cluster:* for all".to_string())
}
"node" => Ok(ObjectKind::Node),
"tenant" if rest == "*" => Err("tenant:* is not allowed".to_string()),
"tenant" => {
same_tenant(rest, tenant)?;
Ok(ObjectKind::Tenant)
}
"namespace" => {
let [tid, ns] = split_exact(rest)?;
same_tenant(tid, tenant)?;
check_segment(ns, true)?;
Ok(ObjectKind::Namespace)
}
"stream" => {
let [tid, ns, stream] = split_exact(rest)?;
same_tenant(tid, tenant)?;
check_leaf(ns, stream)?;
Ok(ObjectKind::Stream)
}
"cache" => {
let (cache_part, key) = match rest.match_indices('/').nth(2) {
Some((at, _)) => (&rest[..at], Some(&rest[at + 1..])),
None => (rest, None),
};
let [tid, ns, cache] = split_exact(cache_part)?;
same_tenant(tid, tenant)?;
check_leaf(ns, cache)?;
let Some(key) = key else {
return Ok(ObjectKind::Cache);
};
check_key(key)?;
if ns == "*" || cache == "*" {
return Err("a cache key object must name its namespace and cache".to_string());
}
Ok(ObjectKind::CacheKey)
}
"group" => {
let [tid, ns, stream, group] = split_exact(rest)?;
same_tenant(tid, tenant)?;
check_segment(group, true)?;
check_segment(stream, group == "*")?;
check_segment(ns, stream == "*")?;
Ok(ObjectKind::Group)
}
_ => Ok(ObjectKind::Other),
}
}
fn same_tenant(named: &str, tenant: &str) -> Result<(), String> {
if named == tenant {
Ok(())
} else {
Err(format!(
"names tenant {named:?}, but the request is for tenant {tenant:?}"
))
}
}
fn split_exact<const N: usize>(rest: &str) -> Result<[&str; N], String> {
let parts: Vec<&str> = rest.split('/').collect();
parts
.try_into()
.map_err(|_| format!("expected {N} parts separated by /"))
}
fn check_leaf(ns: &str, leaf: &str) -> Result<(), String> {
check_segment(leaf, true)?;
check_segment(ns, leaf == "*")
}
fn check_segment(raw: &str, allow_star: bool) -> Result<(), String> {
if raw == "*" && !allow_star {
return Err("* fits here only when every part after it is * too".to_string());
}
if raw.is_empty() {
return Err("a part is empty".to_string());
}
if raw.contains(':') {
return Err(format!("{raw:?} holds a ':'"));
}
Ok(())
}
fn check_key(key: &str) -> Result<(), String> {
let literal = key.strip_suffix('*').unwrap_or(key);
if literal.is_empty() {
return Err("a cache key object names a key or a non-empty prefix; \
grant the whole cache without a key part"
.to_string());
}
if literal.contains('*') {
return Err("a cache key may only end in *, which makes it a prefix".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests;