Lightning.Projects.Sandboxes (Lightning v2.16.3)

View Source

Manage sandbox projects - isolated copies of existing projects for safe experimentation.

Sandboxes allow developers to test changes, experiment with workflows, and collaborate without affecting production projects. They share credentials with their parent but maintain separate workflow execution environments.

What gets copied to a sandbox

  • Project settings: retention policies, concurrency limits, MFA requirements
  • Workflow structure: jobs, triggers (disabled), edges, and node positions
  • Credentials: references to parent credentials (no secrets duplicated)
  • Keychain metadata: cloned for jobs that use them
  • Version history: latest workflow version per workflow
  • Optional dataclips: named clips of specific types can be selectively copied

Operations

  • provision/3 - Create a new sandbox from a parent project
  • merge/4 - Merge a sandbox into its target (workflows + collections)
  • update_sandbox/3 - Update sandbox name, color, or environment
  • schedule_sandbox_deletion/2 - Soft-delete a sandbox and its descendants; they remain in the database for a grace period before being purged
  • cancel_scheduled_sandbox_deletion/2 - Restore a scheduled sandbox subtree while it is still within its grace period (admin recovery path)
  • delete_sandbox/2 - Hard-delete a sandbox and all its descendants immediately (used by the Oban purge worker after the grace period elapses)

Authorization

  • Provisioning: Requires :editor, :admin, or :owner role on the parent project, or superuser
  • Merge: Requires :editor, :admin, or :owner role on the target project, or superuser
  • Updates/Deletion: Requires :owner or :admin role on the sandbox itself,
                      or `:owner` or `:admin` on the root project, or superuser

Transaction safety

All operations are performed within database transactions to ensure consistency. Failed operations leave no partial state behind.

Summary

Types

Attributes for creating a new sandbox via provision/3.

Functions

Clears the scheduled deletion on a sandbox subtree, restoring it to active use.

Deletes a sandbox and all its descendant projects.

Merges a sandbox into its target project.

Returns true when user has an :admin or :owner role on any ancestor of project, walking the parent chain.

Creates a new sandbox project by cloning from a parent project.

Schedules a sandbox and its entire descendant subtree for deletion.

Synchronises collection names from a sandbox to its merge target.

Updates a sandbox project's basic attributes.

Types

provision_attrs()

@type provision_attrs() :: %{
  :name => String.t(),
  optional(:color) => String.t() | nil,
  optional(:env) => String.t() | nil,
  optional(:dataclip_ids) => [Ecto.UUID.t()]
}

Attributes for creating a new sandbox via provision/3.

Required

  • :name - Sandbox name (must be unique within the parent project)

Optional

  • :color - UI color hex string (e.g. "#336699")
  • :env - Environment identifier (e.g. "staging", "dev")
  • :dataclip_ids - UUIDs of dataclips to copy (only copies named dataclips of types :global, :saved_input, or :http_request)

The sandbox's project_users are derived from the parent project: every parent user is copied across with their role preserved, except the parent owner who is demoted to :admin. The actor is then set as the sandbox owner (replacing any other role they may have had on the parent). To add a user to the sandbox who is not on the parent, call Lightning.Projects.add_project_users/3 after provision returns — that path goes through the seat-limit check.

Functions

cancel_scheduled_sandbox_deletion(sandbox, actor)

@spec cancel_scheduled_sandbox_deletion(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | term()}
  | Lightning.Extensions.UsageLimiting.error()

Clears the scheduled deletion on a sandbox subtree, restoring it to active use.

Walks every descendant of sandbox and clears scheduled_deletion on any row that has it set. Triggers are not automatically re-enabled: this is an admin recovery path and the operator decides whether the subtree should resume firing triggers.

Cascade semantics

The cancel clears scheduled_deletion on every descendant that has it set, regardless of whether the schedule originated from this subtree's parent or from a separate scheduling action on the descendant itself. Any row whose scheduled_deletion is already nil is left alone.

Limit

Restoring a sandbox moves it back into the active count, so the same usage-limit action that gates new sandbox creation also gates restore. When the active-sandbox count is already at the limit, restore is refused with {:error, :too_many_sandboxes, message}; the operator needs to delete an active sandbox first.

Parameters

  • sandbox - Sandbox project to restore (or sandbox ID as string)
  • actor - User performing the action (needs :delete_sandbox permission)

Returns

  • {:ok, restored_sandbox} - Sandbox subtree restored
  • {:error, :unauthorized} - Actor lacks permission on the sandbox
  • {:error, :not_found} - Sandbox ID not found (when using a string ID)
  • Lightning.Extensions.UsageLimiting.error() - Limit reached

delete_sandbox(sandbox, actor)

@spec delete_sandbox(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | term()}

Deletes a sandbox and all its descendant projects.

Parameters

  • sandbox - Sandbox project to delete (or sandbox ID as string)
  • actor - User performing the deletion (needs :owner or :admin role on sandbox)

Returns

  • {:ok, deleted_sandbox} - Successfully deleted sandbox and descendants
  • {:error, :unauthorized} - Actor lacks permission on sandbox
  • {:error, :not_found} - Sandbox ID not found (when using string ID)
  • {:error, reason} - Database or other deletion error

