Skip to content

Commit 9919348

Browse files
authored
feat: allow projects editors to provision and merge sandboxes (#4424)
* feat: allow editors to provision and merge sandboxes (#4384) Expand sandbox permissions so that editors (not just admin/owner) on a project can fork new sandboxes and merge them back. Merge authorization now checks the user's role on the target project rather than only the source sandbox. - Add :editor to provision_sandbox allowed roles - Add :merge_sandbox policy action checking editor+ on target project - Update check_manage_permissions so merge uses editor+ on root - Filter merge target options by user's role on each target - Add server-side merge authorization check in confirm-merge handler * chore: add changelog entry for #4384 * test: cover nil branch in user_role_on_project/2 Add a no-membership sandbox to the target filtering test so the nil return path is exercised. * refactor: clean up check_manage_permissions logic Remove unreachable is_superuser check in the else branch and derive is_root_editor_plus from is_root_owner_or_admin to avoid a redundant scan of project_users.
1 parent 32f1001 commit 9919348

7 files changed

Lines changed: 319 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to
1919

2020
### Changed
2121

22+
- Editors can now provision and merge sandboxes; merge checks editor+ role on
23+
the target project [#4384](https://github.com/OpenFn/lightning/issues/4384)
2224
- Show specific workflow names in sandbox merge dialog when target project has
2325
diverged, instead of generic warning message
2426
[#4001](https://github.com/OpenFn/lightning/issues/4001)

lib/lightning/policies/sandboxes.ex

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ defmodule Lightning.Policies.Sandboxes do
55
Sandboxes have different authorization rules than regular projects:
66
- Sandbox owners/admins can manage their own sandboxes
77
- Root project owners/admins can manage any sandbox in their workspace
8+
- Editors (and above) on the root project can merge sandboxes
9+
- Editors (and above) on the parent project can provision sandboxes
810
- Superusers can manage any sandbox anywhere
911
"""
1012
@behaviour Bodyguard.Policy
@@ -17,6 +19,7 @@ defmodule Lightning.Policies.Sandboxes do
1719
:delete_sandbox
1820
| :update_sandbox
1921
| :provision_sandbox
22+
| :merge_sandbox
2023

2124
@doc """
2225
Authorize sandbox operations based on user role and project hierarchy.
@@ -31,18 +34,31 @@ defmodule Lightning.Policies.Sandboxes do
3134
3235
### `:provision_sandbox`
3336
User can create sandboxes if they are:
34-
- Owner/admin of the parent project they're creating the sandbox under
37+
- Editor/admin/owner of the parent project they're creating the sandbox under
38+
39+
### `:merge_sandbox`
40+
User can merge a sandbox into a target project if they are:
41+
- Editor/admin/owner on the target project
42+
- Superuser
3543
3644
## Parameters
3745
- `action` - The action being attempted
3846
- `user` - The user attempting the action
39-
- `project` - The sandbox project (for delete/update) or parent project (for provision)
47+
- `project` - The sandbox project (for delete/update), parent project (for provision),
48+
or target project (for merge)
4049
"""
4150
@spec authorize(actions(), User.t(), Project.t()) :: boolean
4251

4352
def authorize(:provision_sandbox, %User{} = user, %Project{} = parent_project) do
4453
case Projects.get_project_user_role(user, parent_project) do
45-
role when role in [:owner, :admin] -> true
54+
role when role in [:owner, :admin, :editor] -> true
55+
_ -> user.role == :superuser
56+
end
57+
end
58+
59+
def authorize(:merge_sandbox, %User{} = user, %Project{} = target_project) do
60+
case Projects.get_project_user_role(user, target_project) do
61+
role when role in [:owner, :admin, :editor] -> true
4662
_ -> user.role == :superuser
4763
end
4864
end
@@ -79,14 +95,24 @@ defmodule Lightning.Policies.Sandboxes do
7995
binary() => %{update: boolean(), delete: boolean(), merge: boolean()}
8096
}
8197
def check_manage_permissions(sandboxes, %User{} = user, root_project) do
82-
has_root_privileges =
83-
user.role == :superuser or
98+
is_superuser = user.role == :superuser
99+
100+
is_root_owner_or_admin =
101+
Enum.any?(
102+
root_project.project_users,
103+
&(&1.user_id == user.id and &1.role in [:owner, :admin])
104+
)
105+
106+
is_root_editor_plus =
107+
is_root_owner_or_admin or
84108
Enum.any?(
85109
root_project.project_users,
86-
&(&1.user_id == user.id and &1.role in [:owner, :admin])
110+
&(&1.user_id == user.id and &1.role == :editor)
87111
)
88112

89-
if has_root_privileges do
113+
has_full_privileges = is_superuser or is_root_owner_or_admin
114+
115+
if has_full_privileges do
90116
Map.new(sandboxes, &{&1.id, %{update: true, delete: true, merge: true}})
91117
else
92118
Map.new(sandboxes, fn sandbox ->
@@ -96,14 +122,11 @@ defmodule Lightning.Policies.Sandboxes do
96122
&(&1.user_id == user.id and &1.role in [:owner, :admin])
97123
)
98124

99-
# Merge follows the same rule as update
100-
# Today update/delete/merge share the same rule.
101-
# If they ever diverge, split here (e.g., compute separate booleans).
102125
{sandbox.id,
103126
%{
104127
update: is_owner_or_admin_here?,
105128
delete: is_owner_or_admin_here?,
106-
merge: is_owner_or_admin_here?
129+
merge: is_root_editor_plus
107130
}}
108131
end)
109132
end

lib/lightning/projects/sandboxes.ex

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ defmodule Lightning.Projects.Sandboxes do
2323
2424
## Authorization
2525
26-
* **Provisioning**: Requires `:owner` or `:admin` role on the parent project or superuser
26+
* **Provisioning**: Requires `:editor`, `:admin`, or `:owner` role on the parent project, or superuser
27+
* **Merge**: Requires `:editor`, `:admin`, or `:owner` role on the target project, or superuser
2728
* **Updates/Deletion**: Requires `:owner` or `:admin` role on the sandbox itself,
2829
or `:owner` or `:admin` on the root project, or superuser
2930
@@ -88,7 +89,7 @@ defmodule Lightning.Projects.Sandboxes do
8889
8990
## Parameters
9091
* `parent` - Project to clone from
91-
* `actor` - User creating the sandbox (needs `:owner` or `:admin` role on parent)
92+
* `actor` - User creating the sandbox (needs `:editor`, `:admin`, or `:owner` role on parent)
9293
* `attrs` - Creation attributes (see `t:provision_attrs/0`)
9394
9495
## Returns

lib/lightning_web/live/sandbox_live/index.ex

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ defmodule LightningWeb.SandboxLive.Index do
22
use LightningWeb, :live_view
33

44
alias Ecto.Changeset
5+
alias Lightning.Policies.Permissions
56
alias Lightning.Projects
67
alias Lightning.Projects.MergeProjects
78
alias Lightning.Projects.ProjectLimiter
@@ -276,9 +277,24 @@ defmodule LightningWeb.SandboxLive.Index do
276277
|> noreply()
277278

278279
target ->
279-
source
280-
|> perform_merge(target, actor)
281-
|> handle_merge_result(socket, source, target, root_project, actor)
280+
if Permissions.can?(
281+
:sandboxes,
282+
:merge_sandbox,
283+
actor,
284+
target
285+
) do
286+
source
287+
|> perform_merge(target, actor)
288+
|> handle_merge_result(socket, source, target, root_project, actor)
289+
else
290+
socket
291+
|> put_flash(
292+
:error,
293+
"You are not authorized to merge into this project"
294+
)
295+
|> reset_merge_modal_state()
296+
|> noreply()
297+
end
282298
end
283299
end
284300
end
@@ -390,7 +406,7 @@ defmodule LightningWeb.SandboxLive.Index do
390406
current_user = socket.assigns.current_user
391407

392408
can_create_sandbox =
393-
Lightning.Policies.Permissions.can?(
409+
Permissions.can?(
394410
:sandboxes,
395411
:provision_sandbox,
396412
current_user,
@@ -518,13 +534,18 @@ defmodule LightningWeb.SandboxLive.Index do
518534
end
519535

520536
defp get_merge_target_options(socket, source_sandbox) do
537+
current_user = socket.assigns.current_user
521538
root_project = socket.assigns.root_project
522539

523540
socket.assigns.workspace_projects
524541
|> Enum.reject(fn potential_target ->
525542
potential_target.id == source_sandbox.id or
526543
Projects.descendant_of?(potential_target, source_sandbox, root_project)
527544
end)
545+
|> Enum.filter(fn project ->
546+
user_role_on_project(project, current_user) in [:owner, :admin, :editor] or
547+
current_user.role == :superuser
548+
end)
528549
|> Enum.map(fn project ->
529550
%{
530551
value: project.id,
@@ -533,6 +554,13 @@ defmodule LightningWeb.SandboxLive.Index do
533554
end)
534555
end
535556

557+
defp user_role_on_project(project, user) do
558+
case Enum.find(project.project_users, &(&1.user_id == user.id)) do
559+
nil -> nil
560+
pu -> pu.role
561+
end
562+
end
563+
536564
defp get_all_descendants(sandbox, workspace_projects) do
537565
project_map = Map.new(workspace_projects, &{&1.id, &1})
538566

test/lightning/policies/sandbox_permissions_test.exs

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,79 @@ defmodule Lightning.Policies.SandboxesTest do
337337
end
338338
end
339339

340+
describe "merge_sandbox permissions" do
341+
test "editors on the target project can merge sandboxes", %{
342+
root_project: root_project,
343+
user: user
344+
} do
345+
insert(:project_user, user: user, project: root_project, role: :editor)
346+
347+
root_project =
348+
Lightning.Repo.preload(root_project, :project_users, force: true)
349+
350+
assert Sandboxes
351+
|> Permissions.can?(:merge_sandbox, user, root_project)
352+
end
353+
354+
test "admins on the target project can merge sandboxes", %{
355+
root_project: root_project,
356+
user: user
357+
} do
358+
insert(:project_user, user: user, project: root_project, role: :admin)
359+
360+
root_project =
361+
Lightning.Repo.preload(root_project, :project_users, force: true)
362+
363+
assert Sandboxes
364+
|> Permissions.can?(:merge_sandbox, user, root_project)
365+
end
366+
367+
test "owners on the target project can merge sandboxes", %{
368+
root_project_owner: owner,
369+
root_project: root_project
370+
} do
371+
assert Sandboxes
372+
|> Permissions.can?(:merge_sandbox, owner, root_project)
373+
end
374+
375+
test "viewers on the target project cannot merge sandboxes", %{
376+
root_project: root_project,
377+
user: user
378+
} do
379+
insert(:project_user, user: user, project: root_project, role: :viewer)
380+
381+
root_project =
382+
Lightning.Repo.preload(root_project, :project_users, force: true)
383+
384+
refute Sandboxes
385+
|> Permissions.can?(:merge_sandbox, user, root_project)
386+
end
387+
388+
test "users without project access cannot merge sandboxes", %{
389+
root_project: root_project,
390+
user: user
391+
} do
392+
refute Sandboxes
393+
|> Permissions.can?(:merge_sandbox, user, root_project)
394+
end
395+
396+
test "superusers can merge sandboxes into any project", %{
397+
superuser: superuser,
398+
root_project: root_project,
399+
other_root_project: other_root_project
400+
} do
401+
assert Sandboxes
402+
|> Permissions.can?(:merge_sandbox, superuser, root_project)
403+
404+
assert Sandboxes
405+
|> Permissions.can?(
406+
:merge_sandbox,
407+
superuser,
408+
other_root_project
409+
)
410+
end
411+
end
412+
340413
describe "check_manage_permissions/3 bulk operation" do
341414
setup %{
342415
root_project: root_project,
@@ -456,7 +529,30 @@ defmodule Lightning.Policies.SandboxesTest do
456529
assert map_size(permissions) == 4
457530

458531
for sandbox <- sandboxes do
459-
assert %{update: false, delete: false} = permissions[sandbox.id]
532+
assert %{update: false, delete: false, merge: false} =
533+
permissions[sandbox.id]
534+
end
535+
end
536+
537+
test "root project editor gets merge but not update/delete on all sandboxes",
538+
%{
539+
root_project: root_project,
540+
sandboxes: sandboxes,
541+
user: user
542+
} do
543+
insert(:project_user, user: user, project: root_project, role: :editor)
544+
545+
root_project =
546+
Lightning.Repo.preload(root_project, :project_users, force: true)
547+
548+
permissions =
549+
Sandboxes.check_manage_permissions(sandboxes, user, root_project)
550+
551+
assert map_size(permissions) == 4
552+
553+
for sandbox <- sandboxes do
554+
assert %{update: false, delete: false, merge: true} =
555+
permissions[sandbox.id]
460556
end
461557
end
462558
end
@@ -493,7 +589,7 @@ defmodule Lightning.Policies.SandboxesTest do
493589
refute Sandboxes |> Permissions.can?(:delete_sandbox, user, sandbox)
494590
end
495591

496-
test "provision_sandbox with editor role", %{
592+
test "provision_sandbox with editor role is allowed", %{
497593
root_project: root_project,
498594
user: user
499595
} do
@@ -502,7 +598,7 @@ defmodule Lightning.Policies.SandboxesTest do
502598
root_project =
503599
Lightning.Repo.preload(root_project, :project_users, force: true)
504600

505-
refute Sandboxes
601+
assert Sandboxes
506602
|> Permissions.can?(:provision_sandbox, user, root_project)
507603
end
508604

@@ -536,8 +632,10 @@ defmodule Lightning.Policies.SandboxesTest do
536632
permissions =
537633
Sandboxes.check_manage_permissions(sandboxes, user, root_project)
538634

635+
# Editor on root gets merge but not update/delete
539636
for sandbox <- sandboxes do
540-
assert %{update: false, delete: false} = permissions[sandbox.id]
637+
assert %{update: false, delete: false, merge: true} =
638+
permissions[sandbox.id]
541639
end
542640
end
543641
end

test/lightning/sandboxes_test.exs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,18 @@ defmodule Lightning.Projects.SandboxesTest do
179179
end
180180

181181
describe "authorization" do
182-
test "rejects non-admin/editor" do
183-
%{actor: actor, parent: parent} = build_parent_fixture!(:editor)
182+
test "rejects viewer" do
183+
%{actor: actor, parent: parent} = build_parent_fixture!(:viewer)
184184

185185
assert {:error, :unauthorized} =
186-
Sandboxes.provision(parent, actor, %{name: "SB"})
186+
Sandboxes.provision(parent, actor, %{name: "sb-viewer"})
187+
end
188+
189+
test "allows editor" do
190+
%{actor: actor, parent: parent} = build_parent_fixture!(:editor)
191+
192+
assert {:ok, %Project{}} =
193+
Sandboxes.provision(parent, actor, %{name: "sb-editor"})
187194
end
188195

189196
test "allows admin/owner" do

0 commit comments

Comments
 (0)