Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
834944b
Hide non-accessible sandboxes from the sandboxes list
elias-ba May 15, 2026
eb9f8ee
Apply the same visibility rule to the global project picker
elias-ba May 15, 2026
4ee4913
Make Sandboxes.visible_sandboxes/3 the single source of truth
elias-ba May 15, 2026
7d1c7d7
Tighten picker render assertion to parse data-items JSON
elias-ba May 15, 2026
aeb9d0b
Move visible_sandboxes/3 from the Sandboxes policy to Projects
elias-ba May 15, 2026
b6861a6
Drop dead defensive branches in filter_descendants_for_user
elias-ba May 15, 2026
b148cc5
Polish visibility helpers for clarity and consistency
elias-ba May 15, 2026
f5eb5e8
Tighten visibility and scheduled-deletion handling
elias-ba May 16, 2026
de36d37
Walk to any ancestor root, not just parent_id-nil
elias-ba May 16, 2026
19b824b
Reshape user-accessible tree at the data layer for the picker
elias-ba May 16, 2026
8c02821
Replace ancestor walk per Repo.get with a single recursive CTE
elias-ba May 16, 2026
69372a7
Format files per mix format
elias-ba May 16, 2026
b5a3332
Scope project_users preload to the current user
elias-ba May 16, 2026
d4b1d9e
Lock in picker pruning even when the cascade is bypassed at write
elias-ba May 16, 2026
74aa8ad
Raise ArgumentError when project_users isn't preloaded
elias-ba May 16, 2026
a3dedff
Extract pipeline stages from get_project_tree_for_user/1
elias-ba May 16, 2026
c1ad032
Make the two recursive-CTE builders self-documenting
elias-ba May 16, 2026
be98f37
Strip caller references from public docstrings
elias-ba May 16, 2026
1d23d1c
Return ProjectTreeItem from get_project_tree_for_user/1
elias-ba May 16, 2026
b8a1e06
Lock in handler-level visibility and add list_descendants unit tests
elias-ba May 16, 2026
374f561
Add picker tree benchmark to measure picker query cost
elias-ba May 16, 2026
227aea0
Use Benchee for the picker tree benchmark distribution
elias-ba May 16, 2026
5e3fa95
Drop picker benchmark and its mix.exs dep scope expansion
elias-ba May 16, 2026
36392aa
Centralise the sandbox visibility predicate
elias-ba May 16, 2026
23654f2
Use a recursive CTE to walk to the root in visible_to_user?/2
elias-ba May 16, 2026
2b0be05
Optimise root_of/1 with a recursive CTE walk
elias-ba May 16, 2026
9704543
Make Sandboxes.parent_admin?/2 walk via CTE + bulk role check
elias-ba May 16, 2026
40db44a
Cache project chain on Collaborate mount
elias-ba May 16, 2026
553d93b
Reuse Projects.root_of/1 in Collaborate mount
elias-ba May 16, 2026
d18ccf8
Align sandbox access on direct project membership
elias-ba May 17, 2026
7fccd26
Update tests for the fork-model sandbox access
elias-ba May 17, 2026
4318aa7
Sync changelog and docstring with the fork-model rule
elias-ba May 17, 2026
87e85ea
Tighten support-user wording in get_project_tree_for_user/1 docstring
elias-ba May 17, 2026
36259fa
Tighten merge gate to admin/owner on source and stop naming descendants
elias-ba May 17, 2026
6eb34c9
Collapse check_manage_permissions/3 into manage_authority/2
elias-ba May 17, 2026
0f5a830
Show descendant count in the delete confirmation modal
elias-ba May 17, 2026
ffb171e
Drop root_project from visible_sandboxes and raise on missing preload
elias-ba May 17, 2026
99225a8
Clarify list_sandboxes/1 docstring on where picker filtering lives
elias-ba May 17, 2026
bbcc483
Drop unreachable nil branch from nearest_visible_ancestor_id/3
elias-ba May 17, 2026
07461e4
Drop unreachable nil base case from nearest_visible_ancestor_id
elias-ba May 17, 2026
92a4145
Cover Projects.subscribe/0 with a publish/receive round-trip test
elias-ba May 17, 2026
e865ea6
Cover the singular and plural descendant copy in the delete modal
elias-ba May 17, 2026
01f2025
Remove unused import
elias-ba May 17, 2026
e7ca495
Document the two-sided merge gate in the Sandboxes policy moduledoc
elias-ba May 17, 2026
4adb355
Add one-line invariant note above Map.fetch! in nearest_visible_ances…
elias-ba May 17, 2026
1df5bb2
Stop leaking the absolute workspace root to sandbox-only members
elias-ba May 17, 2026
dfbfe56
Hide the env-badge "main" fallback for sandboxes shown as access roots
elias-ba May 17, 2026
b5371f0
Show a 'Restricted parent project' placeholder when the access root i…
elias-ba May 17, 2026
0e532d3
Match restricted-parent placeholder height with the project cards, dr…
elias-ba May 17, 2026
6dbc9f7
Render the access root as a sandbox card with action buttons when it …
elias-ba May 17, 2026
03861d0
Give the picker button a visible default color when the sandbox has none
elias-ba May 17, 2026
547aabb
Use --color-primary-600 instead of hardcoded #4f39f6 for sandbox-card…
elias-ba May 17, 2026
4563571
Drop redundant div wrapper around the access-root card in workspace_list
elias-ba May 17, 2026
60eee2a
Compute access_root once in :project_scope and reuse it across the page
elias-ba May 17, 2026
f17ee93
Use Phoenix LiveView soft navigation in the picker instead of a full …
elias-ba May 17, 2026
5242d60
Stop leaking ancestor names from the collaborative editor for sandbox…
elias-ba May 17, 2026
27f625b
Lift display-name-with-access-root truncation into Projects
elias-ba May 17, 2026
4223061
Let action handlers find the access-root sandbox by id, not just desc…
elias-ba May 17, 2026
60cd41b
Replace find_managed_sandbox with a unified workspace_tree assign
elias-ba May 17, 2026
edb0fb1
Extract decorate_for_render and call manage_authority/2 once
elias-ba May 17, 2026
e42541f
Bring CollaborateNew and picker JS tests in line with the new contracts
elias-ba May 17, 2026
28a4150
Address re-review: access_root fast-path, drop scheduled rows from mo…
elias-ba May 17, 2026
0dfac2b
Extract active_descendants/1 and lock in scheduled-row exclusion with…
elias-ba May 17, 2026
072763e
Mirror the scheduled-row exclusion test on the merge modal
elias-ba May 17, 2026
5a4c949
Document the two-sided merge gate by caller; cover deeper hidden chains
elias-ba May 17, 2026
72a2704
Cover display_name_within_access_root/2 directly
elias-ba May 17, 2026
a2dd4a7
Cover the access_root and current_user paths of breadcrumb_project_pi…
elias-ba May 17, 2026
6b043c4
Collapse access_root_for_user/2 into a single round trip
elias-ba May 17, 2026
e387e6e
Trim docstrings to single lines, drop private-function comments
elias-ba May 17, 2026
129c2d7
Drop dead [] branch in access_root_for_user/2
elias-ba May 17, 2026
88fb2e4
Merge remote-tracking branch 'origin/main' into fix/hide-non-accessib…
elias-ba May 17, 2026
219a682
Scope PR to visibility-only; track access-rule changes in #4768
elias-ba May 18, 2026
6f8d60a
Tighten CHANGELOG entry to match the surrounding style
elias-ba May 18, 2026
69cbf7a
Remove the "Restricted parent project" placeholder card
elias-ba May 18, 2026
4f8a0f4
Drop the merge-modal mention from the CHANGELOG
elias-ba May 18, 2026
5ddadf0
Revert data-storage settings test assertions to match main's HEEX
elias-ba May 18, 2026
82fd4d3
Tighten merge button to admin/owner on source
elias-ba May 18, 2026
cb5b875
Flatten check_manage_permissions/3 to a single boolean per sandbox
elias-ba May 18, 2026
a6683e1
Drop superuser bypass from sandbox policies
elias-ba May 18, 2026
e0e21f7
Merge remote-tracking branch 'origin/release/2.16.4' into fix/hide-no…
elias-ba May 18, 2026
8dfd614
Document both sides of the merge gate in the :merge_sandbox docstring
elias-ba May 18, 2026
e18dbbc
simplify changelog
josephjclark May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ and this project adheres to
action as new sandbox creation, and the Restore button in the sandbox list is
disabled (with the limiter's tooltip) when the active sandbox count is already
at the limit.
- Sandboxes no longer appear in the sandboxes list, the project picker, or via
the sandbox URL unless the user has access
[#4762](https://github.com/OpenFn/lightning/issues/4762)
- The Merge button on a sandbox now requires admin or owner on the source
sandbox (or admin/owner on the root project)
[#4762](https://github.com/OpenFn/lightning/issues/4762)
- Sandbox policies no longer treat `User.role: :superuser` as a project-access
bypass [#4762](https://github.com/OpenFn/lightning/issues/4762)
- `Cmd/Ctrl+Enter` now runs the workflow directly; `Cmd/Ctrl+Shift+Enter` opens
"run with custom input". When a retryable run is loaded, the primary action
switches to retry. [#4736](https://github.com/OpenFn/lightning/issues/4736)
Expand Down
21 changes: 18 additions & 3 deletions assets/js/picker/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,16 @@ export interface PickerItem {
/**
* Optional hero icon class for the row (e.g. `hero-credit-card`).
* When absent, falls back to the project-style default:
* `hero-folder` at depth 0 and `hero-beaker` for nested items.
* `hero-folder` for root projects and `hero-beaker` for sandboxes.
*/
icon?: string;
/**
* Whether the underlying project is structurally a sandbox (has a
* real `parent_id` in the database). Drives the default icon
* independently of the row's display depth, so a sandbox surfaced as
* a user's access root still renders with the sandbox icon.
*/
isSandbox?: boolean;
}

interface PickerProps {
Expand Down Expand Up @@ -203,7 +210,15 @@ export function Picker(props: PickerProps) {
}, [openPicker, openEvent]);

const go = (href: string, sameSection = false) => {
window.location.href = href + (sameSection ? window.location.hash : '');
const fullHref = href + (sameSection ? window.location.hash : '');
Comment thread
elias-ba marked this conversation as resolved.
const a = document.createElement('a');
a.href = fullHref;
a.setAttribute('data-phx-link', 'redirect');
a.setAttribute('data-phx-link-state', 'push');
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};

const handleInputKeyDown = useCallback(
Expand Down Expand Up @@ -316,7 +331,7 @@ export function Picker(props: PickerProps) {
const isNested = item.depth > 0;
const indentPx = item.depth * 10;
const itemIcon =
item.icon ?? (isNested ? 'hero-beaker' : 'hero-folder');
item.icon ?? (item.isSandbox ? 'hero-beaker' : 'hero-folder');

return (
<li
Expand Down
4 changes: 2 additions & 2 deletions assets/js/picker/PickerButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function PickerButton(props: PickerButtonProps) {
const openEvent = props['data-open-event'];
const isSandbox = props['data-is-sandbox'] === 'true';
const accentIcon = props['data-accent-icon'] || icon;
const color = props['data-color'] || null;
const color = props['data-color'] || 'var(--color-primary-600)';

const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
Expand All @@ -56,7 +56,7 @@ export function PickerButton(props: PickerButtonProps) {
'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 hover:border-gray-400',
isSandbox && 'text-white border border-transparent hover:opacity-90'
)}
style={isSandbox && color ? { backgroundColor: color } : undefined}
style={isSandbox ? { backgroundColor: color } : undefined}
>
<span
className={cn(
Expand Down
61 changes: 31 additions & 30 deletions assets/test/picker/Picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,31 +165,33 @@ describe('Picker search', () => {
});

describe('Picker navigation', () => {
let hrefSetter: ReturnType<typeof vi.fn>;
let originalLocation: Location;
let clickedAnchors: HTMLAnchorElement[];
let clickSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
hrefSetter = vi.fn();
originalLocation = window.location;
delete (window as unknown as { location?: Location }).location;
(window as unknown as { location: unknown }).location = {
pathname: '/',
search: '',
hash: '',
get href() {
return '';
},
set href(value: string) {
hrefSetter(value);
},
};
clickedAnchors = [];
clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function (this: HTMLAnchorElement) {
clickedAnchors.push(this);
});
});

afterEach(() => {
(window as unknown as { location: Location }).location = originalLocation;
clickSpy.mockRestore();
closePicker();
});

function lastNavigation() {
expect(clickedAnchors).toHaveLength(1);
const a = clickedAnchors[0];
return {
href: a.getAttribute('href'),
phxLink: a.getAttribute('data-phx-link'),
phxLinkState: a.getAttribute('data-phx-link-state'),
};
}

test('navigates to the server-provided href on click', () => {
mountPicker([item('target', 'target', 0, { href: '/projects/target/w' })]);
openPicker();
Expand All @@ -201,7 +203,11 @@ describe('Picker navigation', () => {
targetOption.click();
});

expect(hrefSetter).toHaveBeenCalledWith('/projects/target/w');
expect(lastNavigation()).toEqual({
href: '/projects/target/w',
phxLink: 'redirect',
phxLinkState: 'push',
});
});

test('uses data-view-all-href for the view-all row', () => {
Expand All @@ -221,12 +227,11 @@ describe('Picker navigation', () => {
viewAll.click();
});

expect(hrefSetter).toHaveBeenCalledWith('/somewhere');
expect(lastNavigation().href).toBe('/somewhere');
});

test('preserves the current hash when item has sameSection=true', () => {
(window as unknown as { location: { hash: string } }).location.hash =
'#credentials';
window.location.hash = '#credentials';

mountPicker([
item('target', 'target', 0, {
Expand All @@ -243,14 +248,11 @@ describe('Picker navigation', () => {
option.click();
});

expect(hrefSetter).toHaveBeenCalledWith(
'/projects/target/settings#credentials'
);
expect(lastNavigation().href).toBe('/projects/target/settings#credentials');
});

test('drops the hash when item has sameSection=false', () => {
(window as unknown as { location: { hash: string } }).location.hash =
'#some-anchor';
window.location.hash = '#some-anchor';

mountPicker([
item('target', 'target', 0, {
Expand All @@ -267,12 +269,11 @@ describe('Picker navigation', () => {
option.click();
});

expect(hrefSetter).toHaveBeenCalledWith('/projects/target/history');
expect(lastNavigation().href).toBe('/projects/target/history');
});

test('does not preserve hash for the view-all row', () => {
(window as unknown as { location: { hash: string } }).location.hash =
'#credentials';
window.location.hash = '#credentials';

render(
<Picker
Expand All @@ -290,7 +291,7 @@ describe('Picker navigation', () => {
viewAll.click();
});

expect(hrefSetter).toHaveBeenCalledWith('/projects');
expect(lastNavigation().href).toBe('/projects');
});
});

Expand Down
104 changes: 47 additions & 57 deletions lib/lightning/policies/sandboxes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ defmodule Lightning.Policies.Sandboxes do
@moduledoc """
The Bodyguard Policy module for sandbox project operations.

Sandboxes have different authorization rules than regular projects:
Sandbox authorization mirrors regular projects: access is decided by the
acting user's role on the project they're acting on (or the workspace
root, where the cascade applies). `User.role` (`:user` / `:superuser`)
is a user-type for global user-management screens; it is not a
project-access bypass and has no effect on sandbox policy decisions, in
line with `Lightning.Policies.ProjectUsers`.

- Sandbox owners/admins can manage their own sandboxes
- Root project owners/admins can manage any sandbox in their workspace
- Editors (and above) on the root project can merge sandboxes
- Editors (and above) on the parent project can provision sandboxes
- Superusers can manage any sandbox anywhere

Destructive actions on a sandbox (delete, update, merge) are scoped to
admin/owner on the sandbox itself (or the root cascade above). This
matches the rest of Lightning, where destructive actions are admin/owner
scoped, and it keeps the merge button on the sandboxes list aligned with
the cleanup step that runs after merge submission (which calls
`:delete_sandbox` and so requires admin/owner on the source).
"""
@behaviour Bodyguard.Policy

Expand All @@ -22,24 +33,27 @@ defmodule Lightning.Policies.Sandboxes do
| :merge_sandbox

@doc """
Authorize sandbox operations based on user role and project hierarchy.
Authorize sandbox operations based on the user's role on the project
involved.

## Authorization Rules

### `:delete_sandbox` and `:update_sandbox`
User can perform these actions if they are:
- Superuser (can manage any sandbox)
User must be one of:
- Owner/admin of the sandbox itself
- Owner/admin of the root project (workspace)

### `:provision_sandbox`
User can create sandboxes if they are:
- Editor/admin/owner of the parent project they're creating the sandbox under
User must be editor/admin/owner of the parent project they're creating
the sandbox under.

### `:merge_sandbox`
User can merge a sandbox into a target project if they are:
- Editor/admin/owner on the target project
- Superuser
This check authorises the **target side** of a merge: the user must be
editor/admin/owner on the target project (the project being merged
*into*). The merge flow also requires admin/owner on the **source
sandbox** itself, enforced by `check_manage_permissions/3` (button
gate) and by the post-merge cleanup, which calls `:delete_sandbox`
to retire the source and so requires admin/owner there.

## Parameters
- `action` - The action being attempted
Expand All @@ -50,84 +64,60 @@ defmodule Lightning.Policies.Sandboxes do
@spec authorize(actions(), User.t(), Project.t()) :: boolean

def authorize(:provision_sandbox, %User{} = user, %Project{} = parent_project) do
case Projects.get_project_user_role(user, parent_project) do
role when role in [:owner, :admin, :editor] -> true
_ -> user.role == :superuser
end
Projects.get_project_user_role(user, parent_project) in [
:owner,
:admin,
:editor
]
end

def authorize(:merge_sandbox, %User{} = user, %Project{} = target_project) do
case Projects.get_project_user_role(user, target_project) do
role when role in [:owner, :admin, :editor] -> true
_ -> user.role == :superuser
end
Projects.get_project_user_role(user, target_project) in [
:owner,
:admin,
:editor
]
end

def authorize(action, %User{} = user, %Project{} = sandbox)
when action in [:delete_sandbox, :update_sandbox] do
cond do
user.role == :superuser ->
true

Projects.get_project_user_role(user, sandbox) in [:owner, :admin] ->
true

has_root_project_permission?(sandbox, user) ->
true

true ->
false
end
Projects.get_project_user_role(user, sandbox) in [:owner, :admin] or
has_root_project_permission?(sandbox, user)
end

def authorize(_action, _user, _project), do: false

@doc """
Bulk permission check for multiple sandboxes to avoid N+1 queries.
Bulk manage check for multiple sandboxes, avoiding N+1 queries.

Returns a map: sandbox_id => %{update: boolean, delete: boolean, merge: boolean}
Returns a map `sandbox_id => boolean()` where `true` means the user can
perform any of the destructive sandbox actions (update, delete, merge)
on that sandbox. The boolean is `true` when the user is an owner/admin
on the sandbox itself, or an owner/admin on the root project (cascade).

Assumes `root_project.project_users` and each `sandbox.project_users`
are preloaded (as ensured by `Projects.list_workspace_projects/2`).
"""
@spec check_manage_permissions([Project.t()], User.t(), Project.t()) ::
%{
binary() => %{update: boolean(), delete: boolean(), merge: boolean()}
}
%{binary() => boolean()}
def check_manage_permissions(sandboxes, %User{} = user, root_project) do
is_superuser = user.role == :superuser

is_root_owner_or_admin =
Enum.any?(
root_project.project_users,
&(&1.user_id == user.id and &1.role in [:owner, :admin])
)

is_root_editor_plus =
is_root_owner_or_admin or
Enum.any?(
root_project.project_users,
&(&1.user_id == user.id and &1.role == :editor)
)

has_full_privileges = is_superuser or is_root_owner_or_admin

if has_full_privileges do
Map.new(sandboxes, &{&1.id, %{update: true, delete: true, merge: true}})
if is_root_owner_or_admin do
Map.new(sandboxes, &{&1.id, true})
else
Map.new(sandboxes, fn sandbox ->
is_owner_or_admin_here? =
can_manage? =
Enum.any?(
sandbox.project_users,
&(&1.user_id == user.id and &1.role in [:owner, :admin])
)

{sandbox.id,
%{
update: is_owner_or_admin_here?,
delete: is_owner_or_admin_here?,
merge: is_root_editor_plus
}}
{sandbox.id, can_manage?}
end)
end
end
Expand Down
Loading