Example

{:ok, deleted} = Sandboxes.delete_sandbox(sandbox, user)

merge(source, target, actor, opts \\ %{})

Merges a sandbox into its target project.

Imports the sandbox's workflow configuration into the target via the provisioner and synchronises collection names. Runs inside a single transaction. Collection data is never copied.

Callers must authorise the merge before calling (e.g. :merge_sandbox).

Parameters

  • source - The sandbox project being merged
  • target - The project receiving the merge
  • actor - The user performing the merge
  • opts - Merge options (:selected_workflow_ids, :deleted_target_workflow_ids)

Returns

  • {:ok, updated_target} - Merge succeeded
  • {:error, reason} - Workflow merge or collection sync failed

parent_admin?(project, user)

Returns true when user has an :admin or :owner role on any ancestor of project, walking the parent chain.

Used to enforce the parent-admin floor rule: a user who is admin/owner on any ancestor project cannot be removed from, or downgraded within, a sandbox descended from that project.

provision(parent, actor, attrs)

@spec provision(
  Lightning.Projects.Project.t(),
  Lightning.Accounts.User.t(),
  provision_attrs()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :nesting_too_deep | Ecto.Changeset.t() | term()}

Creates a new sandbox project by cloning from a parent project.

The creator becomes the sandbox owner, and all workflow triggers are disabled. Credentials are shared (not duplicated) between parent and sandbox.

Parameters

  • parent - Project to clone from
  • actor - User creating the sandbox (needs :editor, :admin, or :owner role on parent)
  • attrs - Creation attributes (see provision_attrs/0)

Returns

  • {:ok, sandbox_project} - Successfully created sandbox
  • {:error, :unauthorized} - Actor lacks permission on parent
  • {:error, :nesting_too_deep} - Parent is already at Lightning.Config.max_sandbox_nesting_depth/0
  • {:error, changeset} - Validation or database error

Example

{:ok, sandbox} = Sandboxes.provision(parent_project, user, %{
  name: "test-environment",
  color: "#336699"
})

Concurrency note

The nesting-depth check runs inside the same Repo.transaction as the sandbox insert, but PostgreSQL's default READ COMMITTED isolation does not lock the parent's ancestry. A concurrent committed reparent of parent or any of its ancestors between the depth read and the insert could place the new sandbox one level above the cap. Lightning has no reparenting code path today, so this is theoretical; if a re-homing feature ships, this check should be tightened with SELECT FOR UPDATE on the ancestor chain.

schedule_sandbox_deletion(sandbox, actor)

@spec schedule_sandbox_deletion(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | term()}

Schedules a sandbox and its entire descendant subtree for deletion.

The sandbox stays in the database for a grace period (controlled by PURGE_DELETED_AFTER_DAYS) before the Oban purge worker permanently deletes it. During the grace period the sandbox is hidden from the parent's sandbox listing but remains recoverable via cancel_scheduled_sandbox_deletion/2.

All triggers in the subtree are disabled so that scheduled work stops immediately. The scheduled timestamp is applied to the target and every descendant in a single transaction so the entire subtree shares a grace period and gets purged together.

Cascade semantics

Scheduling cascades through every descendant unconditionally. If a child sandbox was already scheduled separately (with an earlier timestamp), that earlier timestamp is overwritten with the new one. The intent is that scheduling a parent always synchronises the whole subtree's grace window; if you need a child to be purged on its original earlier timestamp, do not schedule the parent.

Parameters

  • sandbox - Sandbox project to schedule (or sandbox ID as string)
  • actor - User performing the action (needs :delete_sandbox permission)

Returns

  • {:ok, scheduled_sandbox} - Sandbox subtree scheduled for deletion
  • {:error, :unauthorized} - Actor lacks permission on the sandbox
  • {:error, :not_found} - Sandbox ID not found (when using a string ID)
  • {:error, reason} - Database or other failure

sync_collections(source, target)

@spec sync_collections(Lightning.Projects.Project.t(), Lightning.Projects.Project.t()) ::
  {:ok, %{created: non_neg_integer(), deleted: non_neg_integer()}}
  | {:error, term()}

Synchronises collection names from a sandbox to its merge target.

Names only in the source are created empty in the target; names only in the target are deleted along with their items. Collection data is never copied. The combined byte-size of deleted collections is reported via CollectionHook.handle_delete/2 for usage accounting.

Runs inside a single transaction.

update_sandbox(sandbox, actor, attrs)

@spec update_sandbox(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t(),
  map()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | Ecto.Changeset.t()}

Updates a sandbox project's basic attributes.

Parameters

  • sandbox - Sandbox project to update (or sandbox ID as string)
  • actor - User performing the update (needs :owner or :admin role on sandbox)
  • attrs - Map with :name, :color, and/or :env keys

Returns

  • {:ok, updated_sandbox} - Successfully updated sandbox
  • {:error, :unauthorized} - Actor lacks permission on sandbox
  • {:error, :not_found} - Sandbox ID not found (when using string ID)
  • {:error, changeset} - Validation error

Example

= Sandboxes.update_sandbox(sandbox, user, %{

name: "new-name",
color: "#ff6b35"

})