Skip to content

Commit fa10c41

Browse files
committed
editoast: add CLI to interact with roles
Signed-off-by: Leo Valais <[email protected]>
1 parent 3495b31 commit fa10c41

File tree

6 files changed

+211
-4
lines changed

6 files changed

+211
-4
lines changed

editoast/Cargo.lock

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

editoast/Cargo.toml

+1-2
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ utoipa = { version = "4.2.3", features = ["chrono", "uuid"] }
8080
uuid = { version = "1.10.0", features = ["serde", "v4"] }
8181

8282
[dependencies]
83-
# For batch dependency updates, see editoast/README.md
84-
83+
anyhow = "1.0"
8584
async-trait = "0.1.82"
8685
axum = { version = "0.7.7", default-features = false, features = [
8786
"multipart",

editoast/editoast_authz/src/builtin_role.rs

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
use serde::Deserialize;
22
use serde::Serialize;
33
use strum::AsRefStr;
4+
use strum::Display;
5+
use strum::EnumIter;
46
use strum::EnumString;
57
use utoipa::ToSchema;
68

79
use crate::roles::BuiltinRoleSet;
810

911
#[derive(
10-
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumString, AsRefStr, ToSchema,
12+
Debug,
13+
Clone,
14+
Copy,
15+
PartialEq,
16+
Eq,
17+
Hash,
18+
Serialize,
19+
Deserialize,
20+
EnumString,
21+
AsRefStr,
22+
EnumIter,
23+
Display,
24+
ToSchema,
1125
)]
1226
pub enum BuiltinRole {
1327
/// A user with this role short-circuits all role and permission checks

editoast/src/client/mod.rs

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod postgres_config;
22
mod redis_config;
3+
pub mod roles;
34
pub mod stdcm_search_env_commands;
45
mod telemetry_config;
56

@@ -14,6 +15,7 @@ use derivative::Derivative;
1415
use editoast_derive::EditoastError;
1516
pub use postgres_config::PostgresConfig;
1617
pub use redis_config::RedisConfig;
18+
use roles::RolesCommand;
1719
use stdcm_search_env_commands::StdcmSearchEnvCommands;
1820
pub use telemetry_config::TelemetryConfig;
1921
pub use telemetry_config::TelemetryKind;
@@ -71,6 +73,8 @@ pub enum Commands {
7173
long_about = "STDCM search environment management commands"
7274
)]
7375
STDCMSearchEnv(StdcmSearchEnvCommands),
76+
#[command(subcommand, about, long_about = "Roles related commands")]
77+
Roles(RolesCommand),
7478
}
7579

7680
#[derive(Subcommand, Debug)]

editoast/src/client/roles.rs

