Skip to content

Sandboxes DevX - #5137

Draft
elias-ba wants to merge 95 commits into
mainfrom
sandbox-devx
Draft

Sandboxes DevX#5137
elias-ba wants to merge 95 commits into
mainfrom
sandbox-devx

Conversation

@elias-ba

@elias-ba elias-ba commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Description

This PR delivers phase 1 of the sandbox developer experience. A workflow stops
being something you edit in place while it processes production data, and gains a
lifecycle you drive from the editor.

A workflow is now either a draft or live. state is a real column, backfilled
from whether any trigger was enabled, so liveness is no longer inferred from
trigger flags. Going live turns the triggers on and records a version; switching
back to draft turns them off. A live workflow is read-only on its own project, and
that lock is enforced on the channel carrying the edits rather than in the UI, so
it holds against a second tab.

To change a live workflow you edit it in a sandbox. Edit in sandbox creates one or
joins one already open on that workflow, clones the workflow in as a draft, and
takes you there without reloading the page. You choose what the sandbox starts
with: nothing, one of the project's named inputs, or the input from the run you
are looking at, which you review and can redact before it leaves production. What
you see is what lands, because the reviewed body is created in the sandbox rather
than the original row being copied.

In the sandbox you turn the workflow on, which gives it its own webhook URL and
fires its own schedules while the parent keeps running, then you promote. Promote
saves, merges that one workflow into the parent, and leaves the parent live
throughout. It warns first when the parent has changed since the sandbox was
created, because promote replaces rather than merges. It no longer archives the
sandbox, so several workflows can be promoted from one, and archiving is a
separate step that turns the triggers off and schedules deletion.

Publishing now leaves a trail. A release is recorded at each go-live, promote and
restore, with who published it and where it came from. The editor's version list
reads releases rather than every intermediate save, so ?v= now means a release
number. Recent History tags each run with the version it ran against, or Draft
when it ran against a snapshot that was never published, and pinning a version
scopes the feed to it. Opening a run that executed against different content opens
the workflow exactly as that run saw it, through ?as_run=, which works for runs
that were never published. The version-mismatch banner is gone.

Any published version can be restored. Restore puts that version's content back
and publishes it, so production keeps running throughout, and it says first which
triggers it will delete, which URLs stop answering, and which come back switched
off. The versions in between stay in the history.

The editor asks before it throws away unsaved changes. Switching version, opening
a run that pins one, and leaving for a sandbox each destroyed the collaborative
document and took uncommitted edits with it, silently. Each now offers to save
first, and closing the tab warns.

Finally, a project records which of a credential's value sets it may read, on the
sharing row. That used to be decided by matching the project's environment name
against the credential's value sets at run time, and that name is an ordinary
project setting.

Closes #5130, #4920, #5052, #4865, #5134, #4864, #5141.

Phase 2 stays open on #4852.

Validation steps

  1. On a workflow with a trigger enabled, check it reads Live, is read-only, and
    that a second tab cannot edit it either.
  2. Switch it to draft, check the dialog says it stops processing data, then go
    live again and check a version is recorded with your name.
  3. On a live workflow, choose Edit in sandbox. Check it offers both creating one
    and joining any already open on that workflow, and that leaving does not
    reload the page.
  4. Open a run, choose Edit in sandbox, pick the run's input, redact something in
    the review step, and check the sandbox opens on the run panel with what you
    saw selected, not the original.
  5. In the sandbox, turn the workflow on, call its webhook URL, and check the
    parent is untouched. Turn it off and check the URL stops answering.
  6. Promote. Check the parent goes to the sandbox's content while staying live,
    that the sandbox is still there afterwards, and that archiving it lands you on
    the parent's copy of the workflow.
  7. Change the parent's workflow, then promote from the sandbox again. Check the
    dialog warns that promoting replaces the newer parent work.
  8. Restore an earlier version. Check the dialog names the triggers it will delete
    and the ones coming back switched off, that production keeps running, and that
    the versions in between stay in the list.
  9. With unsaved edits, switch version, open a run that pins one, and leave for a
    sandbox. Each should offer to save first. Close the tab and check the browser
    warns.
  10. Open an old run and check the workflow renders as that run executed it, and
    that Recent History tags runs with their version, or Draft.
  11. On a root project, check credentials resolve exactly as before.
  12. In a sandbox, run a job with a credential. It should refuse and name the
    credential. Grant it a value set on the project's credentials tab and run
    again. As an editor, check the column is readable and not changeable.

