Skip to content

Sandboxes: scope visibility, tighten merge gate, drop superuser bypass#4763

Merged
elias-ba merged 82 commits into
release/2.16.4from
fix/hide-non-accessible-sandboxes-4762
May 18, 2026
Merged

Sandboxes: scope visibility, tighten merge gate, drop superuser bypass#4763
elias-ba merged 82 commits into
release/2.16.4from
fix/hide-non-accessible-sandboxes-4762

Conversation

@elias-ba
Copy link
Copy Markdown
Contributor

@elias-ba elias-ba commented May 15, 2026

Description

Problem

Today, having any role on a parent project automatically surfaces every sandbox underneath, in the Sandboxes page, the global project picker, and the breadcrumb. The actual entry policy is already direct-membership-only on main, so users see sandboxes they can't actually open. The mismatch is confusing and leaks names of sandboxes a user has no business seeing.

Fix

Sandboxes only show up for users with direct membership on that sandbox (or for support users when the sandbox's own support-access flag is on). Visibility now matches what the entry policy already enforces.

  • Sandboxes only appear for users with direct membership, in three places: the Sandboxes page, the global project picker, and when visiting the sandbox URL directly.
  • If you can see a sandbox but not its parent, the breadcrumb at the top-left starts at the highest project you can see; ancestors you can't reach are hidden silently.
  • If you can see a sandbox but not the sandboxes between it and the root, the picker collapses the gap and nests the sandbox under the highest visible ancestor.

Two alignments bundled in

Both are pre-existing bugs/inconsistencies rather than new policy decisions, so I bundled them here:

  • Merge button. Now requires admin/owner on the source sandbox (or admin/owner on the root). On main, an editor on the root could click Merge, but the cleanup that retires the source requires admin/owner, so the merge silently leaves the source in place. The button now matches what the action can complete.
  • Superuser bypass. Removed from sandbox policies. Lightning.Policies.ProjectUsers already ignores User.role for regular projects, so this aligns sandboxes with the rest of Lightning. Superuser is a type of user, not a project role.

The bigger access-rule decisions (parent-admin floor, root cascade) live in #4768 for separate discussion.

Closes #4762.
Closes #4745.

Validation steps

Setup

Seed the demo users. Either set IS_RESETTABLE_DEMO=yes and run mix run priv/repo/demo.exs, or from IEx:

iex -S mix
iex> Lightning.SetupUtils.setup_demo(create_super: true)

Either gives you four users (all password welcome12345): Amy Admin (demo@openfn.org, owner of DHIS2), Esther Editor, Vikram Viewer, and Sizwe Super (superuser).

Sign in as Amy, open DHIS2's Sandboxes page (left sidebar), and click + New sandbox to provision one. To add or remove members on a project, open its Settings in the left sidebar and pick the Collaboration tab. Reset Esther's and Vikram's memberships between scenarios as needed.

1. A user without a sandbox membership cannot see the sandbox

Sign in as Amy and add Esther to DHIS2 as an Admin using DHIS2's Collaboration tab. Do not open the sandbox's Collaboration tab; Esther stays absent from the sandbox itself.

  1. Sign out. Sign in as Esther.
  2. Open DHIS2's Sandboxes page.
  3. Click the project name in the top-left breadcrumb to open the project picker.

The sandbox is absent from the Sandboxes list, and the picker shows only DHIS2 with no nested entries. Esther's Admin role on DHIS2 does not surface the sandbox. Visiting the sandbox URL directly (/projects/<sandbox_id>/w) redirects her away.

2. Adding the user directly to the sandbox makes it visible

Still signed in as Amy, open the sandbox's Collaboration tab and add Esther as a Viewer.

  1. Sign in as Esther.
  2. Reload DHIS2's Sandboxes page.
  3. Re-open the project picker.

The sandbox now appears in both the Sandboxes list and the picker. Visiting the sandbox URL directly now loads it.

3. Support-user access follows the sandbox's own support-access toggle

Sign in as Sizwe, open /settings/users, edit a fresh user with no project_users row anywhere, tick Support user, and save.

  1. Sign in as that support user.
  2. Open the sandbox URL directly (/projects/<sandbox_id>/w).

The sandbox inherits DHIS2's allow_support_access at provisioning. While the sandbox's flag is true, the support user can open it. As Amy, open the sandbox's Settings and toggle allow_support_access to false:

  1. Reload as the support user. Access is denied. DHIS2's own flag value is irrelevant; only the sandbox's flag controls support access to the sandbox.

4. The breadcrumb and picker hide ancestors the user can't reach

Two related observations on the same underlying rule (ancestors you can't access aren't shown).

Breadcrumb truncation at the access root. As Amy, provision sandbox-A under DHIS2 (if you haven't) and add Esther to sandbox-A as an Editor (no role on DHIS2). As Esther, open sandbox-A, then its Sandboxes page, and provision a nested sandbox-B (Esther becomes its Owner at creation). Open any page inside sandbox-B. The top-left breadcrumb shows sandbox-A/sandbox-B, not DHIS2/sandbox-A/sandbox-B.

Picker collapses hidden intermediates. As Amy, reset Esther's memberships, then re-add her: Editor on DHIS2, Viewer on sandbox-B, no role on sandbox-A. As Esther, open the project picker. It shows DHIS2 at the top level with sandbox-B nested directly under it (one level of indentation); sandbox-A is absent.

Additional notes for the reviewer

AI Usage

  • I have used Claude Code
  • I have used another model
  • I have not used AI

You can read more details in our
Responsible AI Policy

Pre-submission checklist

  • I have performed an AI review of my code (we recommend using /review
    with Claude Code)
  • I have implemented and tested all related authorization policies.
    (e.g., :owner, :admin, :editor, :viewer)
  • I have updated the changelog.
  • I have ticked a box in "AI usage" in this PR

@github-project-automation github-project-automation Bot moved this to New Issues in Core May 15, 2026
@github-actions
Copy link
Copy Markdown

Security Review ✅

  • S0 (project scoping): New Sandboxes.visible_sandboxes/3 at lib/lightning/policies/sandboxes.ex:147 tightens scoping by filtering descendants to those the user is a member of (with cascading visibility for superusers and root owners/admins); membership is derived from preloaded project_users populated by Projects.list_workspace_projects/2 at lib/lightning/projects.ex:1647, not from spoofable params.
  • S1 (authorization): The filter is invoked in load_workspace_projects at lib/lightning_web/live/sandbox_live/index.ex:536 before sandboxes are surfaced, and the policy module has role-checked branches covered by tests in test/lightning/policies/sandbox_permissions_test.exs:560-661 for superuser, root owner, root admin, root editor, sandbox-only, and non-member cases.
  • S2 (audit trail): N/A — change is read-side visibility filtering only, with no new writes to configuration resources.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.2%. Comparing base (d23fa15) to head (5ddadf0).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##            main   #4763     +/-   ##
=======================================
+ Coverage   90.1%   90.2%   +0.1%     
=======================================
  Files        450     441      -9     
  Lines      22593   22443    -150     
=======================================
- Hits       20356   20247    -109     
+ Misses      2237    2196     -41     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@elias-ba elias-ba requested a review from josephjclark May 15, 2026 15:49
@brandonjackson brandonjackson self-requested a review May 15, 2026 15:59
@elias-ba elias-ba force-pushed the fix/hide-non-accessible-sandboxes-4762 branch 3 times, most recently from fab9d72 to 350a2ba Compare May 16, 2026 16:09
elias-ba added 18 commits May 16, 2026 16:15
Mirrors how the projects list filters out projects the user is not a
member of. Superusers and root-project owners/admins keep full visibility
over the workspace; everyone else only sees sandboxes they have a
project_users row on.

Closes #4762
get_project_tree_for_user/1 used to return the full subtree under every
root the user is a member of, so the picker dropdown leaked sandboxes the
user had no project_users row on. The function now filters descendants
with the same rule used by the sandboxes list: superusers and root
owners/admins (and support users on support-access roots) keep cascading
access; everyone else only sees descendants they hold a project_users
row on.

Closes the picker half of #4762.
The picker filter and the sandboxes-list filter encoded the same rule
twice with a subtle divergence: only the picker cascaded for support
users on a support-access root, so a support user opening the sandboxes
page saw an empty list while the picker showed every descendant.

Sandboxes.visible_sandboxes/3 now owns the rule (including the
support-user branch). Projects.get_project_tree_for_user/1 groups
descendants by their root and delegates to the policy per root, so both
surfaces stay in lockstep. Adds a picker render test asserting that
hidden sandboxes do not appear in the dropdown items.
Use Floki to extract the data-items attribute from the rendered picker,
decode it, and assert UUID membership in the id array. Previously the
test matched on the bare UUID substring in the full HTML, which would
have also passed if the id appeared inside an unrelated tag.
The policy modules in lib/lightning/policies/ are reserved for
Bodyguard-style authorize/3 callbacks; context modules route to them
through Lightning.Policies.Permissions.can?/4. Reaching directly into a
specific policy module from a context (Lightning.Projects calling
Lightning.Policies.Sandboxes) had no precedent and created a soft cycle
because the Sandboxes policy already depends on Projects for
root_of/1 and get_project_user_role/2.

visible_sandboxes/3 isn't an action authorization decision; it filters a
list of Project rows by hierarchy and membership. That belongs alongside
get_projects_for_user, projects_for_user_query, and get_project_tree_for_user
in Lightning.Projects. Tests move with it.
The deletion cascade in Sandboxes.do_schedule_sandbox_deletion/1 sets
scheduled_deletion on a sandbox AND every descendant in one transaction,
so the supported API can never produce a non-scheduled descendant whose
parent chain is broken by a scheduled (and thus filtered) intermediate.
Combined with descendant_ids/1 only returning descendants of roots the
user already has, every chain is guaranteed to terminate at a root in
root_lookup.

Replace Map.fetch/Map.get + fallback branches with Map.fetch!. If a
future change breaks the invariant we get a loud KeyError instead of
silently dropping sandboxes from the picker.
* Separate cascading_visibility?/2 and member?/2 from visible_sandboxes/3.
  The cascading rule (superuser, support user on support-access root,
  root owner/admin) is now a named predicate; the function reads as
  "give back the whole list when access cascades, otherwise keep only
  members."
* Replace Enum.uniq_by with Enum.sort_by(& &1.name) in the support-user
  variant of roots_for_user_tree/1. SQL UNION already dedupes; the
  remaining concern was unstable ordering relative to the non-support
  variant, which the sort fixes.
* Add a multi-root test that exercises per-root grouping with one
  cascading workspace and one membership-only workspace.
* Use Lightning.Factories.insert consistently in the picker test instead
  of mixing three fixture conventions.
Three follow-ups from a deeper audit of visibility surfaces:

* get_project_tree_for_user/1 now uses a scoped recursive CTE that
  filters is_nil(scheduled_deletion) in both the initial and recursion
  arms (active_descendants_query/1). The walk prunes at a scheduled
  ancestor, so the visibility filter can never receive a non-scheduled
  descendant whose parent chain is broken by a scheduled intermediate.
  The invariant the deletion cascade enforces at the write path is now
  also enforced at the read path, so Map.fetch! in
  filter_descendants_for_user/3 holds by construction, not by trust.
* get_merge_target_options/2 now rejects scheduled-for-deletion
  sandboxes. They could previously appear as merge targets in the modal.
* list_sandboxes/1's docstring claimed list_workspace_projects/2 filters
  scheduled rows out. It doesn't; the sandboxes list shows scheduled
  rows in a "Recently Deleted" section and the picker filters them via
  get_project_tree_for_user/1. Doc updated to reflect reality.

descendant_ids/1 and descendants_query/1 stay unchanged - they're still
the right tool for credential uniqueness across the tree and for the
hard-delete cascade in Sandboxes.subtree_ids/1.

Adds two regression tests: one for the scheduled-ancestor pruning in
the tree fetch, one for scheduled sandboxes being excluded from merge
target options.
ancestor_root_id/3 (formerly root_id_of/2) now stops at any project in
root_lookup rather than only at parent_id: nil. The function's contract
becomes "walk up until you find a root," which is what it always meant.
Removes the silent coupling between filter_descendants_for_user/3 and
the data-fetch invariant that roots are always top-level.
Two follow-ups from an external review that surfaced gaps in the
"hide non-accessible sandboxes" rule.

Sandbox-only and orphan-descendant cases for the picker
-------------------------------------------------------

Before, get_project_tree_for_user/1 returned only roots the user was a
member of (parent_id IS NULL) and walked their descendants. That broke
two real scenarios:

* A user added directly to a deep sandbox without membership on its
  ancestors got an empty picker, because their sandbox was not a root.
* A user on root R plus on grandchild B but not on intermediate A saw
  only R in the picker; the tree walk could not descend through hidden
  A to reach B.

roots_for_user_tree/1 now computes *access roots* via
candidate_roots_query/1 and shadowing through has_candidate_ancestor?/2:
every project the user holds a project_users row on (any depth), plus
support-access roots for support users, minus any candidate that has a
member ancestor (so a root member shadows their own descendants).

get_project_tree_for_user/1 then reshapes the returned parent_id field
of every project for picker-friendly rendering. Access roots get
parent_id: nil; visible descendants get parent_id rewritten via
reparent_for_picker/3 to their nearest visible ancestor (intermediate
ancestors the user cannot see are elided). The picker walk reverts to
the simple "start at parent_id: nil" form because the tree is now
correctly shaped at the data layer.

Merge modal surfaces hidden descendants
---------------------------------------

The merge modal listed descendants of the source sandbox by walking
the user-visibility-filtered workspace_projects. A user who can merge
S but cannot see all of S's descendants would see an incomplete list,
while the server-side merge still affects the full subtree.

Adds Projects.list_descendants/1 (a workspace-root-agnostic query for
"every descendant of this project") and switches the merge modal to
it, so the user is shown what their action will actually affect.
Removes the now-unused get_all_descendants/2 and local descendant_of?/3
helpers from SandboxLive.Index.

Tests
-----

* Reparenting onto the nearest visible ancestor (projects unit and
  picker render).
* Sandbox-only access root (projects unit and picker render).
* Prefer-topmost-membership when the user holds rows on both root and
  descendant.
* Merge modal lists descendants the current viewer cannot otherwise
  see.
The previous has_candidate_ancestor?/2 walked parent chains with one
Repo.get/2 per hop, producing N_candidates * max_depth round-trips
every time the picker rendered. Replace with a single depth-bounded
recursive CTE (ancestor_ids_by_starting_id/1) that returns every
ancestor id paired with its starting candidate, grouped in Elixir and
intersected against the candidate set.

The CTE selects the starting and ancestor ids cast to Ecto.UUID so the
returned map keys are the string form Project.id uses elsewhere; raw
binary keys would silently miss every Map.get/Map.has_key? lookup
against candidate_id_set and let shadowed sandboxes leak through as
duplicate access roots.

Strengthens the existing "topmost membership wins" test to assert the
reshaped parent_id chain (root -> middle -> grandchild) and the
absence of duplicates, and adds a support-user shadow test so the
support_user branch of candidate_roots_query/1 is exercised
end-to-end. Tightens the visible_sandboxes/3 docstring to call out
both preload guarantors (list_workspace_projects/2 and
get_project_tree_for_user/1).
mix format --check-formatted was failing on CI. Local check runs were
reading the wrong exit code (pipe to tail dropped mix's exit status),
so the formatter drift never surfaced.
The picker runs on every page render via get_project_tree_for_user/1,
and the previous preload loaded every membership row on every project
the user could see, just so cascading_visibility?/2 and member?/2 could
check for one row. Scope the preload to the current user so each
project comes back with at most one project_users row.

cascading_visibility?/2 and member?/2 only ever look for a row matching
the current user's id, so a scoped preload satisfies the contract on
visible_sandboxes/3. The only two callers
(LightningWeb.SandboxLive.Index via list_workspace_projects/2 and
Projects.filter_descendants_for_user/3 via this function) both honor
the contract for the current user.
Adds a regression test that constructs the "scheduled middle, active
leaf" state directly via Repo.update_all (bypassing
Sandboxes.do_schedule_sandbox_deletion/1's atomic subtree cascade) and
asserts the picker still prunes the entire subtree. Documents the
read-side guarantee independently of the write-side invariant.
visible_sandboxes/3 now sits on Lightning.Projects (a public,
broadly-imported context), so a future caller is one easy mistake away
from a Protocol.UndefinedError deep inside Enum.any?/2. Guard at the
function entry: if root_project.project_users or any
sandbox.project_users is %Ecto.Association.NotLoaded{}, raise an
ArgumentError that names the unloaded association and points at the
function docstring.

Existing internal callers
(LightningWeb.SandboxLive.Index.load_workspace_projects/1 and
Projects.filter_descendants_for_user/3) already honor the contract,
so this is purely a friendlier failure mode at the API boundary.
The top-level function now reads as the algorithm it implements: get
roots, load descendants, filter for visibility, shape for picker.

* load_active_descendants/2 owns the descendant fetch (scoped preload,
  ordering).
* shape_for_picker/3 owns the access-root nil-ification and descendant
  reparenting.
* roots_for_user_tree/1 names its shadow predicate
  (shadowed_by_candidate_ancestor?/3) and its loading stage
  (load_candidate_roots/1) instead of inlining both.

No behavior change; existing tests cover every path.
active_descendants_query/1: rename `initial` and `recursion` to
direct_children and next_level_down. The names now describe what each
CTE arm produces rather than its role in the recursive walk.

ancestor_ids_by_starting_id/1: split the bundled function into three
named pieces:

* the orchestrator now reads as a 5-line pipeline (to_list ->
  ancestor_pairs_query -> Repo.all -> group_ancestors_by_starting_id),
* ancestor_pairs_query/1 owns the SQL with starting_rows /
  next_ancestor_up bindings,
* group_ancestors_by_starting_id/1 owns the post-query grouping.

No behavior change, no extra round-trips, no extra traversals.
list_descendants/1 and visible_sandboxes/3 named the specific callers
and described usage scenarios ("used by surfaces that need to
communicate...", "single source of truth across both the sandboxes
list and the global project picker", "Both call sites take care of
this: ..."). Caller details rot as the codebase evolves; the
function contracts stand on their own.
@elias-ba elias-ba force-pushed the fix/hide-non-accessible-sandboxes-4762 branch from 350a2ba to be98f37 Compare May 16, 2026 16:16
The function used to return %Project{} structs with parent_id rewritten
for picker display. That worked but exposed a foot-gun: a future caller
that pipes the result into a changeset, Repo.update, or any permissions
helper that reads parent_id would corrupt the persisted hierarchy.

Introduce a small ProjectTreeItem struct (id, name, color, parent_id)
and return that instead. The picker walk consumes the same fields, so
the rendering contract is unchanged; the type signature now states
plainly that the result is a tree-rendering shape, not a list of
persistable projects.
@josephjclark
Copy link
Copy Markdown
Collaborator

@elias-ba my instinct is that the behaviours here are all correct. I'd like to test a bit deeper but I think this is a good change.

@josephjclark
Copy link
Copy Markdown
Collaborator

This will fix #4745 right?

Comment thread assets/js/picker/Picker.tsx
Comment thread lib/lightning/policies/sandboxes.ex Outdated
Comment thread lib/lightning/policies/sandboxes.ex Outdated
Comment thread lib/lightning/policies/sandboxes.ex
@elias-ba elias-ba changed the title Sandboxes: derive visibility and access from membership Sandboxes: scope visibility to direct membership May 18, 2026
elias-ba added 5 commits May 18, 2026 13:15
Joe (review) flagged that the original PR bundled two concerns: a
visibility bug (sandboxes appearing for users with no row on them) and
four access-rule changes (delete/rename cascade, merge gate, parent-admin
floor, superuser bypass). The visibility fix is uncontroversial and lands
here; the access-rule discussion moves to #4768.

Revert to main:
  - lib/lightning/policies/sandboxes.ex (restores root cascade and
    check_manage_permissions/3)
  - lib/lightning/projects/sandboxes.ex (restores parent_admin? helper)
  - lib/lightning_web/live/project_live/settings.ex (restores
    parent-admin floor in collaboration tab)
  - test/lightning/policies/sandbox_permissions_test.exs
  - test/lightning_web/live/project_live/sandbox_settings_test.exs

Surgically restore old behavior:
  - lib/lightning/projects.ex: re-add the parent_admin? raise in
    delete_project_user!
  - lib/lightning_web/live/sandbox_live/index.ex: replace
    manage_authority/2 with check_manage_permissions/3 (cascade-aware,
    keyed on workspace_root); revert decorate_for_render to the
    {update, delete, merge} shape; restore the superuser bypass in
    get_merge_target_options
  - test/lightning/sandboxes_test.exs: restore the "superuser becomes
    sandbox owner" provisioning test
  - test/lightning_web/live/sandbox_live/index_test.exs: restore the
    "editor on root can see and use merge button" assertion

Keep the visibility scope intact: visible_sandboxes filter, access_root
breadcrumb truncation, restricted-parent placeholder, picker collapsing
hidden intermediates, soft-navigation, and the merge confirmation modal
counting descendants without naming them.

CHANGELOG trimmed to describe only the visibility fix.
The placeholder added a stub to the Sandboxes page when the user's
access root has a parent they can't reach. Two reasons to drop it:

  - The global project picker hides inaccessible ancestors silently;
    surfacing them only on the Sandboxes page made the two surfaces
    inconsistent.
  - The information isn't actionable; the user can't request access
    or navigate up. The breadcrumb already starts at their access
    root, which is enough signal.

This trims a piece of new UI that wasn't strictly required to fix the
visibility bug.
The settings.html.heex was reverted to main as part of the scope
reduction; the test assertions were still expecting the new wording
introduced by the access-policy commits and were failing against the
old copy. Revert the test to main's assertions so it matches.
@elias-ba elias-ba changed the base branch from main to release/2.16.4 May 18, 2026 14:25
elias-ba added 3 commits May 18, 2026 14:45
Fix a partial-action bug: today an editor on the root project can click
Merge on a sandbox, the merge data sync runs, but the cleanup that
retires the source sandbox calls Sandboxes.schedule_sandbox_deletion/2
which requires admin/owner on the source. The merge ends up succeeding
for the data but the source stays in place.

This is a behaviour change, but bundled with the visibility fix because:

  - It's a real bug (the button promises something the action can't
    complete).
  - It aligns with the rest of Lightning, where destructive actions are
    admin/owner scoped.
  - The fix is small and self-contained: one boolean in
    check_manage_permissions/3.

Two tests in sandbox_permissions_test and one in sandbox_live/index_test
asserted the old "editor on root can merge" behaviour; updated to the
new rule. CHANGELOG and PR description updated.
Now that update, delete, and merge all share the same predicate
(admin/owner on the sandbox, with the cascade above), the per-key map
was storing three copies of the same boolean. Collapse the return to
%{sandbox_id => boolean()} and rename the local in decorate_for_render
to match.

Tests updated to assert on the flat boolean.
Aligns sandbox authorization with Lightning.Policies.ProjectUsers, which
governs regular projects and ignores User.role entirely when deciding
project access. Superuser is a type of user (used for global
user-management screens), not a project role. Treating it as a
project-access bypass was a one-off in the sandbox policy stack with no
equivalent on regular projects.

Updated:
  - :provision_sandbox, :merge_sandbox, :delete_sandbox/:update_sandbox
    in Lightning.Policies.Sandboxes (drop the user.role == :superuser
    branches)
  - check_manage_permissions/3 (drop is_superuser; has_full_privileges
    collapses to is_root_owner_or_admin)
  - get_merge_target_options/2 in SandboxLive.Index (drop the superuser
    branch from the target filter)
  - Sandboxes moduledoc and projects/sandboxes.ex authorization docs
  - Five policy tests + the provisioning test in sandboxes_test (invert
    the assertions: superuser without a project role gets no rights)

CHANGELOG and PR description updated.
@elias-ba
Copy link
Copy Markdown
Contributor Author

elias-ba commented May 18, 2026

This will fix #4745 right?

Yes, #4745 and #4762 describe the same visibility bug.

@elias-ba
Copy link
Copy Markdown
Contributor Author

elias-ba commented May 18, 2026

@elias-ba the diff here is kinda crazy which makes it a bit harder to read what's going on assess risk. Does it just need a rebase?

Scoped the PR down. The four access-rule changes (cascade, parent-admin floor, merge gate, superuser bypass) were originally bundled together. After your review, only the visibility fix plus the two access changes that I'd call straight bug fixes survived: the merge button now matches the action it triggers, and User.role: :superuser is no longer treated as a project-access bypass (consistent with Lightning.Policies.ProjectUsers). Everything else lives in #4768 for separate discussion. Also retargeted at release/2.16.4.

@elias-ba elias-ba requested a review from josephjclark May 18, 2026 15:28
Comment thread lib/lightning/policies/sandboxes.ex Outdated
@elias-ba elias-ba changed the title Sandboxes: scope visibility to direct membership Sandboxes: scope visibility, tighten merge gate, drop superuser bypass May 18, 2026
Comment thread lib/lightning_web/live/project_live/settings.html.heex
elias-ba added 2 commits May 18, 2026 15:45
Joe (PR review) noted that the :merge_sandbox section only documented
the target-side gate (editor+ on the target). The source-side gate
(admin/owner on the sandbox itself) was implied but not surfaced.

The merge flow has two gates: this authorize/3 clause checks the target,
and check_manage_permissions/3 plus the post-merge :delete_sandbox
cleanup check the source. Document both explicitly so the rule is
visible from the docstring.
Copy link
Copy Markdown
Collaborator

@josephjclark josephjclark left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great ,diff is clean and changes look like proper bug fixes, with the behavioral questions factored out.

Thanks @elias-ba

@elias-ba elias-ba merged commit ea1965f into release/2.16.4 May 18, 2026
5 of 7 checks passed
@github-project-automation github-project-automation Bot moved this from New Issues to Done in Core May 18, 2026
@elias-ba elias-ba deleted the fix/hide-non-accessible-sandboxes-4762 branch May 18, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Hide non-accessible sandboxes from the sandboxes list

3 participants