+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
use std::{collections::HashSet, fmt::Display};
2+
3+
use anyhow::{anyhow, bail};
4+
use clap::{Args, Subcommand};
5+
use editoast_authz::{
6+
authorizer::{StorageDriver, UserInfo},
7+
roles::BuiltinRoleSet,
8+
BuiltinRole,
9+
};
10+
use editoast_models::DbConnection;
11+
use itertools::Itertools as _;
12+
use strum::IntoEnumIterator;
13+
use tracing::info;
14+
15+
use crate::models::auth::PgAuthDriver;
16+
17+
#[derive(Debug, Subcommand)]
18+
pub enum RolesCommand {
19+
/// Lists the builtin roles supported by editoast
20+
ListRoles,
21+
/// Lists the roles assigned to a subject
22+
List(ListArgs),
23+
/// Grants builtin roles to a subject
24+
Add(AddArgs),
25+
/// Revokes builtin roles from a subject
26+
Remove(RemoveArgs),
27+
}
28+
29+
#[derive(Debug, Args)]
30+
pub struct ListArgs {
31+
/// A subject ID or user identity
32+
subject: String,
33+
}
34+
35+
#[derive(Debug, Args)]
36+
pub struct AddArgs {
37+
/// A subject ID or user identity
38+
subject: String,
39+
/// A non-empty list of builtin roles
40+
roles: Vec<String>,
41+
}
42+
43+
#[derive(Debug, Args)]
44+
pub struct RemoveArgs {
45+
/// A subject ID or user identity
46+
subject: String,
47+
/// A non-empty list of builtin roles
48+
roles: Vec<String>,
49+
}
50+
51+
pub fn list_roles() {
52+
BuiltinRole::iter().for_each(|role| println!("{role}"));
53+
}
54+
55+
#[derive(Debug)]
56+
struct Subject {
57+
id: i64,
58+
info: UserInfo,
59+
}
60+
61+
impl Display for Subject {
62+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63+
let Self {
64+
id,
65+
info: UserInfo { identity, name },
66+
} = self;
67+
write!(f, "{identity}#{id} ({name})")
68+
}
69+
}
70+
71+
async fn parse_and_fetch_subject(
72+
subject: &String,
73+
driver: &PgAuthDriver<BuiltinRole>,
74+
) -> anyhow::Result<Subject> {
75+
let id = if let Ok(id) = subject.parse::<i64>() {
76+
id
77+
} else {
78+
let uid = driver.get_user_id(subject).await?;
79+
uid.ok_or_else(|| anyhow!("No subject with identity '{subject}' found"))?
80+
};
81+
if let Some(info) = driver.get_user_info(id).await? {
82+
let subject = Subject { id, info };
83+
info!("Subject {subject}");
84+
Ok(subject)
85+
} else {
86+
bail!("No subject found with ID {id}");
87+
}
88+
}
89+
90+
pub async fn list_subject_roles(
91+
ListArgs { subject }: ListArgs,
92+
conn: DbConnection,
93+
) -> anyhow::Result<()> {
94+
let driver = PgAuthDriver::<BuiltinRole>::new(conn);
95+
let subject = parse_and_fetch_subject(&subject, &driver).await?;
96+
let roles = driver.fetch_subject_roles(subject.id).await?;
97+
if roles.is_empty() {
98+
println!("{subject} has no roles assigned");
99+
return Ok(());
100+
}
101+
for role in roles {
102+
println!("{role}");
103+
}
104+
Ok(())
105+
}
106+
107+
fn parse_role_case_insensitive(tag: &String) -> anyhow::Result<BuiltinRole> {
108+
let tag = tag.to_lowercase();
109+
for role in BuiltinRole::iter() {
110+
if role.as_str().to_lowercase() == tag {
111+
return Ok(role);
112+
}
113+
}
114+
bail!("Invalid role tag '{tag}'");
115+
}
116+
117+
pub async fn add_roles(
118+
AddArgs { subject, roles }: AddArgs,
119+
conn: DbConnection,
120+
) -> anyhow::Result<()> {
121+
let driver = PgAuthDriver::<BuiltinRole>::new(conn);
122+
let subject = parse_and_fetch_subject(&subject, &driver).await?;
123+
let roles = roles
124+
.iter()
125+
.map(parse_role_case_insensitive)
126+
.collect::<Result<HashSet<_>, _>>()?;
127+
info!(
128+
"Adding roles {} to {subject}",
129+
roles
130+
.iter()
131+
.map(|role| role.to_string())
132+
.collect_vec()
133+
.join(", "),
134+
);
135+
driver.ensure_subject_roles(subject.id, roles).await?;
136+
Ok(())
137+
}
138+
139+
pub async fn remove_roles(
140+
RemoveArgs { subject, roles }: RemoveArgs,
141+
conn: DbConnection,
142+
) -> anyhow::Result<()> {
143+
let driver = PgAuthDriver::<BuiltinRole>::new(conn);
144+
let subject = parse_and_fetch_subject(&subject, &driver).await?;
145+
let roles = roles
146+
.iter()
147+
.map(parse_role_case_insensitive)
148+
.collect::<Result<HashSet<_>, _>>()?;
149+
info!(
150+
"Removing roles {} from {subject}",
151+
roles
152+
.iter()
153+
.map(|role| role.to_string())
154+
.collect_vec()
155+
.join(", "),
156+
);
157+
driver.remove_subject_roles(subject.id, roles).await?;
158+
Ok(())
159+
}

editoast/src/main.rs

+30
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ use axum::{Router, ServiceExt};
2020
use axum_tracing_opentelemetry::middleware::OtelAxumLayer;
2121
use chashmap::CHashMap;
2222
use clap::Parser;
23+
use client::roles;
24+
use client::roles::RolesCommand;
2325
use client::stdcm_search_env_commands::handle_stdcm_search_env_command;
2426
use client::{
2527
ClearArgs, Client, Color, Commands, DeleteProfileSetArgs, ElectricalProfilesCommands,
@@ -245,6 +247,25 @@ async fn run() -> Result<(), Box<dyn Error + Send + Sync>> {
245247
Commands::STDCMSearchEnv(subcommand) => {
246248
handle_stdcm_search_env_command(subcommand, db_pool).await
247249
}
250+
Commands::Roles(roles_command) => match roles_command {
251+
RolesCommand::ListRoles => {
252+
roles::list_roles();
253+
Ok(())
254+
}
255+
RolesCommand::List(list_args) => {
256+
roles::list_subject_roles(list_args, db_pool.get().await?)
257+
.await
258+
.map_err(Into::into)
259+
}
260+
RolesCommand::Add(add_args) => roles::add_roles(add_args, db_pool.get().await?)
261+
.await
262+
.map_err(Into::into),
263+
RolesCommand::Remove(remove_args) => {
264+
roles::remove_roles(remove_args, db_pool.get().await?)
265+
.await
266+
.map_err(Into::into)
267+
}
268+
},
248269
}
249270
}
250271

@@ -892,6 +913,15 @@ impl CliError {
892913
}
893914
}
894915

916+
impl From<anyhow::Error> for CliError {
917+
fn from(err: anyhow::Error) -> Self {
918+
CliError {
919+
exit_code: 1,
920+
message: format!("❌ {err}"),
921+
}
922+
}
923+
}
924+
895925
#[cfg(test)]
896926
mod tests {
897927
use super::*;

0 commit comments

Comments
 (0)