Additional notes for the reviewer

  1. The credential change breaks working sandboxes on purpose. Sandboxes start
    granted nothing, so every sandbox with a credential wired to a job stops
    resolving until the credential's owner grants it. There is no override,
    because an override is the hole. On our instance that is 58 sandboxes, and
    the backfill prints the count on the way past.
  2. Every workflow with an enabled trigger becomes live, and therefore read-only
    on its own project. People will meet this the first time they open one.
  3. ?v= changes meaning from a snapshot lock version to a release number. Links
    inside the app were moved to ?as_run=, but old bookmarks will land on the
    wrong version or fail to resolve.
  4. Five migrations, three of them backfills. The credential one aborts the deploy
    if any project_credentials row has a NULL credential_id, deliberately,
    because the composite foreign key cannot constrain those rows and the fix is
    an operator decision rather than a migration's.
  5. Projects.Environment is deleted. Nothing asks its question any more.
  6. Two loose ends I would rather you saw than found: PromotedNotice can no
    longer fire, because the archive path lets the server redirect and nothing
    sets ?promoted=1; and set_trigger_enabled is wired end to end with no
    control in the UI.
  7. test_elixir is red on one integration test that needs a worker running.
    Everything else is green.

AI Usage

Please disclose whether you've used AI anywhere in this PR (it's cool, we just
want to know!):

  • 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

elias-ba added 30 commits June 16, 2026 14:29
Adds a :draft | :live state to Lightning.Workflows.Workflow and a
migration that backfills existing workflows with an enabled trigger to
:live, preserving current behaviour. No behaviour change yet.

Part of #4857
go_live sets a workflow to :live and enables its triggers; switch_to_draft
sets it to :draft and disables them. Both go through the existing
save_workflow path (snapshot + version recording). No UI wiring yet.

Part of #4857
Adds Workflows.editable_state?/2 and ANDs it into the workflow channel's
can_edit_workflow, so a :live workflow on a non-sandbox project is read-only
(the editor's existing read-only path then applies). Drafts and the cloned
workflow inside a sandbox stay editable.

Part of #4857
* Add lifecycle editor UI: state badge, Go live, Switch to draft

Surfaces the workflow lifecycle in the collaborative editor:
- draft/live badge and a state-driven primary action (Go live when a
  draft, Switch to draft with a confirmation when live).
- A Session transition (set_workflow_state) that flips state + trigger
  enablement and persists with the document in one save, reconciled into
  the Y.Doc; driven by go_live / switch_to_draft channel events.
- The workflow's state is serialized to the editor and read via a new
  useSessionWorkflow hook; after a transition the session context is
  re-fetched so the badge and read-only lock update.

Completes #4857

* Hide Run button in read-only (live) states

Run (test) is only meaningful in editable states (draft, sandbox). It is
now hidden when the editor is read-only, matching the concept where live
and archived states have no Run action.

Part of #4857

* Mock useSessionWorkflow in collaborative editor keyboard test

The Header now reads the session-context workflow via useSessionWorkflow;
the keyboard test mocks useSessionContext, so it needs the new export or
the Header throws on render.

Part of #4857

* Cover go_live/switch_to_draft channel events and the no-shared-doc path

Channel tests drive go_live and switch_to_draft (happy path + unauthorized
rejection) through a joined socket, covering the handlers and
transition_lifecycle_state. A session test covers the no-shared-doc error
branch of the shared save pipeline.

Part of #4857

* Cover changeset-error path; ignore defensive save branches

Adds a test that triggers an interpolated changeset error (concurrency < 1)
to cover the error-formatting path, and wraps the two genuinely-defensive
save branches (:wrong_project, documented unreachable; :deserialization_failed,
a catch-all rescue) in coveralls-ignore, matching the repo convention.

Part of #4857
Trigger enabled was previously something a user could set directly from
several surfaces, independently of a workflow's draft/live state. That let a
draft workflow run a live trigger against production, contradicting the
lifecycle contract. Trigger enabled now follows the lifecycle, with go live
and switch to draft as the only controls that change it.

- Route the dashboard on/off toggle through go live / switch to draft so
  state and triggers always move together (activation usage limit preserved).
- Remove the standalone enable controls from the collaborative editor header
  and trigger inspector, and drop the now-dead client enable plumbing so the
  client can no longer desync triggers from state.
- Infer workflow state from triggers on import so a live workflow exported
  and re-imported round-trips as live instead of silently reverting to draft.

The legacy editor toggle is left untouched as it is being sunsetted. Carrying
state in the project.yaml wire format, hardening the read-only lock on the
sync channel, and the AI prompt cleanup are tracked as follow-ups.
From a live workflow, "Edit in sandbox" opens a picker to either branch a new
sandbox from the current live version or join an active one, then lands the
user in the sandbox editor with that workflow's trigger live and its own
endpoint. The parent stays untouched until a change is promoted back (a later
slice of the epic).

- New channel events: list a parent's active sandboxes (with collaborators and
  the joinable workflow, sorted by last edited) and create-and-open a sandbox.
