1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
use crate::client::ClickUpClient;
use crate::commands::auth::resolve_token;
use crate::commands::workspace::resolve_workspace;
use crate::error::CliError;
use crate::output::OutputConfig;
use crate::Cli;
use clap::Subcommand;
#[derive(Subcommand)]
pub enum AclCommands {
/// Update ACL for an object (Enterprise only, v3)
Update {
/// Object type (e.g. task, list, folder, space)
object_type: String,
/// Object ID
object_id: String,
/// Mark the object private (true) or remove the private flag (false). Omit to leave unchanged.
#[arg(long)]
private: Option<bool>,
/// Grant a user permission. Format: USER_ID[:LEVEL] where LEVEL is read|comment|edit|create (default: read). Repeat for multiple users.
#[arg(long = "grant-user")]
grant_user: Vec<String>,
/// Grant a group permission. Same format as --grant-user but the id refers to a user group.
#[arg(long = "grant-group")]
grant_group: Vec<String>,
/// Revoke a user (sends permission_level=0). Repeat for multiple users.
#[arg(long = "revoke-user")]
revoke_user: Vec<String>,
/// Revoke a group (sends permission_level=0). Repeat for multiple groups.
#[arg(long = "revoke-group")]
revoke_group: Vec<String>,
/// Raw JSON body (overrides all other flags). Use this for advanced shapes the flags don't cover.
#[arg(long)]
body: Option<String>,
},
}
fn permission_to_level(name: &str) -> Result<u8, CliError> {
// ClickUp's documented permission_level integers on ACL entries.
// The API enum is 1, 3, 4, 5 (0 used here for revocation).
match name.to_lowercase().as_str() {
"read" | "1" => Ok(1),
"comment" | "3" => Ok(3),
"edit" | "4" => Ok(4),
"create" | "5" => Ok(5),
other => Err(CliError::ClientError {
message: format!(
"Unknown permission '{}'. Valid: read (1), comment (3), edit (4), create (5).",
other
),
status: 0,
}),
}
}
fn parse_grant(raw: &str, kind: &'static str) -> Result<serde_json::Value, CliError> {
// Format: ID[:LEVEL]. Default level is read (1).
let (id, level) = match raw.split_once(':') {
Some((id, level)) => (id.trim().to_string(), permission_to_level(level.trim())?),
None => (raw.trim().to_string(), 1u8),
};
Ok(serde_json::json!({
"kind": kind,
"id": id,
"permission_level": level,
}))
}
fn revoke_entry(id: &str, kind: &'static str) -> serde_json::Value {
// permission_level=0 to indicate removal. ClickUp's spec treats
// permission_level as optional and 0 is conventionally used to revoke.
// If a workspace requires a different shape, use --body for raw JSON.
serde_json::json!({
"kind": kind,
"id": id,
"permission_level": 0,
})
}
pub async fn execute(command: AclCommands, cli: &Cli) -> Result<(), CliError> {
let token = resolve_token(cli)?;
let client = ClickUpClient::new(&token, cli.timeout)?;
let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
match command {
AclCommands::Update {
object_type,
object_id,
private,
grant_user,
grant_group,
revoke_user,
revoke_group,
body,
} => {
let team_id = resolve_workspace(cli)?;
// ClickUp's PATCH /v3/workspaces/{ws}/{type}/{id}/acls body shape
// per the v3 OpenAPI spec:
// { "private"?: bool, "entries"?: [ { kind, id, permission_level? } ] }
// The previous implementation invented `{access_type, grant, revoke}`,
// which the endpoint does not recognise.
let request_body = if let Some(raw) = body {
serde_json::from_str(&raw).map_err(|e| CliError::ClientError {
message: format!("Invalid JSON body: {}", e),
status: 0,
})?
} else {
let mut b = serde_json::Map::new();
if let Some(p) = private {
b.insert("private".into(), serde_json::Value::Bool(p));
}
let mut entries: Vec<serde_json::Value> = Vec::new();
for raw in grant_user {
entries.push(parse_grant(&raw, "user")?);
}
for raw in grant_group {
entries.push(parse_grant(&raw, "group")?);
}
for id in revoke_user {
entries.push(revoke_entry(&id, "user"));
}
for id in revoke_group {
entries.push(revoke_entry(&id, "group"));
}
if !entries.is_empty() {
b.insert("entries".into(), serde_json::Value::Array(entries));
}
serde_json::Value::Object(b)
};
let resp = client
.patch(
&format!(
"/v3/workspaces/{}/{}/{}/acls",
team_id, object_type, object_id
),
&request_body,
)
.await?;
if cli.output == "json" {
println!("{}", serde_json::to_string_pretty(&resp).unwrap());
} else {
output.print_message(&format!("ACL updated for {} {}", object_type, object_id));
}
Ok(())
}
}
}