]> git.proxmox.com Git - proxmox-backup.git/blame - src/api2/config/sync.rs
clippy 1.65 fixes
[proxmox-backup.git] / src / api2 / config / sync.rs
CommitLineData
b1d4edc7 1use ::serde::{Deserialize, Serialize};
dc7a5b34 2use anyhow::{bail, Error};
25877d05 3use hex::FromHex;
dc7a5b34 4use serde_json::Value;
b1d4edc7 5
dc7a5b34 6use proxmox_router::{http_bail, Permission, Router, RpcEnvironment};
8d6425aa 7use proxmox_schema::{api, param_bail};
b1d4edc7 8
e3619d41 9use pbs_api_types::{
dc7a5b34
TL
10 Authid, SyncJobConfig, SyncJobConfigUpdater, JOB_ID_SCHEMA, PRIV_DATASTORE_AUDIT,
11 PRIV_DATASTORE_BACKUP, PRIV_DATASTORE_MODIFY, PRIV_DATASTORE_PRUNE, PRIV_REMOTE_AUDIT,
12 PRIV_REMOTE_READ, PROXMOX_CONFIG_DIGEST_SCHEMA,
e3619d41 13};
a4e5a0fc 14use pbs_config::sync;
59af9ca9 15
ba3d7e19 16use pbs_config::CachedUserInfo;
b1d4edc7 17
59af9ca9
FG
18pub fn check_sync_job_read_access(
19 user_info: &CachedUserInfo,
20 auth_id: &Authid,
21 job: &SyncJobConfig,
22) -> bool {
abd82485 23 let ns_anchor_privs = user_info.lookup_privs(auth_id, &job.acl_path());
83e30003 24 if ns_anchor_privs & PRIV_DATASTORE_AUDIT == 0 {
59af9ca9
FG
25 return false;
26 }
27
9a37bd6c 28 let remote_privs = user_info.lookup_privs(auth_id, &["remote", &job.remote]);
59af9ca9
FG
29 remote_privs & PRIV_REMOTE_AUDIT != 0
30}
aa64e065 31
c06c1b4b
FG
32/// checks whether user can run the corresponding pull job
33///
34/// namespace creation/deletion ACL and backup group ownership checks happen in the pull code directly.
35/// remote side checks/filters remote datastore/namespace/group access.
59af9ca9
FG
36pub fn check_sync_job_modify_access(
37 user_info: &CachedUserInfo,
38 auth_id: &Authid,
39 job: &SyncJobConfig,
40) -> bool {
abd82485 41 let ns_anchor_privs = user_info.lookup_privs(auth_id, &job.acl_path());
83e30003 42 if ns_anchor_privs & PRIV_DATASTORE_BACKUP == 0 {
59af9ca9
FG
43 return false;
44 }
45
46 if let Some(true) = job.remove_vanished {
83e30003 47 if ns_anchor_privs & PRIV_DATASTORE_PRUNE == 0 {
59af9ca9
FG
48 return false;
49 }
50 }
51
52 let correct_owner = match job.owner {
53 Some(ref owner) => {
54 owner == auth_id
dc7a5b34
TL
55 || (owner.is_token() && !auth_id.is_token() && owner.user() == auth_id.user())
56 }
59af9ca9 57 // default sync owner
ad54df31 58 None => auth_id == Authid::root_auth_id(),
59af9ca9
FG
59 };
60
61 // same permission as changing ownership after syncing
83e30003 62 if !correct_owner && ns_anchor_privs & PRIV_DATASTORE_MODIFY == 0 {
59af9ca9
FG
63 return false;
64 }
65
9a37bd6c 66 let remote_privs = user_info.lookup_privs(auth_id, &["remote", &job.remote, &job.remote_store]);
59af9ca9
FG
67 remote_privs & PRIV_REMOTE_READ != 0
68}
b1d4edc7
DM
69
70#[api(
71 input: {
72 properties: {},
73 },
74 returns: {
75 description: "List configured jobs.",
76 type: Array,
e3619d41 77 items: { type: SyncJobConfig },
b1d4edc7 78 },
59af9ca9
FG
79 access: {
80 description: "Limited to sync job entries where user has Datastore.Audit on target datastore, and Remote.Audit on source remote.",
81 permission: &Permission::Anybody,
82 },
b1d4edc7 83)]
6f652b1b
DM
84/// List all sync jobs
85pub fn list_sync_jobs(
b1d4edc7 86 _param: Value,
41c1a179 87 rpcenv: &mut dyn RpcEnvironment,
6f652b1b 88) -> Result<Vec<SyncJobConfig>, Error> {
59af9ca9
FG
89 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
90 let user_info = CachedUserInfo::new()?;
b1d4edc7 91
6f652b1b 92 let (config, digest) = sync::config()?;
b1d4edc7 93
6f652b1b 94 let list = config.convert_to_typed_array("sync")?;
b1d4edc7 95
16f6766a 96 rpcenv["digest"] = hex::encode(digest).into();
b1d4edc7 97
59af9ca9
FG
98 let list = list
99 .into_iter()
9a37bd6c 100 .filter(|sync_job| check_sync_job_read_access(&user_info, &auth_id, sync_job))
59af9ca9 101 .collect();
dc7a5b34 102 Ok(list)
b1d4edc7
DM
103}
104
105#[api(
106 protected: true,
107 input: {
108 properties: {
5bd77f00
DM
109 config: {
110 type: SyncJobConfig,
111 flatten: true,
b1d4edc7
DM
112 },
113 },
114 },
59af9ca9
FG
115 access: {
116 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",
117 permission: &Permission::Anybody,
118 },
b1d4edc7 119)]
6f652b1b 120/// Create a new sync job.
59af9ca9 121pub fn create_sync_job(
5bd77f00 122 config: SyncJobConfig,
59af9ca9
FG
123 rpcenv: &mut dyn RpcEnvironment,
124) -> Result<(), Error> {
125 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
126 let user_info = CachedUserInfo::new()?;
b1d4edc7 127
a4e5a0fc 128 let _lock = sync::lock_config()?;
b1d4edc7 129
5bd77f00 130 if !check_sync_job_modify_access(&user_info, &auth_id, &config) {
59af9ca9
FG
131 bail!("permission check failed");
132 }
b1d4edc7 133
66abc4cb
FG
134 if let Some(max_depth) = config.max_depth {
135 if let Some(ref ns) = config.ns {
136 ns.check_max_depth(max_depth)?;
137 }
138 if let Some(ref ns) = config.remote_ns {
139 ns.check_max_depth(max_depth)?;
140 }
141 }
142
5bd77f00 143 let (mut section_config, _digest) = sync::config()?;
b1d4edc7 144
5bd77f00 145 if section_config.sections.get(&config.id).is_some() {
8d6425aa 146 param_bail!("id", "job '{}' already exists.", config.id);
b1d4edc7
DM
147 }
148
5bd77f00 149 section_config.set_data(&config.id, "sync", &config)?;
b1d4edc7 150
5bd77f00 151 sync::save_config(&section_config)?;
b1d4edc7 152
5bd77f00 153 crate::server::jobstate::create_state_file("syncjob", &config.id)?;
93bb51fe 154
b1d4edc7
DM
155 Ok(())
156}
157
158#[api(
159 input: {
160 properties: {
161 id: {
162 schema: JOB_ID_SCHEMA,
163 },
164 },
165 },
e3619d41 166 returns: { type: SyncJobConfig },
59af9ca9
FG
167 access: {
168 description: "Limited to sync job entries where user has Datastore.Audit on target datastore, and Remote.Audit on source remote.",
169 permission: &Permission::Anybody,
170 },
b1d4edc7 171)]
6f652b1b 172/// Read a sync job configuration.
41c1a179 173pub fn read_sync_job(id: String, rpcenv: &mut dyn RpcEnvironment) -> Result<SyncJobConfig, Error> {
59af9ca9
FG
174 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
175 let user_info = CachedUserInfo::new()?;
176
6f652b1b 177 let (config, digest) = sync::config()?;
b1d4edc7 178
6f652b1b 179 let sync_job = config.lookup("sync", &id)?;
59af9ca9
FG
180 if !check_sync_job_read_access(&user_info, &auth_id, &sync_job) {
181 bail!("permission check failed");
182 }
183
16f6766a 184 rpcenv["digest"] = hex::encode(digest).into();
b1d4edc7 185
6f652b1b 186 Ok(sync_job)
b1d4edc7
DM
187}
188
189#[api()]
190#[derive(Serialize, Deserialize)]
dc7a5b34 191#[serde(rename_all = "kebab-case")]
b1d4edc7
DM
192#[allow(non_camel_case_types)]
193/// Deletable property name
194pub enum DeletableProperty {
f1694b06
FG
195 /// Delete the owner property.
196 owner,
b1d4edc7
DM
197 /// Delete the comment property.
198 comment,
199 /// Delete the job schedule.
200 schedule,
201 /// Delete the remove-vanished flag.
202 remove_vanished,
062edce2
TL
203 /// Delete the group_filter property.
204 group_filter,
6eb756bc
DM
205 /// Delete the rate_in property.
206 rate_in,
207 /// Delete the burst_in property.
208 burst_in,
209 /// Delete the rate_out property.
210 rate_out,
211 /// Delete the burst_out property.
212 burst_out,
c06c1b4b
FG
213 /// Delete the ns property,
214 ns,
215 /// Delete the remote_ns property,
216 remote_ns,
b9310489
FG
217 /// Delete the max_depth property,
218 max_depth,
b1d4edc7
DM
219}
220
221#[api(
222 protected: true,
223 input: {
224 properties: {
225 id: {
226 schema: JOB_ID_SCHEMA,
227 },
5bd77f00
DM
228 update: {
229 type: SyncJobConfigUpdater,
230 flatten: true,
b1d4edc7
DM
231 },
232 delete: {
233 description: "List of properties to delete.",
234 type: Array,
235 optional: true,
236 items: {
237 type: DeletableProperty,
238 }
239 },
240 digest: {
241 optional: true,
242 schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
243 },
244 },
245 },
59af9ca9
FG
246 access: {
247 permission: &Permission::Anybody,
248 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",
249 },
b1d4edc7 250)]
6f652b1b 251/// Update sync job config.
367c0ff7 252#[allow(clippy::too_many_arguments)]
6f652b1b 253pub fn update_sync_job(
b1d4edc7 254 id: String,
5bd77f00 255 update: SyncJobConfigUpdater,
b1d4edc7
DM
256 delete: Option<Vec<DeletableProperty>>,
257 digest: Option<String>,
59af9ca9 258 rpcenv: &mut dyn RpcEnvironment,
b1d4edc7 259) -> Result<(), Error> {
59af9ca9
FG
260 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
261 let user_info = CachedUserInfo::new()?;
b1d4edc7 262
a4e5a0fc 263 let _lock = sync::lock_config()?;
b1d4edc7 264
6f652b1b 265 let (mut config, expected_digest) = sync::config()?;
b1d4edc7
DM
266
267 if let Some(ref digest) = digest {
25877d05 268 let digest = <[u8; 32]>::from_hex(digest)?;
b1d4edc7
DM
269 crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
270 }
271
e3619d41 272 let mut data: SyncJobConfig = config.lookup("sync", &id)?;
b1d4edc7 273
dc7a5b34 274 if let Some(delete) = delete {
b1d4edc7
DM
275 for delete_prop in delete {
276 match delete_prop {
dc7a5b34
TL
277 DeletableProperty::owner => {
278 data.owner = None;
279 }
280 DeletableProperty::comment => {
281 data.comment = None;
282 }
283 DeletableProperty::schedule => {
284 data.schedule = None;
285 }
286 DeletableProperty::remove_vanished => {
287 data.remove_vanished = None;
288 }
289 DeletableProperty::group_filter => {
290 data.group_filter = None;
291 }
292 DeletableProperty::rate_in => {
293 data.limit.rate_in = None;
294 }
295 DeletableProperty::rate_out => {
296 data.limit.rate_out = None;
297 }
298 DeletableProperty::burst_in => {
299 data.limit.burst_in = None;
300 }
301 DeletableProperty::burst_out => {
302 data.limit.burst_out = None;
303 }
c06c1b4b
FG
304 DeletableProperty::ns => {
305 data.ns = None;
306 }
307 DeletableProperty::remote_ns => {
308 data.remote_ns = None;
309 }
b9310489
FG
310 DeletableProperty::max_depth => {
311 data.max_depth = None;
312 }
b1d4edc7
DM
313 }
314 }
315 }
316
5bd77f00 317 if let Some(comment) = update.comment {
b1d4edc7
DM
318 let comment = comment.trim().to_string();
319 if comment.is_empty() {
320 data.comment = None;
321 } else {
322 data.comment = Some(comment);
323 }
324 }
325
dc7a5b34
TL
326 if let Some(store) = update.store {
327 data.store = store;
328 }
c06c1b4b 329 if let Some(ns) = update.ns {
c06c1b4b
FG
330 data.ns = Some(ns);
331 }
dc7a5b34
TL
332 if let Some(remote) = update.remote {
333 data.remote = remote;
334 }
335 if let Some(remote_store) = update.remote_store {
336 data.remote_store = remote_store;
337 }
c06c1b4b 338 if let Some(remote_ns) = update.remote_ns {
c06c1b4b
FG
339 data.remote_ns = Some(remote_ns);
340 }
dc7a5b34
TL
341 if let Some(owner) = update.owner {
342 data.owner = Some(owner);
343 }
344 if let Some(group_filter) = update.group_filter {
345 data.group_filter = Some(group_filter);
346 }
6eb756bc
DM
347
348 if update.limit.rate_in.is_some() {
349 data.limit.rate_in = update.limit.rate_in;
350 }
351
352 if update.limit.rate_out.is_some() {
353 data.limit.rate_out = update.limit.rate_out;
354 }
355
356 if update.limit.burst_in.is_some() {
357 data.limit.burst_in = update.limit.burst_in;
358 }
359
360 if update.limit.burst_out.is_some() {
361 data.limit.burst_out = update.limit.burst_out;
362 }
f1694b06 363
5bd77f00 364 let schedule_changed = data.schedule != update.schedule;
dc7a5b34
TL
365 if update.schedule.is_some() {
366 data.schedule = update.schedule;
367 }
368 if update.remove_vanished.is_some() {
369 data.remove_vanished = update.remove_vanished;
370 }
c06c1b4b 371 if let Some(max_depth) = update.max_depth {
66abc4cb
FG
372 data.max_depth = Some(max_depth);
373 }
374
375 if let Some(max_depth) = data.max_depth {
c06c1b4b 376 if let Some(ref ns) = data.ns {
66abc4cb 377 ns.check_max_depth(max_depth)?;
c06c1b4b
FG
378 }
379 if let Some(ref ns) = data.remote_ns {
66abc4cb 380 ns.check_max_depth(max_depth)?;
c06c1b4b 381 }
c06c1b4b 382 }
b1d4edc7 383
59af9ca9
FG
384 if !check_sync_job_modify_access(&user_info, &auth_id, &data) {
385 bail!("permission check failed");
386 }
387
6f652b1b 388 config.set_data(&id, "sync", &data)?;
b1d4edc7 389
6f652b1b 390 sync::save_config(&config)?;
b1d4edc7 391
951fe0cb 392 if schedule_changed {
37a634f5 393 crate::server::jobstate::update_job_last_run_time("syncjob", &id)?;
951fe0cb
DC
394 }
395
b1d4edc7
DM
396 Ok(())
397}
398
399#[api(
400 protected: true,
401 input: {
402 properties: {
403 id: {
404 schema: JOB_ID_SCHEMA,
405 },
406 digest: {
407 optional: true,
408 schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
409 },
410 },
411 },
59af9ca9
FG
412 access: {
413 permission: &Permission::Anybody,
414 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",
415 },
b1d4edc7 416)]
6f652b1b 417/// Remove a sync job configuration
59af9ca9
FG
418pub fn delete_sync_job(
419 id: String,
420 digest: Option<String>,
421 rpcenv: &mut dyn RpcEnvironment,
422) -> Result<(), Error> {
423 let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
424 let user_info = CachedUserInfo::new()?;
b1d4edc7 425
a4e5a0fc 426 let _lock = sync::lock_config()?;
b1d4edc7 427
6f652b1b 428 let (mut config, expected_digest) = sync::config()?;
b1d4edc7
DM
429
430 if let Some(ref digest) = digest {
25877d05 431 let digest = <[u8; 32]>::from_hex(digest)?;
b1d4edc7
DM
432 crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
433 }
434
59af9ca9
FG
435 match config.lookup("sync", &id) {
436 Ok(job) => {
437 if !check_sync_job_modify_access(&user_info, &auth_id, &job) {
438 bail!("permission check failed");
439 }
440 config.sections.remove(&id);
dc7a5b34
TL
441 }
442 Err(_) => {
443 http_bail!(NOT_FOUND, "job '{}' does not exist.", id)
444 }
59af9ca9 445 };
b1d4edc7 446
6f652b1b 447 sync::save_config(&config)?;
b1d4edc7 448
1298618a 449 crate::server::jobstate::remove_state_file("syncjob", &id)?;
664d8a27 450
b1d4edc7
DM
451 Ok(())
452}
453
454const ITEM_ROUTER: Router = Router::new()
6f652b1b
DM
455 .get(&API_METHOD_READ_SYNC_JOB)
456 .put(&API_METHOD_UPDATE_SYNC_JOB)
457 .delete(&API_METHOD_DELETE_SYNC_JOB);
b1d4edc7
DM
458
459pub const ROUTER: Router = Router::new()
6f652b1b
DM
460 .get(&API_METHOD_LIST_SYNC_JOBS)
461 .post(&API_METHOD_CREATE_SYNC_JOB)
426c1e35 462 .match_all("id", &ITEM_ROUTER);
aa64e065 463
aa64e065
FG
464#[test]
465fn sync_job_access_test() -> Result<(), Error> {
dc7a5b34
TL
466 let (user_cfg, _) = pbs_config::user::test_cfg_from_str(
467 r###"
aa64e065
FG
468user: noperm@pbs
469
470user: read@pbs
471
472user: write@pbs
473
dc7a5b34
TL
474"###,
475 )
476 .expect("test user.cfg is not parsable");
477 let acl_tree = pbs_config::acl::AclTree::from_raw(
478 r###"
aa64e065
FG
479acl:1:/datastore/localstore1:read@pbs,write@pbs:DatastoreAudit
480acl:1:/datastore/localstore1:write@pbs:DatastoreBackup
481acl:1:/datastore/localstore2:write@pbs:DatastorePowerUser
482acl:1:/datastore/localstore3:write@pbs:DatastoreAdmin
483acl:1:/remote/remote1:read@pbs,write@pbs:RemoteAudit
484acl:1:/remote/remote1/remotestore1:write@pbs:RemoteSyncOperator
dc7a5b34
TL
485"###,
486 )
487 .expect("test acl.cfg is not parsable");
aa64e065
FG
488
489 let user_info = CachedUserInfo::test_new(user_cfg, acl_tree);
490
491 let root_auth_id = Authid::root_auth_id();
492
493 let no_perm_auth_id: Authid = "noperm@pbs".parse()?;
494 let read_auth_id: Authid = "read@pbs".parse()?;
495 let write_auth_id: Authid = "write@pbs".parse()?;
496
497 let mut job = SyncJobConfig {
498 id: "regular".to_string(),
499 remote: "remote0".to_string(),
500 remote_store: "remotestore1".to_string(),
c06c1b4b 501 remote_ns: None,
aa64e065 502 store: "localstore0".to_string(),
c06c1b4b 503 ns: None,
aa64e065
FG
504 owner: Some(write_auth_id.clone()),
505 comment: None,
506 remove_vanished: None,
b9310489 507 max_depth: None,
062edce2 508 group_filter: None,
aa64e065 509 schedule: None,
7a368331 510 limit: pbs_api_types::RateLimitConfig::default(), // no limit
aa64e065
FG
511 };
512
513 // should work without ACLs
237beaed
FG
514 assert!(check_sync_job_read_access(&user_info, root_auth_id, &job));
515 assert!(check_sync_job_modify_access(&user_info, root_auth_id, &job));
aa64e065
FG
516
517 // user without permissions must fail
237beaed
FG
518 assert!(!check_sync_job_read_access(
519 &user_info,
520 &no_perm_auth_id,
521 &job
522 ));
523 assert!(!check_sync_job_modify_access(
524 &user_info,
525 &no_perm_auth_id,
526 &job
527 ));
aa64e065
FG
528
529 // reading without proper read permissions on either remote or local must fail
237beaed 530 assert!(!check_sync_job_read_access(&user_info, &read_auth_id, &job));
aa64e065
FG
531
532 // reading without proper read permissions on local end must fail
533 job.remote = "remote1".to_string();
237beaed 534 assert!(!check_sync_job_read_access(&user_info, &read_auth_id, &job));
aa64e065
FG
535
536 // reading without proper read permissions on remote end must fail
537 job.remote = "remote0".to_string();
538 job.store = "localstore1".to_string();
237beaed 539 assert!(!check_sync_job_read_access(&user_info, &read_auth_id, &job));
aa64e065
FG
540
541 // writing without proper write permissions on either end must fail
542 job.store = "localstore0".to_string();
237beaed
FG
543 assert!(!check_sync_job_modify_access(
544 &user_info,
545 &write_auth_id,
546 &job
547 ));
aa64e065
FG
548
549 // writing without proper write permissions on local end must fail
550 job.remote = "remote1".to_string();
551
552 // writing without proper write permissions on remote end must fail
553 job.remote = "remote0".to_string();
554 job.store = "localstore1".to_string();
237beaed
FG
555 assert!(!check_sync_job_modify_access(
556 &user_info,
557 &write_auth_id,
558 &job
559 ));
aa64e065
FG
560
561 // reset remote to one where users have access
562 job.remote = "remote1".to_string();
563
564 // user with read permission can only read, but not modify/run
237beaed 565 assert!(check_sync_job_read_access(&user_info, &read_auth_id, &job));
aa64e065 566 job.owner = Some(read_auth_id.clone());
237beaed
FG
567 assert!(!check_sync_job_modify_access(
568 &user_info,
569 &read_auth_id,
570 &job
571 ));
aa64e065 572 job.owner = None;
237beaed
FG
573 assert!(!check_sync_job_modify_access(
574 &user_info,
575 &read_auth_id,
576 &job
577 ));
aa64e065 578 job.owner = Some(write_auth_id.clone());
237beaed
FG
579 assert!(!check_sync_job_modify_access(
580 &user_info,
581 &read_auth_id,
582 &job
583 ));
aa64e065
FG
584
585 // user with simple write permission can modify/run
237beaed
FG
586 assert!(check_sync_job_read_access(&user_info, &write_auth_id, &job));
587 assert!(check_sync_job_modify_access(
588 &user_info,
589 &write_auth_id,
590 &job
591 ));
aa64e065
FG
592
593 // but can't modify/run with deletion
594 job.remove_vanished = Some(true);
237beaed
FG
595 assert!(!check_sync_job_modify_access(
596 &user_info,
597 &write_auth_id,
598 &job
599 ));
aa64e065
FG
600
601 // unless they have Datastore.Prune as well
602 job.store = "localstore2".to_string();
237beaed
FG
603 assert!(check_sync_job_modify_access(
604 &user_info,
605 &write_auth_id,
606 &job
607 ));
aa64e065
FG
608
609 // changing owner is not possible
610 job.owner = Some(read_auth_id.clone());
237beaed
FG
611 assert!(!check_sync_job_modify_access(
612 &user_info,
613 &write_auth_id,
614 &job
615 ));
aa64e065 616
ad54df31 617 // also not to the default 'root@pam'
aa64e065 618 job.owner = None;
237beaed
FG
619 assert!(!check_sync_job_modify_access(
620 &user_info,
621 &write_auth_id,
622 &job
623 ));
aa64e065
FG
624
625 // unless they have Datastore.Modify as well
626 job.store = "localstore3".to_string();
44288184 627 job.owner = Some(read_auth_id);
237beaed
FG
628 assert!(check_sync_job_modify_access(
629 &user_info,
630 &write_auth_id,
631 &job
632 ));
aa64e065 633 job.owner = None;
237beaed
FG
634 assert!(check_sync_job_modify_access(
635 &user_info,
636 &write_auth_id,
637 &job
638 ));
aa64e065
FG
639
640 Ok(())
641}