]> git.proxmox.com Git - proxmox-backup.git/blob - src/api2/config/sync.rs
ab4acf220deebd1b35eee2a99d5210f95a2319bb
[proxmox-backup.git] / src / api2 / config / sync.rs
1 use anyhow::{bail, Error};
2 use serde_json::Value;
3 use ::serde::{Deserialize, Serialize};
4 use hex::FromHex;
5
6 use proxmox_router::{http_bail, Router, RpcEnvironment, Permission};
7 use proxmox_schema::{api, param_bail};
8
9 use pbs_api_types::{
10 Authid, SyncJobConfig, SyncJobConfigUpdater, JOB_ID_SCHEMA, PROXMOX_CONFIG_DIGEST_SCHEMA,
11 PRIV_DATASTORE_AUDIT, PRIV_DATASTORE_BACKUP, PRIV_DATASTORE_MODIFY, PRIV_DATASTORE_PRUNE,
12 PRIV_REMOTE_AUDIT, PRIV_REMOTE_READ,
13 };
14 use pbs_config::sync;
15
16 use pbs_config::CachedUserInfo;
17
18 pub fn check_sync_job_read_access(
19 user_info: &CachedUserInfo,
20 auth_id: &Authid,
21 job: &SyncJobConfig,
22 ) -> bool {
23 let datastore_privs = user_info.lookup_privs(auth_id, &["datastore", &job.store]);
24 if datastore_privs & PRIV_DATASTORE_AUDIT == 0 {
25 return false;
26 }
27
28 let remote_privs = user_info.lookup_privs(auth_id, &["remote", &job.remote]);
29 remote_privs & PRIV_REMOTE_AUDIT != 0
30 }
31
32 // user can run the corresponding pull job
33 pub fn check_sync_job_modify_access(
34 user_info: &CachedUserInfo,
35 auth_id: &Authid,
36 job: &SyncJobConfig,
37 ) -> bool {
38 let datastore_privs = user_info.lookup_privs(auth_id, &["datastore", &job.store]);
39 if datastore_privs & PRIV_DATASTORE_BACKUP == 0 {
40 return false;
41 }
42
43 if let Some(true) = job.remove_vanished {
44 if datastore_privs & PRIV_DATASTORE_PRUNE == 0 {
45 return false;
46 }
47 }
48
49 let correct_owner = match job.owner {
50 Some(ref owner) => {
51 owner == auth_id
52 || (owner.is_token()
53 && !auth_id.is_token()
54 && owner.user() == auth_id.user())
55 },
56 // default sync owner
57 None => auth_id == Authid::root_auth_id(),
58 };
59
60 // same permission as changing ownership after syncing
61 if !correct_owner && datastore_privs & PRIV_DATASTORE_MODIFY == 0 {
62 return false;
63 }
64
65 let remote_privs = user_info.lookup_privs(auth_id, &["remote", &job.remote, &job.remote_store]);
66 remote_privs & PRIV_REMOTE_READ != 0
67 }
68
69 #[api(
70 input: {
71 properties: {},
72 },
73 returns: {
74 description: "List configured jobs.",
75 type: Array,
76 items: { type: SyncJobConfig },
77 },
78 access: {
79 description: "Limited to sync job entries where user has Datastore.Audit on target datastore, and Remote.Audit on source remote.",
80 permission: &Permission::Anybody,
81 },
82 )]
83 /// List all sync jobs
84 pub fn list_sync_jobs(
85 _param: Value,
86 mut rpcenv: &mut dyn RpcEnvironment,
87 ) -> Result<Vec<SyncJobConfig>, Error> {
88 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
89 let user_info = CachedUserInfo::new()?;
90
91 let (config, digest) = sync::config()?;
92
93 let list = config.convert_to_typed_array("sync")?;
94
95 rpcenv["digest"] = hex::encode(&digest).into();
96
97 let list = list
98 .into_iter()
99 .filter(|sync_job| check_sync_job_read_access(&user_info, &auth_id, sync_job))
100 .collect();
101 Ok(list)
102 }
103
104 #[api(
105 protected: true,
106 input: {
107 properties: {
108 config: {
109 type: SyncJobConfig,
110 flatten: true,
111 },
112 },
113 },
114 access: {
115 description: "User needs Datastore.Backup on target datastore, and Remote.Read on source remote. Additionally, remove_vanished requires Datastore.Prune, and any owner other than the user themselves requires Datastore.Modify",
116 permission: &Permission::Anybody,
117 },
118 )]
119 /// Create a new sync job.
120 pub fn create_sync_job(
121 config: SyncJobConfig,
122 rpcenv: &mut dyn RpcEnvironment,
123 ) -> Result<(), Error> {
124 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
125 let user_info = CachedUserInfo::new()?;
126
127 let _lock = sync::lock_config()?;
128
129 if !check_sync_job_modify_access(&user_info, &auth_id, &config) {
130 bail!("permission check failed");
131 }
132
133 let (mut section_config, _digest) = sync::config()?;
134
135 if section_config.sections.get(&config.id).is_some() {
136 param_bail!("id", "job '{}' already exists.", config.id);
137 }
138
139 section_config.set_data(&config.id, "sync", &config)?;
140
141 sync::save_config(&section_config)?;
142
143 crate::server::jobstate::create_state_file("syncjob", &config.id)?;
144
145 Ok(())
146 }
147
148 #[api(
149 input: {
150 properties: {
151 id: {
152 schema: JOB_ID_SCHEMA,
153 },
154 },
155 },
156 returns: { type: SyncJobConfig },
157 access: {
158 description: "Limited to sync job entries where user has Datastore.Audit on target datastore, and Remote.Audit on source remote.",
159 permission: &Permission::Anybody,
160 },
161 )]
162 /// Read a sync job configuration.
163 pub fn read_sync_job(
164 id: String,
165 mut rpcenv: &mut dyn RpcEnvironment,
166 ) -> Result<SyncJobConfig, Error> {
167 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
168 let user_info = CachedUserInfo::new()?;
169
170 let (config, digest) = sync::config()?;
171
172 let sync_job = config.lookup("sync", &id)?;
173 if !check_sync_job_read_access(&user_info, &auth_id, &sync_job) {
174 bail!("permission check failed");
175 }
176
177 rpcenv["digest"] = hex::encode(&digest).into();
178
179 Ok(sync_job)
180 }
181
182 #[api()]
183 #[derive(Serialize, Deserialize)]
184 #[serde(rename_all="kebab-case")]
185 #[allow(non_camel_case_types)]
186 /// Deletable property name
187 pub enum DeletableProperty {
188 /// Delete the owner property.
189 owner,
190 /// Delete the comment property.
191 comment,
192 /// Delete the job schedule.
193 schedule,
194 /// Delete the remove-vanished flag.
195 remove_vanished,
196 /// Delete the group_filter property.
197 group_filter,
198 /// Delete the rate_in property.
199 rate_in,
200 /// Delete the burst_in property.
201 burst_in,
202 /// Delete the rate_out property.
203 rate_out,
204 /// Delete the burst_out property.
205 burst_out,
206 }
207
208 #[api(
209 protected: true,
210 input: {
211 properties: {
212 id: {
213 schema: JOB_ID_SCHEMA,
214 },
215 update: {
216 type: SyncJobConfigUpdater,
217 flatten: true,
218 },
219 delete: {
220 description: "List of properties to delete.",
221 type: Array,
222 optional: true,
223 items: {
224 type: DeletableProperty,
225 }
226 },
227 digest: {
228 optional: true,
229 schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
230 },
231 },
232 },
233 access: {
234 permission: &Permission::Anybody,
235 description: "User needs Datastore.Backup on target datastore, and Remote.Read on source remote. Additionally, remove_vanished requires Datastore.Prune, and any owner other than the user themselves requires Datastore.Modify",
236 },
237 )]
238 /// Update sync job config.
239 #[allow(clippy::too_many_arguments)]
240 pub fn update_sync_job(
241 id: String,
242 update: SyncJobConfigUpdater,
243 delete: Option<Vec<DeletableProperty>>,
244 digest: Option<String>,
245 rpcenv: &mut dyn RpcEnvironment,
246 ) -> Result<(), Error> {
247 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
248 let user_info = CachedUserInfo::new()?;
249
250 let _lock = sync::lock_config()?;
251
252 let (mut config, expected_digest) = sync::config()?;
253
254 if let Some(ref digest) = digest {
255 let digest = <[u8; 32]>::from_hex(digest)?;
256 crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
257 }
258
259 let mut data: SyncJobConfig = config.lookup("sync", &id)?;
260
261 if let Some(delete) = delete {
262 for delete_prop in delete {
263 match delete_prop {
264 DeletableProperty::owner => { data.owner = None; },
265 DeletableProperty::comment => { data.comment = None; },
266 DeletableProperty::schedule => { data.schedule = None; },
267 DeletableProperty::remove_vanished => { data.remove_vanished = None; },
268 DeletableProperty::group_filter => { data.group_filter = None; },
269 DeletableProperty::rate_in => { data.limit.rate_in = None; },
270 DeletableProperty::rate_out => { data.limit.rate_out = None; },
271 DeletableProperty::burst_in => { data.limit.burst_in = None; },
272 DeletableProperty::burst_out => { data.limit.burst_out = None; },
273 }
274 }
275 }
276
277 if let Some(comment) = update.comment {
278 let comment = comment.trim().to_string();
279 if comment.is_empty() {
280 data.comment = None;
281 } else {
282 data.comment = Some(comment);
283 }
284 }
285
286 if let Some(store) = update.store { data.store = store; }
287 if let Some(remote) = update.remote { data.remote = remote; }
288 if let Some(remote_store) = update.remote_store { data.remote_store = remote_store; }
289 if let Some(owner) = update.owner { data.owner = Some(owner); }
290 if let Some(group_filter) = update.group_filter { data.group_filter = Some(group_filter); }
291
292 if update.limit.rate_in.is_some() {
293 data.limit.rate_in = update.limit.rate_in;
294 }
295
296 if update.limit.rate_out.is_some() {
297 data.limit.rate_out = update.limit.rate_out;
298 }
299
300 if update.limit.burst_in.is_some() {
301 data.limit.burst_in = update.limit.burst_in;
302 }
303
304 if update.limit.burst_out.is_some() {
305 data.limit.burst_out = update.limit.burst_out;
306 }
307
308 let schedule_changed = data.schedule != update.schedule;
309 if update.schedule.is_some() { data.schedule = update.schedule; }
310 if update.remove_vanished.is_some() { data.remove_vanished = update.remove_vanished; }
311
312 if !check_sync_job_modify_access(&user_info, &auth_id, &data) {
313 bail!("permission check failed");
314 }
315
316 config.set_data(&id, "sync", &data)?;
317
318 sync::save_config(&config)?;
319
320 if schedule_changed {
321 crate::server::jobstate::update_job_last_run_time("syncjob", &id)?;
322 }
323
324 Ok(())
325 }
326
327 #[api(
328 protected: true,
329 input: {
330 properties: {
331 id: {
332 schema: JOB_ID_SCHEMA,
333 },
334 digest: {
335 optional: true,
336 schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
337 },
338 },
339 },
340 access: {
341 permission: &Permission::Anybody,
342 description: "User needs Datastore.Backup on target datastore, and Remote.Read on source remote. Additionally, remove_vanished requires Datastore.Prune, and any owner other than the user themselves requires Datastore.Modify",
343 },
344 )]
345 /// Remove a sync job configuration
346 pub fn delete_sync_job(
347 id: String,
348 digest: Option<String>,
349 rpcenv: &mut dyn RpcEnvironment,
350 ) -> Result<(), Error> {
351 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
352 let user_info = CachedUserInfo::new()?;
353
354 let _lock = sync::lock_config()?;
355
356 let (mut config, expected_digest) = sync::config()?;
357
358 if let Some(ref digest) = digest {
359 let digest = <[u8; 32]>::from_hex(digest)?;
360 crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
361 }
362
363 match config.lookup("sync", &id) {
364 Ok(job) => {
365 if !check_sync_job_modify_access(&user_info, &auth_id, &job) {
366 bail!("permission check failed");
367 }
368 config.sections.remove(&id);
369 },
370 Err(_) => { http_bail!(NOT_FOUND, "job '{}' does not exist.", id) },
371 };
372
373 sync::save_config(&config)?;
374
375 crate::server::jobstate::remove_state_file("syncjob", &id)?;
376
377 Ok(())
378 }
379
380 const ITEM_ROUTER: Router = Router::new()
381 .get(&API_METHOD_READ_SYNC_JOB)
382 .put(&API_METHOD_UPDATE_SYNC_JOB)
383 .delete(&API_METHOD_DELETE_SYNC_JOB);
384
385 pub const ROUTER: Router = Router::new()
386 .get(&API_METHOD_LIST_SYNC_JOBS)
387 .post(&API_METHOD_CREATE_SYNC_JOB)
388 .match_all("id", &ITEM_ROUTER);
389
390
391 #[test]
392 fn sync_job_access_test() -> Result<(), Error> {
393 let (user_cfg, _) = pbs_config::user::test_cfg_from_str(r###"
394 user: noperm@pbs
395
396 user: read@pbs
397
398 user: write@pbs
399
400 "###).expect("test user.cfg is not parsable");
401 let acl_tree = pbs_config::acl::AclTree::from_raw(r###"
402 acl:1:/datastore/localstore1:read@pbs,write@pbs:DatastoreAudit
403 acl:1:/datastore/localstore1:write@pbs:DatastoreBackup
404 acl:1:/datastore/localstore2:write@pbs:DatastorePowerUser
405 acl:1:/datastore/localstore3:write@pbs:DatastoreAdmin
406 acl:1:/remote/remote1:read@pbs,write@pbs:RemoteAudit
407 acl:1:/remote/remote1/remotestore1:write@pbs:RemoteSyncOperator
408 "###).expect("test acl.cfg is not parsable");
409
410 let user_info = CachedUserInfo::test_new(user_cfg, acl_tree);
411
412 let root_auth_id = Authid::root_auth_id();
413
414 let no_perm_auth_id: Authid = "noperm@pbs".parse()?;
415 let read_auth_id: Authid = "read@pbs".parse()?;
416 let write_auth_id: Authid = "write@pbs".parse()?;
417
418 let mut job = SyncJobConfig {
419 id: "regular".to_string(),
420 remote: "remote0".to_string(),
421 remote_store: "remotestore1".to_string(),
422 store: "localstore0".to_string(),
423 owner: Some(write_auth_id.clone()),
424 comment: None,
425 remove_vanished: None,
426 group_filter: None,
427 schedule: None,
428 limit: pbs_api_types::RateLimitConfig::default(), // no limit
429 };
430
431 // should work without ACLs
432 assert_eq!(check_sync_job_read_access(&user_info, root_auth_id, &job), true);
433 assert_eq!(check_sync_job_modify_access(&user_info, root_auth_id, &job), true);
434
435 // user without permissions must fail
436 assert_eq!(check_sync_job_read_access(&user_info, &no_perm_auth_id, &job), false);
437 assert_eq!(check_sync_job_modify_access(&user_info, &no_perm_auth_id, &job), false);
438
439 // reading without proper read permissions on either remote or local must fail
440 assert_eq!(check_sync_job_read_access(&user_info, &read_auth_id, &job), false);
441
442 // reading without proper read permissions on local end must fail
443 job.remote = "remote1".to_string();
444 assert_eq!(check_sync_job_read_access(&user_info, &read_auth_id, &job), false);
445
446 // reading without proper read permissions on remote end must fail
447 job.remote = "remote0".to_string();
448 job.store = "localstore1".to_string();
449 assert_eq!(check_sync_job_read_access(&user_info, &read_auth_id, &job), false);
450
451 // writing without proper write permissions on either end must fail
452 job.store = "localstore0".to_string();
453 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), false);
454
455 // writing without proper write permissions on local end must fail
456 job.remote = "remote1".to_string();
457
458 // writing without proper write permissions on remote end must fail
459 job.remote = "remote0".to_string();
460 job.store = "localstore1".to_string();
461 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), false);
462
463 // reset remote to one where users have access
464 job.remote = "remote1".to_string();
465
466 // user with read permission can only read, but not modify/run
467 assert_eq!(check_sync_job_read_access(&user_info, &read_auth_id, &job), true);
468 job.owner = Some(read_auth_id.clone());
469 assert_eq!(check_sync_job_modify_access(&user_info, &read_auth_id, &job), false);
470 job.owner = None;
471 assert_eq!(check_sync_job_modify_access(&user_info, &read_auth_id, &job), false);
472 job.owner = Some(write_auth_id.clone());
473 assert_eq!(check_sync_job_modify_access(&user_info, &read_auth_id, &job), false);
474
475 // user with simple write permission can modify/run
476 assert_eq!(check_sync_job_read_access(&user_info, &write_auth_id, &job), true);
477 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), true);
478
479 // but can't modify/run with deletion
480 job.remove_vanished = Some(true);
481 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), false);
482
483 // unless they have Datastore.Prune as well
484 job.store = "localstore2".to_string();
485 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), true);
486
487 // changing owner is not possible
488 job.owner = Some(read_auth_id.clone());
489 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), false);
490
491 // also not to the default 'root@pam'
492 job.owner = None;
493 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), false);
494
495 // unless they have Datastore.Modify as well
496 job.store = "localstore3".to_string();
497 job.owner = Some(read_auth_id);
498 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), true);
499 job.owner = None;
500 assert_eq!(check_sync_job_modify_access(&user_info, &write_auth_id, &job), true);
501
502 Ok(())
503 }