- Creating clones the parent project, then promotes the edited workflow to live
  inside the sandbox; the other cloned workflows stay draft so state and
  triggers remain coherent. The sandbox copy stays editable because the
  read-only lock only applies to live workflows outside a sandbox.
- A sandbox badge in the editor header. Server-side errors (permission, usage
  limit) surface their real message in the picker rather than a generic one.
Picker:
- List only joinable sandboxes, and hide the join section when there are none
- Show each row with the creator's avatar, name and the sandbox's creation time
- Require a name to create a sandbox
- Truncate long workflow names in the header so the save action stays visible

Backend and channel:
- Consolidate provisioning and promotion with a compensating delete so a failed promote leaves no orphan sandbox
- Gate content edits on a live workflow server-side; keep lifecycle transitions role-only
- Handle the nesting-too-deep error and add a catch-all so the channel cannot crash the socket
- Restrict the sandbox listing to users who can create or join
- Extract a shared workflow lookup and colour helper, drop duplicated code

Editor:
- Surface field-level channel errors through the notifications service instead of raw toasts
Copying the trigger URL is a read action, but the read-only lock was
disabling it along with the real editing controls. Expose the read-only
reason from useWorkflowReadOnly and keep copy enabled when the workflow is
merely locked (live, or no edit permission), while still disabling it when
the shown URL is not a current endpoint: a deleted workflow, a pinned
historical version, or an unsaved new workflow.

Closes #4990
State that switching to draft takes the workflow out of production and
disables its triggers, instead of the vague 'stops processing data'. The
dialog already closes on Escape.

Closes #4989
Go live, Switch to draft, and Edit in sandbox used shadow-sm, heavier than
the shared Button component's shadow-xs, so they stood out from the rest of
the app. Align them to shadow-xs.

