| 483 | 483 | | /// work items, anyone who can read a repository sees those filed there |
| 484 | 484 | | /// (on a public one, everyone), and one with no origin is for members |
| 485 | 485 | | /// only. `None` means they may not see it at all. |
| 486 | + | /// |
| 487 | + | /// A work item stays in its workspace when its origin moves to another: |
| 488 | + | /// there, reading it still follows the repository (public stays public; |
| 489 | + | /// a private one's readers still read what was filed in it), but nothing |
| 490 | + | /// more comes through the origin, whose roles are now another |
| 491 | + | /// workspace's. |
| 486 | 492 | | pub fn item_role(viewer: Option<&User>, workspace: &str, origin: Option<RepoRef<'_>>) -> Option<RepoRole> { |
| 487 | 493 | | let member = viewer.and_then(|user| workspace_role(user, workspace)); |
| 488 | | − | let repo = origin.and_then(|origin| permission(viewer, origin)); |
| 494 | + | let repo = origin.and_then(|origin| { |
| 495 | + | let role = permission(viewer, origin)?; |
| 496 | + | Some(if origin_is_in(origin, workspace) { role } else { role.min(RepoRole::Read) }) |
| 497 | + | }); |
| 489 | 498 | | member.max(repo) |
| 490 | 499 | | } |
| 491 | 500 | | |
| 501 | + | /// Whether a work item's origin is still in the work item's workspace. |
| 502 | + | fn origin_is_in(origin: RepoRef<'_>, workspace: &str) -> bool { |
| 503 | + | origin.namespace.eq_ignore_ascii_case(workspace) |
| 504 | + | } |
| 505 | + | |
| 492 | 506 | | /// Whether the viewer may do `capability` on a work item, by the same |
| 493 | 507 | | /// table as [`can`]: through their membership of the workspace, or their |
| 494 | | − | /// role on its origin as [`can`] reads it. |
| 508 | + | /// role on its origin as [`can`] reads it (only Read, once the origin is |
| 509 | + | /// in another workspace: see [`item_role`]). |
| 495 | 510 | | pub fn can_item(viewer: Option<&User>, workspace: &str, origin: Option<RepoRef<'_>>, capability: Capability) -> bool { |
| 496 | 511 | | let member = viewer.and_then(|user| workspace_role(user, workspace)); |
| 497 | 512 | | if member.is_some_and(|role| allows(role, capability)) { |
| 498 | 513 | | return true; |
| 499 | 514 | | } |
| 500 | | − | origin.is_some_and(|origin| can(viewer, origin, capability)) |
| 515 | + | origin.is_some_and(|origin| (capability == Capability::Read || origin_is_in(origin, workspace)) && can(viewer, origin, capability)) |
| 501 | 516 | | } |
| 502 | 517 | | |
| 503 | 518 | | /// `Ok` when the viewer may do `capability` on a work item; otherwise |
| ⋯ |
| 882 | 897 | | } |
| 883 | 898 | | |
| 884 | 899 | | #[test] |
| 900 | + | fn an_origin_moved_to_another_workspace_still_reads_but_gives_nothing_more() { |
| 901 | + | // rep_1 moved from acme to globex; its work items stayed in acme. |
| 902 | + | let moved = |private| Some(RepoRef { id: "rep_1", namespace: "globex", private }); |
| 903 | + | let globex_owner = person(&[("globex", Role::Owner, None)], &[]); |
| 904 | + | assert_eq!(item_role(Some(&globex_owner), "acme", moved(true)), Some(RepoRole::Read)); |
| 905 | + | assert!(can_item(Some(&globex_owner), "acme", moved(true), Capability::Read)); |
| 906 | + | assert!(!can_item(Some(&globex_owner), "acme", moved(true), Capability::Triage)); |
| 907 | + | assert!(!can_item(Some(&globex_owner), "acme", moved(false), Capability::Participate)); |
| 908 | + | // Public stays public. |
| 909 | + | assert_eq!(item_role(None, "acme", moved(false)), Some(RepoRole::Read)); |
| 910 | + | // acme's members keep what their membership gives. |
| 911 | + | let acme_writer = person(&[("acme", Role::Member, Some(BasePermission::Write))], &[]); |
| 912 | + | assert!(can_item(Some(&acme_writer), "acme", moved(true), Capability::Triage)); |
| 913 | + | } |
| 914 | + | |
| 915 | + | #[test] |
| 885 | 916 | | fn a_narrowed_token_reaches_only_what_it_was_made_for() { |
| 886 | 917 | | let mut member = person(&[("acme", Role::Member, Some(BasePermission::Write))], &[]); |
| 887 | 918 | | member.token = Some(Box::new(TokenAccess { |