Closes #4991
Review fixes:
- Gate "Edit in sandbox" on a new can_provision_sandbox permission, so users
  who cannot provision see it disabled with a reason instead of an error on
  click (brings #4920 forward)
- Add a "live" read-only reason so live workflows stop showing "you do not
  have permission to edit", and keep the webhook Copy URL enabled on them
- Return only joinable sandboxes from list_sandboxes, server-side
- Log the compensating-delete failure in provision_editing_sandbox
- Rename the sandbox row's "creator" to "owner" (accurate after a transfer)
- Extract a shared channel-error helper in the picker; add nesting_too_deep
  to the channel error type unions

In-sandbox header:
- Remove the sandbox badge and hide the draft/live badge inside a sandbox
- Replace Go live / Switch to draft with a disabled "Promote" button
  (coming soon) inside a sandbox
Flatten the nested cards into a single panel separated by a hairline divider;
anchor each row with a thin colour stripe from the sandbox's own colour instead
of repeated owner initials; make the whole row the click target with a quiet
Join that fills in and reveals an arrow on hover; add a loading skeleton, an
accessible input label, and relative timestamps with the exact date on hover
via the app's Tooltip; refine the create button and drop the beaker icon;
replace the bottom Cancel button with a corner close (X).
- Enforce the live-workflow lock on update_trigger_auth_methods (was role-only)
- Show the "live" read-only message only to users who can act on it (switch to
  draft / edit in sandbox); viewers get the plain no-permission message
- Enable webhook Copy URL on pinned versions (the endpoint is stable)
- Give the truncated header workflow name a tooltip with the full name
- Sort the picker list by created time to match the "Created" label, preload
  only the sandbox owner, and document the intentional save-path re-read
- Remove the dead client-side joinable filter and cap the join list height with
  an internal scroll for users with many sandboxes
Both were Headless UI dialogs relying on default Escape handling, which the
editor's higher-priority IDE/inspector Escape handlers intercept first. Register
a MODAL-priority Escape handler on each (matching ConfigureAdaptorModal) so they
close on Esc.
Header now calls usePermissions directly for can_provision_sandbox, but this
test's useSessionContext mock did not export it, so Header threw on render.
MergeProjects built each trigger with the kafka_configuration embed left as a
%KafkaConfiguration{} struct (stringify_keys only touches top-level keys), but
the provisioner import expects plain maps, so merging or promoting a workflow
with a Kafka trigger raised an Ecto.CastError. Serialize the config to a plain
map (the fields the import round-trips, credentials omitted), matching the
export path. Pre-existing: the sandbox-merge LiveView flow crashed the same way.
From a workflow open in a sandbox, a Promote action merges it back into the
parent using the existing sandbox merge, scoped to that workflow, then archives
the sandbox. Authorized by merge_sandbox (editor/admin/owner on the parent),
resolved server-side. A two-button confirm dialog; on success the user lands in
the parent project. The archive is best-effort and non-atomic, so a
merged-but-not-archived result is reported gracefully.

Part of #4929.
Save the sandbox before promoting (aborts if the save fails), so promote
reflects the current editor state; the confirmation now reads "Save and
promote". Add a "Promoting..." spinner state, and show the success toast on
the parent page after navigation (via a query param the editor reads on load)
so the hard navigation no longer wipes it.

Part of #4929.
A sandbox merge/promote (and the provisioner import path generally) writes the
workflow to the database but never refreshed the workflow's live collaborative
document, so anyone on that workflow saw stale content and a stale version
badge whenever its SharedDoc was still warm. Pre-existing: the sandbox-merge
LiveView had the same gap.

The provisioner import now broadcasts a reconcile request per affected workflow
after its transaction commits; the process that owns the live SharedDoc
subscribes and resets the document in place from the database (which also sets
lock_version, fixing the badge), reusing the lifecycle reset mechanism. The
Y.Doc mutation only runs in the owning process, so no transaction crosses a
process boundary. Merge and promote inherit this via the provisioner.

Part of #4929.
…g lifecycle controls

Promote now merges the current sandbox workflow into the parent and no longer archives the sandbox, so several related workflows can be promoted from one sandbox before it is retired. Archiving is a separate, explicit action gated on the delete-sandbox permission, which is also surfaced to the editor.

Add a way to enable a trigger on a non-live (draft or sandbox) workflow for testing without going live; it refuses live workflows and reuses the shared trigger-update path. On import, a brand-new workflow's draft/live state is inferred from its triggers (any enabled trigger means live), while an existing workflow keeps its stored state on a round-trip. Edit-in-sandbox brings the workflow in disabled, and a failed provision cleans up the half-built sandbox.

Part of #4929.
Add the two-phase promote dialog (confirm, then optionally archive the sandbox), keeping the user in the sandbox when they choose to keep it so they can promote another workflow. Restore the trigger enable/disable toggle in the trigger inspector footer for non-live workflows, guarded by a warning (with a 'don't show again' preference) before enabling one against real systems.

Harden read-only workflows: hide run-creation everywhere rather than showing it disabled, stop undo/redo from mutating a live workflow, and hide edit actions that were only rendered disabled. Polish the header (hide Save on read-only, move the lifecycle badge next to the version picker with a tooltip, drop the redundant read-only pill), restyle the confirmation dialogs, and refine the edit-in-sandbox picker (submit on Enter, inline duplicate-name error).

Part of #4929.
Publishing a workflow now writes a workflow_releases row: a sequential version
number, the snapshot it published, who published it and, for a promote, the
sandbox it came from. Existing live workflows are backfilled with a v1.

A workflow predating the snapshot system has no snapshot to point at. Going live
skips the release for those rather than failing the save, matching the workflows
the backfill migration logs as skipped.

Nothing reads these rows yet. The editor's version list still shows snapshots.

Part of #4866
workflow_releases holds its snapshot with an on_delete: :restrict foreign key,
but unused_snapshots/0 only knew about workflows, work orders, runs and steps. A
released snapshot that nothing else referenced was selected for deletion, and the
data retention job failed on the foreign key instead of skipping it.

Part of #4866
Going live on a workflow that is already live changed nothing, so no snapshot
was captured and the code fell back to the current snapshot. That minted a new
version pointing at the same content as the previous one, and it allocated the
version number without a lock: Ecto issues no UPDATE for an empty changeset, so
the optimistic lock never runs. Two of those racing hit the unique index, and
the resulting WorkflowRelease changeset was written into the Y.Doc errors map,
so the editor showed "version_number exists for this workflow" and the go-live
failed silently.

Recording only when the save captured a snapshot fixes all three, and matches
promote, which already records nothing for a merge with no changes.

Also from review: the backfill keeps one snapshot per workflow with DISTINCT ON,
since nothing enforces one snapshot per lock_version and a duplicate would take
the migration down; the migration's down no longer deletes real releases whose
author was nilled by an account purge; and project deletion with a released
workflow is now covered by a test, which is what settles that the restrict
foreign key does not abort the cascade.

Part of #4866
The previous commit's replacement matched both [#PR] placeholders, not just
this PR's.
The epic branch was cut on 26 June and had fallen 126 commits behind main.
Bringing main in now rather than at the end of Phase 1, so the small PRs stack
on something current.

Twelve files conflicted. Most were both sides adding different things and both
being kept. Three needed real integration.

index.ex takes main's authorization and project-scoped lookup, with the epic's
lifecycle transition inside it. main fixed that handler on 21 July; the epic
branch predated the fix and would have reverted it, letting any authenticated
user flip another project's workflow live or take a live one offline. main's two
regression tests for that come across with it.

workflow_channel.ex takes main's join-time permission assigns and folds the
epic's editable_state? into user_permissions/4. That assign also gates
forward_yjs_message?, so a live workflow's read-only lock now drops inbound
document frames on the server instead of only disabling the UI. That is what
4898 asked for.

session.ex keeps the epic's do_save_workflow/3 extraction and ports main's
{:error, :snapshot_failed} clause into it. workflows.ex returns that and the
spec declares it, so leaving it out would have raised WithClauseError.

TriggerInspector and types/workflow.ts take main's side outright: main's rewrite
already drops the Enabled toggle the epic wanted gone, and nothing references
the Actions interface main deleted.

Elixir: 5625 tests, 15 failures, all integration or environment (no worker, no
CLI). Frontend: 2902 passing, 0 failures. Format, credo and dialyzer clean. The
three TypeScript errors in js/yaml predate this on both branches.
Everything else in the sandboxes epic gives people new ways to change
production. This is the only way back.

A restore is a publish, not an edit. It writes the chosen release's snapshot in
as the workflow's content and leaves the lifecycle state alone, which is
guaranteed rather than checked: the workflow changeset cannot express state at
all. So production keeps running through the rollback.

Surviving triggers keep the enabled flag they have. Putting an old one back
would stop production receiving in the middle of a rollback, which is the worst
possible moment, and it is also what a promote already does. That makes the
restore faithful in content but not in enablement, deliberately.

Anything the snapshot does not hold is deleted. That is what makes this a
revert rather than a merge, and it is why a trigger added since disappears and
the URL built from it stops answering. The UI has to say so before it happens.

The result is recorded as the next version, labelled with the version it came
from, so the trail reads forward and the bad version stays in the history.

This is the server half. The dropdown action and its confirmation follow.
The restore itself is gated on :edit_workflow rather than the content-edit
gate, which refuses a live workflow outside a sandbox and would refuse the only
case that matters. go_live and promote answer it the same way.

It reads the workflow row again rather than trusting the struct the socket
joined on. The replacement is computed against those children, so a stale one
leaves rows behind that the snapshot does not hold, and a stale lock_version
crashed the channel instead of replying.

request_restore_check names the triggers the restore will delete, so the
confirmation can say which URLs stop answering before anyone agrees.
A project asserts an environment name, and credential resolution matches that
name against the credential's bodies at run time. The name is typed on the
project settings screen; the values are typed on the credential screen, by a
possibly different person. Nothing ties the two together, so typing the right
name is enough to read someone else's values. That is how a sandbox reaches its
parent's production secrets today.

This adds the grant to the share itself. project_credentials now names the
exact credential_bodies row it may read, so the decision is recorded rather
than reconstructed from a string at run time, and renaming anything cannot
widen it.

Nullable, and nil means no grant, which will resolve nothing. The foreign key
is composite on (credential_id, credential_body_id) so a share can only grant a
body of the credential it was given; a single-column reference would be the
same hole in a new place. NO ACTION rather than RESTRICT because deleting a
credential relies on two cascades firing in one statement, and RESTRICT checks
too early to allow that.

Nothing reads the column yet, so behaviour is unchanged.
The action sits on the version's own row in the dropdown rather than in the
header button group. Those buttons all act on the workflow on screen and are
deliberately hidden on a pinned view; restore is the one action that is about
the version being read, so it belongs with the version.

The confirmation is not a warning. Restoring is a deliberate rollback, so it
says what it costs and gets out of the way: which triggers disappear, which
webhook URLs stop answering, and that the versions in between stay. It cannot
be confirmed until the server has answered what that is, and an unanswered
check reads as nothing to lose rather than blocking the rollback.

Only offered to someone who can edit the workflow, and never on the newest
release, which is what is already live.
…where

Creating a credential with one body grants it to the project's share, so the
credential works where it was made. With more than one body the choice belongs
to a project admin and the share stays ungranted until they make it.

Three paths deliberately grant nothing, each now saying so in a comment
because the omission is the point rather than an oversight. A sandbox clone
gets a reference to the parent's credential and no access to its values.
Merging a sandbox-only credential up gives the target a share it has not yet
chosen values for. Adding a credential to a parent project reaches every
sandbox beneath it and gives none of them the parent's values.

Nothing reads the grant yet, so behaviour is unchanged.
Review found all of these and every one is real.

A trigger the restore had to re-create came back switched off while the
workflow stayed live, so the URL silently stopped answering and the dialog had
said nothing about it. A snapshot never recorded which webhook auth methods
were attached to a trigger, so bringing it back on for the user would put the
URL back without its authentication, which is worse. It still comes back off,
and now the dialog says so and says why.

The response config is an embed, and omitting it left the key absent, which
Ecto reads as leave alone. So a restored trigger kept replying with the status
codes from the version being rolled away from. It is written now.

Writing the snapshot's positions unconditionally wiped a hand-arranged canvas
whenever the restored version had none, which is any version published while
the canvas was on auto-layout. Only written when the snapshot holds them.

A concurrent save raised an unrescued StaleEntryError, which killed the channel
and left the client waiting out its timeout for a reply that never came. It is
refused with a message that says what happened.

The restoring client was excluded from the broadcast and told nothing else, so
its version chip read as an old version straight after a rollback and its
dropdown still offered Restore on the version now live. It gets the context
push the go-live path already sends.

And a check for a version the user had since cancelled could land after a later
one, replacing a real warning with silence, which is exactly the failure the
dialog exists to prevent. The answer is keyed on the version it is about.

Also softens the new version field's parse so a rolling deploy cannot blank the
dropdown, and covers the restore label, which nothing tested.
Every root project's share is granted the body it resolves today, which is the
one named after its environment, or main for a root that predates the column.
So nothing changes for a root project when the readers switch over.

Sandboxes are left ungranted, including the ones whose environment name happens
to match an ancestor's. Granting those would record the accident as a decision
and keep them reading production. The migration prints how many sandboxes have
a credential wired to a job, because that is the number of projects someone has
to choose values for before the readers switch.

Choosing is now possible. A project's credentials tab shows which values each
credential uses here and lets an admin change it, or take it away. An editor
sees the choice without being able to make it: they write the job that spends
the credential, they do not decide which values it spends.

The readers still match environment names, so behaviour is unchanged and the
hole is still open. That is the next commit.
This is the commit that closes it.

Credential resolution now asks the project's share which values it may read.
It no longer asks the project what its environment is called and then looks for
a body with that name. So a sandbox typing its parent's environment name reads
nothing, which is the exposure, and it was reachable by any editor on a
production project: creating a sandbox makes you its owner, a sandbox owner can
edit the project's environment, and the sandbox already holds a reference to
every one of the parent's credentials.

Every reader moves together, in one commit, because two readers disagreeing
about how a credential resolves is the bug rather than a step towards fixing
it. That is the run path, the channel proxy's destination auth, and the
editor's adaptor metadata. Projects.Environment goes with them: it existed to
answer a question nobody asks any more.

No grant is a refusal, not a fallback. The error names the credential and
points at where the choice is made, and no longer suggests changing the
project's environment to match, which was the exposure shipped as product copy.

The scrubber now masks every body of a credential rather than the one an
environment named. Nothing records which values a step actually spent, so
narrowing it would be guessing, and a wrong guess puts a secret on screen.
That also fixes a live bug where the first credential's environment was
applied to all of them.

The attack is written down as a test: a sandbox set to its parent's
environment, resolving nothing.
save_workflow only ever hands these a changeset, so the fallbacks could never
match. CI caught it.
Review found the hole still open, and it was right. I gated the grant on
:edit_project on the project holding the share, and creating a sandbox makes
you its owner. So an editor on a production project could provision a sandbox,
become its owner, and grant themselves the parent's production values through
the picker. Two clicks instead of a rename, and the picker listed the
production environment by name to make it easy.

The values belong to the credential, so the decision does too. Anyone else sees
the name of the set in use and no picker, which also stops a project's list
disclosing the other environments on someone else's credential.

Second hole, same review: an ordinary credential edit granted every ungranted
share. So renaming a credential handed every sandbox beneath it the parent's
values, and deleting an environment resurrected a grant someone had
deliberately revoked, because no grant and never chosen are the same absence.
The grant is now set only on the shares a create inserted.

Both are written down as tests that fail if either gate is put back.

Also fixed, all from the same review. The editor's metadata task crashed on
every ungranted project, twice over: an unhandled reason in the metadata
service and a tuple reaching a channel that cannot print one. Deleting an
environment a project was granted raised a 500 instead of telling the form
which projects are using it. A CLI deploy and kickstart created shares that
could never resolve, so they take the only set of values when there is no
choice to make. And priv/schemas went in as a machine-local symlink again,
which the trailing slash in .gitignore does not catch.

The last two readers that matched an environment name are gone, along with the
tests that were keeping them warm. That was the trap the deleted Environment
module existed to close, and leaving the readers would have reopened it.
It is tracked, so .gitignore does not stop it coming back on a git add. The
trailing slash there matched only directories; that is fixed too.
Six more from review, all real.

Any logged-in user could kill their own session with one frame on their
credentials page: the grant event is live there with no project, and LiveView
routes a frame by component id without checking the event appears in that
component's markup. A nil project is a refusal now.

The catch-all added last round crashed on the very reason it was added for. An
OAuth refresh can hand back a raw response map or a changeset, and to_string
takes neither. And I removed the channel's fallback on dialyzer's word, but
dialyzer was trusting a spec that lied: the task worker refuses before the
metadata service runs when it is at its process cap, so not every refusal is
one the service named. Both use inspect, and the spec says what it returns.

The transaction error mapper only knew the two operation names about sets of
values, and the multis also carry audit and propagation steps, one of them
named with a tuple. Those fall through now.

Two callers of a reader I deleted were left behind, one of them a failing test.

The run-failure message told people a project admin makes the choice, which
stopped being true when the gate moved to the credential's owner.

And the new part, which the review made the case for without meaning to: those
58 sandboxes stop resolving the moment this ships, and finding out at run time
is a poor way to learn. The credential badge in the editor now says when this
project was given no values for it, and who chooses them. A keychain says
nothing, because it picks its credential from the run's own data and there is
no single set to have been granted.
The picker set window.location, so switching into a sandbox was a full page
load: white flash, socket dropped, everything rebuilt.

It asks the LiveView to navigate now. That keeps the socket and removes the
flash, and it is a navigation rather than a patch deliberately. A patch keeps
the mount, and the project scope and the workflow-ownership check are mount
hooks, so a patch would leave the old project's scope, project user and access
root resolved. That is the authorization gate not running, not a stale label.
A navigation remounts, so the gate is correct by construction rather than by
anyone remembering to move it.

The old hard navigation stays as the fallback, because the picker is mounted
whether or not there is a LiveView to ask. That is also why it reads the
actions without insisting on a provider.

Nothing needs to suppress the unload warning on this path any more: the page
never unloads, so there is no warning to suppress and no suppression left
standing with no pageshow to clear it.

This is the cheap half of #4865. Doing it by patch, which is what the issue
describes, means moving the access gate to per-params first, and that is the
part with the security trap in it.
Both branches rewrote the Edit-in-sandbox picker: this one to guard the
navigation against unsaved changes, that one to reword the dialog, move its
buttons onto the shared component and lock Create behind the plan's limit. Took
the reworded picker and put the guard back on top of it, so the guard now wraps
the shared Button and the locked Create rather than the old markup.
The picker gained both a live redirect here and a locked Create there; both
kept. Drops the trigger enabled control's test, since that PR deleted the
component as dead.
The task lowers the level to :error so the repo starting up does not drown the
hash it prints, and never puts it back. The level is one setting for the whole
VM, so once its test has run every later assertion on a log line finds nothing
there. That is 13 of the 16 failures on this branch, scattered across
ApplicationTest, ObanManagerTest, AdaptorRegistryTest and others depending on
where the task's own test lands in the order.
The label counts elapsed seconds, so a deletion scheduled exactly one day out
is under 24 hours by the time the page renders and reads "(today)". The test
now schedules two minutes past the boundary. The label saying "(today)" beside
tomorrow's date is a separate thing, left alone here.
It passed `silent: true`, which the save handler does not read, so the pre-promote
save toasted "Workflow saved" underneath the dialog. The option it wanted is
`notify: 'none'`.
A snapshot's job carries association keys it never loads, its credential among
them, and the workflow built from a snapshot handed those to the JSON encoder
that renders the session context. Encoding an unloaded association raises, which
took the channel down, so the client waited out its timeout for a reply that
never came and Recent History read "Request timed out". Reaching a version
through ?as_run= is the same path.

assert_reply never encodes, which is why the existing test passed while the
page did not, so the test now encodes the reply.
Brandon's call: fix the environment hole by stopping anyone changing a project's
environment, and leave the sharing UI alone. So the grant on the sharing row, the
"Values used here" column, the resolver switch, the two migrations and the
no-values badge in the editor all come out, and Projects.Environment comes back.

Reverts 6584c96, 2f92c82, 68e8427, a98eab5, 5ecfc59, 73a1f4e
and fe4e507.
The environment decides which of a credential's value sets the project reads, and
it is a text field on project settings that any project admin could type. A
sandbox holds a reference to every credential its parent holds and creating one
makes you its owner, so a sandbox admin could name the environment after the
parent's and read the parent's production values.

The field stays on the page, because it tells you which values resolve there, but
it is disabled, and env comes out of both places that accepted it from a user, the
settings form's allowlist and update_sandbox. Our own code still sets it.

Sandboxes whose environment was already changed keep the access they have until
the values are reset, which is a data fix rather than a code one.
The dropdown asked for versions whenever the list was empty and no request was
in flight. On a workflow that has never been published the answer is an empty
list, so the reply cleared the in-flight flag, the effect saw an empty list
again, and asked again. The panel never left "Loading versions..." and the
channel took a request every few milliseconds.

The store now records that a request finished, whatever came back, and the
dropdown reads that instead of the list's length. Invalidating the cache on a
new lock version clears it too, so a publish is still picked up.
The list only holds publishes, so 'No versions available' read as though the
workflow had none of anything, when a draft that has been saved has snapshots
and a content hash, just nothing published.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New Issues

Development

Successfully merging this pull request may close these issues.

A sandbox has no way to turn its workflow on

1 participant