mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
* fix: let a principal without a login account own a draft Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Lu3hExEDPZu2dAEhZDVAi * fix: keep an accountless draft owner from colliding or reading as legacy Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Lu3hExEDPZu2dAEhZDVAi * fix: drop the unnameable draft owner everywhere and guard the no-op rename Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Lu3hExEDPZu2dAEhZDVAi * fix: drop the unused Acquire import in the draft rename test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Lu3hExEDPZu2dAEhZDVAi * docs: drop the stale draft_users claim from the fork-clone rationale Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Lu3hExEDPZu2dAEhZDVAi * chore: update ee-repo-ref to f5b783d2f7608e1ff3a817caa8b719e06f8b8981 This commit updates the EE repository reference after PR #768 was merged in windmill-ee-private. Previous ee-repo-ref: f3dba016e9274ee9bbe46b4f070d3ed29843e5fd New ee-repo-ref: f5b783d2f7608e1ff3a817caa8b719e06f8b8981 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
622 lines
24 KiB
Rust
622 lines
24 KiB
Rust
/*
|
|
* Author: Diego Imbert
|
|
* Copyright: Windmill Labs, Inc 2026
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
//! Shared types and helpers for the per-user `draft` table. Lives in
|
|
//! `windmill-common` so entity crates can use it without depending on the
|
|
//! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns.
|
|
|
|
// `DraftUserRef` lives in `windmill-types` (where the list-endpoint row
|
|
// structs `ListableScript`/`ListableFlow` declare `Vec<DraftUserRef>` and
|
|
// can't reach `windmill-common` without a cycle). Re-exported here so draft
|
|
// handlers keep a single import path.
|
|
pub use windmill_types::user_drafts::DraftUserRef;
|
|
|
|
use crate::db::DB;
|
|
use crate::error::Result;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Item kinds a user can have an autosaved draft on. Must stay in lockstep
|
|
/// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND`
|
|
/// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration).
|
|
/// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`).
|
|
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum UserDraftItemKind {
|
|
Script,
|
|
Flow,
|
|
App,
|
|
RawApp,
|
|
Resource,
|
|
Variable,
|
|
TriggerSchedule,
|
|
TriggerWebhook,
|
|
TriggerDefaultEmail,
|
|
TriggerEmail,
|
|
TriggerHttp,
|
|
TriggerWebsocket,
|
|
TriggerPostgres,
|
|
TriggerKafka,
|
|
TriggerNats,
|
|
TriggerMqtt,
|
|
TriggerAmqp,
|
|
TriggerSqs,
|
|
TriggerGcp,
|
|
TriggerAzure,
|
|
TriggerPoll,
|
|
TriggerCli,
|
|
TriggerNextcloud,
|
|
TriggerGoogle,
|
|
TriggerGithub,
|
|
/// All unsaved scripts of one data pipeline, bundled into a single draft
|
|
/// keyed at the pipeline's folder path. Not a runnable: it has no deployed
|
|
/// backing table and is private to its owner.
|
|
DataPipeline,
|
|
}
|
|
|
|
impl UserDraftItemKind {
|
|
/// The snake_case wire/DB string, for interpolating into dynamically-built
|
|
/// SQL (`?::DRAFT_KIND` binds want a string).
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
UserDraftItemKind::Script => "script",
|
|
UserDraftItemKind::Flow => "flow",
|
|
UserDraftItemKind::App => "app",
|
|
UserDraftItemKind::RawApp => "raw_app",
|
|
UserDraftItemKind::Resource => "resource",
|
|
UserDraftItemKind::Variable => "variable",
|
|
UserDraftItemKind::TriggerSchedule => "trigger_schedule",
|
|
UserDraftItemKind::TriggerWebhook => "trigger_webhook",
|
|
UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email",
|
|
UserDraftItemKind::TriggerEmail => "trigger_email",
|
|
UserDraftItemKind::TriggerHttp => "trigger_http",
|
|
UserDraftItemKind::TriggerWebsocket => "trigger_websocket",
|
|
UserDraftItemKind::TriggerPostgres => "trigger_postgres",
|
|
UserDraftItemKind::TriggerKafka => "trigger_kafka",
|
|
UserDraftItemKind::TriggerNats => "trigger_nats",
|
|
UserDraftItemKind::TriggerMqtt => "trigger_mqtt",
|
|
UserDraftItemKind::TriggerAmqp => "trigger_amqp",
|
|
UserDraftItemKind::TriggerSqs => "trigger_sqs",
|
|
UserDraftItemKind::TriggerGcp => "trigger_gcp",
|
|
UserDraftItemKind::TriggerAzure => "trigger_azure",
|
|
UserDraftItemKind::TriggerPoll => "trigger_poll",
|
|
UserDraftItemKind::TriggerCli => "trigger_cli",
|
|
UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud",
|
|
UserDraftItemKind::TriggerGoogle => "trigger_google",
|
|
UserDraftItemKind::TriggerGithub => "trigger_github",
|
|
UserDraftItemKind::DataPipeline => "data_pipeline",
|
|
}
|
|
}
|
|
|
|
/// Every variant, for code that must enumerate kinds (e.g. generating
|
|
/// the `draft_only` existence SQL).
|
|
pub const ALL: [UserDraftItemKind; 26] = [
|
|
UserDraftItemKind::Script,
|
|
UserDraftItemKind::Flow,
|
|
UserDraftItemKind::App,
|
|
UserDraftItemKind::RawApp,
|
|
UserDraftItemKind::Resource,
|
|
UserDraftItemKind::Variable,
|
|
UserDraftItemKind::TriggerSchedule,
|
|
UserDraftItemKind::TriggerWebhook,
|
|
UserDraftItemKind::TriggerDefaultEmail,
|
|
UserDraftItemKind::TriggerEmail,
|
|
UserDraftItemKind::TriggerHttp,
|
|
UserDraftItemKind::TriggerWebsocket,
|
|
UserDraftItemKind::TriggerPostgres,
|
|
UserDraftItemKind::TriggerKafka,
|
|
UserDraftItemKind::TriggerNats,
|
|
UserDraftItemKind::TriggerMqtt,
|
|
UserDraftItemKind::TriggerAmqp,
|
|
UserDraftItemKind::TriggerSqs,
|
|
UserDraftItemKind::TriggerGcp,
|
|
UserDraftItemKind::TriggerAzure,
|
|
UserDraftItemKind::TriggerPoll,
|
|
UserDraftItemKind::TriggerCli,
|
|
UserDraftItemKind::TriggerNextcloud,
|
|
UserDraftItemKind::TriggerGoogle,
|
|
UserDraftItemKind::TriggerGithub,
|
|
UserDraftItemKind::DataPipeline,
|
|
];
|
|
|
|
/// The deployed table backing this kind, keyed by `(workspace_id, path)`.
|
|
/// SINGLE SOURCE for both the draft access check (which table RLS resolves
|
|
/// item-level `extra_perms` against) and the `draft_only` existence check.
|
|
/// `None` for kinds with no per-path backing table (webhook is a property
|
|
/// of a script/flow row; native triggers are keyed by external_id, not
|
|
/// path) — callers treat that as "no deployed counterpart": `draft_only =
|
|
/// true` and a path-only access check.
|
|
pub fn deployed_table(&self) -> Option<&'static str> {
|
|
use UserDraftItemKind::*;
|
|
match self {
|
|
Script => Some("script"),
|
|
Flow => Some("flow"),
|
|
App | RawApp => Some("app"),
|
|
Resource => Some("resource"),
|
|
Variable => Some("variable"),
|
|
TriggerSchedule => Some("schedule"),
|
|
TriggerHttp => Some("http_trigger"),
|
|
TriggerWebsocket => Some("websocket_trigger"),
|
|
TriggerPostgres => Some("postgres_trigger"),
|
|
TriggerKafka => Some("kafka_trigger"),
|
|
TriggerNats => Some("nats_trigger"),
|
|
TriggerMqtt => Some("mqtt_trigger"),
|
|
TriggerAmqp => Some("amqp_trigger"),
|
|
TriggerSqs => Some("sqs_trigger"),
|
|
TriggerGcp => Some("gcp_trigger"),
|
|
TriggerAzure => Some("azure_trigger"),
|
|
TriggerEmail | TriggerDefaultEmail => Some("email_trigger"),
|
|
TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle
|
|
| TriggerGithub => None,
|
|
// Keyed at a folder path, not a runnable; access falls back to the
|
|
// path-only (folder write) check.
|
|
DataPipeline => None,
|
|
}
|
|
}
|
|
|
|
/// Whether OTHER users' drafts at a path are visible to a viewer (the
|
|
/// "others are editing" list, owner circles, and the `get_draft_for_user`
|
|
/// View JSON / Fork endpoint). Enabled only for the full-page editor items
|
|
/// which have the cross-user draft UI. Drawer items keep drafts private to
|
|
/// their owner: they have no such UI, and exposing a secret variable draft
|
|
/// would hand out the `$encrypted:` ciphertext, which a viewer could
|
|
/// launder into plaintext via a deploy.
|
|
pub fn shares_drafts_across_users(&self) -> bool {
|
|
use UserDraftItemKind::*;
|
|
matches!(self, Script | Flow | App | RawApp)
|
|
}
|
|
}
|
|
|
|
/// Query-string flag accepted by every "get by path" route that supports
|
|
/// the draft overlay. `#[serde(flatten)]` into a route-specific query struct
|
|
/// when the route has other query fields.
|
|
#[derive(Debug, Deserialize, Default)]
|
|
pub struct WithDraftQuery {
|
|
/// When true, attach the authed user's draft (if any) as a separate
|
|
/// `draft` field. Defaults to false so non-editor callers see the
|
|
/// deployed shape unchanged.
|
|
#[serde(default)]
|
|
pub get_draft: bool,
|
|
}
|
|
|
|
/// One row of `other_drafts_users`: a draft on the same path owned by
|
|
/// someone other than the authed user. `username` is `None` for the legacy
|
|
/// NULL-email row, surfaced in the frontend as a "Legacy draft" entry.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct OtherDraftUser {
|
|
/// `None` represents a legacy workspace-level draft (no owner).
|
|
pub username: Option<String>,
|
|
/// When this user's draft was last saved (the `draft.created_at` upsert
|
|
/// timestamp), surfaced in the fork modal as "Last updated".
|
|
pub draft_saved_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Response wrapper: the deployed entity untouched plus the authed user's
|
|
/// draft (if any) as a sibling `draft` field, which the frontend pairs to
|
|
/// diff/restore/discard. The deployed and the draft are NEVER merged on the
|
|
/// server — the editor's saved shape can diverge arbitrarily, so any per-kind
|
|
/// translation lives in the frontend loader. `inner` is boxed-erased so a
|
|
/// possibly MB-scale deployed payload serializes in ONE pass (no
|
|
/// `serde_json::Value` round-trip) while keeping the struct non-generic.
|
|
#[derive(Serialize)]
|
|
pub struct WithDraftOverlay {
|
|
/// Deployed payload, flattened to the top level.
|
|
#[serde(flatten)]
|
|
pub inner: Box<dyn erased_serde::Serialize + Send>,
|
|
pub is_draft: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub draft_saved_at: Option<DateTime<Utc>>,
|
|
/// True when no deployed row exists at this path: `inner` is only a
|
|
/// best-effort stand-in synthesized from the draft and only `draft` is
|
|
/// canonical. Frontend uses this to disable "diff/reset vs deployed" and
|
|
/// skip its deployed-shape parsing of `inner`. Omitted when false.
|
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
|
pub no_deployed: bool,
|
|
/// The user's saved draft payload (whatever shape the editor wrote).
|
|
/// Present when `get_draft=true` and a draft exists.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub draft: Option<serde_json::Value>,
|
|
/// Other users with a draft on the same path (excludes the authed user).
|
|
/// Empty list is omitted to keep the common-case response lean.
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub other_drafts_users: Vec<OtherDraftUser>,
|
|
}
|
|
|
|
/// List every other user (and the legacy NULL-email row, if any) with a
|
|
/// draft at `(workspace, kind, path)`. Returns usernames only — emails never
|
|
/// leave the server. LEFT JOIN against `usr` so an orphaned draft (user
|
|
/// removed from the workspace) still surfaces with `username = None`. The
|
|
/// authed user is excluded via `email <> authed_email`; the legacy row
|
|
/// matches because `email IS NULL` fails that comparison.
|
|
async fn fetch_other_drafts_users(
|
|
db: &DB,
|
|
w_id: &str,
|
|
authed_email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
) -> Result<Vec<OtherDraftUser>> {
|
|
// A superadmin authoring in a workspace they are not a member of has no `usr`
|
|
// row: fall back to their instance-derived username (`password.username`), or
|
|
// their email when derivation is disabled. Else a real teammate's draft renders
|
|
// as a phantom "Legacy draft". The genuine NULL-email legacy row keeps
|
|
// `username = None` (no `usr`/`password` match and `d.email` is NULL), which is
|
|
// why an owner that resolves to no name at all — an external JWT's subject has
|
|
// neither row — is dropped instead: `None` is taken to mean "legacy" downstream.
|
|
let rows = sqlx::query_as!(
|
|
OtherDraftUser,
|
|
r#"SELECT COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) as "username?",
|
|
d.created_at as "draft_saved_at!"
|
|
FROM draft d
|
|
LEFT JOIN usr u
|
|
ON u.workspace_id = d.workspace_id
|
|
AND u.email = d.email
|
|
LEFT JOIN password p
|
|
ON p.email = d.email
|
|
AND p.super_admin = true
|
|
WHERE d.workspace_id = $1
|
|
AND d.path = $2
|
|
AND d.typ = $3
|
|
AND (d.email IS NULL OR d.email <> $4)
|
|
AND (d.email IS NULL OR u.username IS NOT NULL OR p.email IS NOT NULL)
|
|
ORDER BY d.email NULLS LAST"#,
|
|
w_id,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
authed_email,
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// If `get_draft` is true AND the authed user has a draft for
|
|
/// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always
|
|
/// serialized into `inner` untouched.
|
|
pub async fn maybe_overlay_draft<T>(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
get_draft: bool,
|
|
deployed: T,
|
|
) -> Result<WithDraftOverlay>
|
|
where
|
|
T: serde::Serialize + Send + 'static,
|
|
{
|
|
// Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps)
|
|
// pass `get_draft = false` and render no overlay, so skip the `usr` join.
|
|
if !get_draft {
|
|
return Ok(WithDraftOverlay {
|
|
inner: Box::new(deployed),
|
|
is_draft: false,
|
|
draft_saved_at: None,
|
|
no_deployed: false,
|
|
draft: None,
|
|
other_drafts_users: Vec::new(),
|
|
});
|
|
}
|
|
|
|
// Independent of the authed user's OWN draft: reset-to-deployed reloads
|
|
// still need to know who else is editing this path. Only the cross-user
|
|
// kinds surface it (see `shares_drafts_across_users`).
|
|
let other_drafts_users = if kind.shares_drafts_across_users() {
|
|
fetch_other_drafts_users(db, w_id, email, kind, path).await?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Prefer the user's OWN per-user draft, falling back to the legacy
|
|
// NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy
|
|
// row when an owned one exists.
|
|
let row = sqlx::query!(
|
|
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
|
created_at
|
|
FROM draft
|
|
WHERE workspace_id = $1
|
|
AND (email = $2 OR email IS NULL)
|
|
AND path = $3
|
|
AND typ = $4
|
|
ORDER BY email NULLS LAST
|
|
LIMIT 1"#,
|
|
w_id,
|
|
email,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some(row) = row else {
|
|
return Ok(WithDraftOverlay {
|
|
inner: Box::new(deployed),
|
|
is_draft: false,
|
|
draft_saved_at: None,
|
|
no_deployed: false,
|
|
draft: None,
|
|
other_drafts_users,
|
|
});
|
|
};
|
|
|
|
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
|
|
|
|
Ok(WithDraftOverlay {
|
|
inner: Box::new(deployed),
|
|
is_draft: true,
|
|
draft_saved_at: Some(row.created_at),
|
|
no_deployed: false,
|
|
draft: Some(draft_json),
|
|
other_drafts_users,
|
|
})
|
|
}
|
|
|
|
/// One row of a "draft-only" list synthesis: a draft at `path` with no
|
|
/// deployed counterpart. `value` is the editor's saved JSON (each handler
|
|
/// maps it into its own `Listable*` shape).
|
|
#[derive(sqlx::FromRow)]
|
|
pub struct DraftOnlyListRow {
|
|
pub path: String,
|
|
pub value: sqlx::types::Json<Box<serde_json::value::RawValue>>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Fetch the authed user's draft rows at paths with NO deployed counterpart,
|
|
/// for synthesizing draft-only entries into a list response. Absence is
|
|
/// checked against `kind.deployed_table()` (the shared single source).
|
|
/// Returns empty for kinds with no path-keyed table. Callers keep their own
|
|
/// gating (`include_draft_only`, page 0, no filters) and row mapping.
|
|
pub async fn fetch_draft_only_list_rows(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
) -> Result<Vec<DraftOnlyListRow>> {
|
|
let Some(table) = kind.deployed_table() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
// `table` is from the closed `deployed_table()` enum, never user input.
|
|
// `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows
|
|
// AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL`
|
|
// last collapses a path that has both to the owned row.
|
|
let sql = format!(
|
|
"SELECT DISTINCT ON (path) path, value, created_at FROM draft \
|
|
WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \
|
|
AND (email = $3 OR email IS NULL) \
|
|
AND NOT EXISTS (SELECT 1 FROM {table} t \
|
|
WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \
|
|
ORDER BY path, (email IS NULL)"
|
|
);
|
|
let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql)
|
|
.bind(w_id)
|
|
.bind(kind.as_str())
|
|
.bind(email)
|
|
.fetch_all(db)
|
|
.await?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// The get-by-path draft choreography, shared by every entity's "get by path"
|
|
/// route. Given the deployed entity as an `Option` (caller maps its own "not
|
|
/// found" to `None`):
|
|
/// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`).
|
|
/// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when
|
|
/// a draft exists, else the caller's 404 via `not_found`.
|
|
/// - `None` without `get_draft` → the caller's 404.
|
|
pub async fn overlay_or_draft_only<T: serde::Serialize + Send + 'static>(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
get_draft: bool,
|
|
deployed: Option<T>,
|
|
not_found: impl FnOnce() -> crate::error::Error,
|
|
) -> Result<WithDraftOverlay> {
|
|
match deployed {
|
|
Some(deployed) => {
|
|
maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await
|
|
}
|
|
None if get_draft => fetch_draft_only(db, w_id, email, kind, path)
|
|
.await?
|
|
.ok_or_else(not_found),
|
|
None => Err(not_found()),
|
|
}
|
|
}
|
|
|
|
/// Delete the drafts an address owns, across every workspace.
|
|
///
|
|
/// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the
|
|
/// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an
|
|
/// account is therefore what has to delete its drafts — a delete path that skips this leaves them
|
|
/// behind forever, addressed to someone who no longer exists. Call it in the same transaction as
|
|
/// the account removal.
|
|
///
|
|
/// No authorization of its own: it acts instance-wide on whatever address it is handed, so the
|
|
/// caller must already have authorized removing that account (superadmin, the account's own
|
|
/// holder, or SCIM).
|
|
pub async fn delete_drafts_of_email<'c>(
|
|
executor: impl sqlx::PgExecutor<'c>,
|
|
email: &str,
|
|
) -> Result<()> {
|
|
sqlx::query!("DELETE FROM draft WHERE email = $1", email)
|
|
.execute(executor)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Move the drafts an address owns onto its new address, for the same reason
|
|
/// [`delete_drafts_of_email`] exists: no foreign key follows the rename, so drafts left behind are
|
|
/// stranded on an address that no longer authenticates. Same authorization contract, for a rename.
|
|
///
|
|
/// The two addresses may each already hold a draft of the same item, since the destination can
|
|
/// belong to a principal with no account and so is not covered by the caller's "address is free"
|
|
/// check. `draft_pkey_with_user` admits only one, so the moving account's wins — which is also why
|
|
/// a rename onto the same address returns early: every row would collide with itself and be
|
|
/// cleared. Callers need not compare first (an IdP re-sending an unchanged `userName` does not).
|
|
pub async fn rename_drafts_of_email(
|
|
conn: &mut sqlx::PgConnection,
|
|
old_email: &str,
|
|
new_email: &str,
|
|
) -> Result<()> {
|
|
if old_email == new_email {
|
|
return Ok(());
|
|
}
|
|
sqlx::query!(
|
|
"DELETE FROM draft dest
|
|
WHERE dest.email = $1
|
|
AND EXISTS (SELECT 1 FROM draft src
|
|
WHERE src.email = $2
|
|
AND src.workspace_id = dest.workspace_id
|
|
AND src.path = dest.path
|
|
AND src.typ = dest.typ)",
|
|
new_email,
|
|
old_email
|
|
)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE draft SET email = $1 WHERE email = $2",
|
|
new_email,
|
|
old_email
|
|
)
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind.
|
|
/// Use when the item is DELETED outright: it's gone for everyone, so leaving
|
|
/// teammates' drafts behind would orphan them forever. Discarding one's OWN
|
|
/// draft while the item lives on goes through `update_draft` with `value: null`.
|
|
/// Idempotent on the no-draft case.
|
|
pub async fn delete_all_drafts_for_path(
|
|
db: &DB,
|
|
w_id: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
) -> Result<()> {
|
|
sqlx::query!(
|
|
r#"DELETE FROM draft
|
|
WHERE workspace_id = $1
|
|
AND path = $2
|
|
AND typ = $3"#,
|
|
w_id,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Discard the deploying user's OWN draft (plus the legacy NULL-email row)
|
|
/// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the
|
|
/// item moved, so the draft at the old path is orphaned (no FK to cascade).
|
|
/// Teammates keep theirs and get the StaleDraftModal on their next reload.
|
|
/// Idempotent on the no-draft case.
|
|
pub async fn delete_own_draft_for_path(
|
|
db: &DB,
|
|
w_id: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
email: &str,
|
|
) -> Result<()> {
|
|
sqlx::query!(
|
|
r#"DELETE FROM draft
|
|
WHERE workspace_id = $1
|
|
AND path = $2
|
|
AND typ = $3
|
|
AND (email = $4 OR email IS NULL)"#,
|
|
w_id,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
email,
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch the authed user's draft as a standalone payload, for "get by path"
|
|
/// routes when no deployed row exists but a draft might. Returns it as a
|
|
/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and
|
|
/// `no_deployed = true`. Callers must have established no deployed row exists;
|
|
/// `Ok(None)` when there's also no draft (caller should 404).
|
|
///
|
|
/// The draft JSON is expected to be an object (so `serde(flatten)` on `inner`
|
|
/// works); a non-object draft renders with no fields flattened.
|
|
pub async fn fetch_draft_only(
|
|
db: &DB,
|
|
w_id: &str,
|
|
email: &str,
|
|
kind: UserDraftItemKind,
|
|
path: &str,
|
|
) -> Result<Option<WithDraftOverlay>> {
|
|
// Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`).
|
|
let row = sqlx::query!(
|
|
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
|
created_at
|
|
FROM draft
|
|
WHERE workspace_id = $1
|
|
AND (email = $2 OR email IS NULL)
|
|
AND path = $3
|
|
AND typ = $4
|
|
ORDER BY email NULLS LAST
|
|
LIMIT 1"#,
|
|
w_id,
|
|
email,
|
|
path,
|
|
kind as UserDraftItemKind,
|
|
)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some(row) = row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?;
|
|
let other_drafts_users = if kind.shares_drafts_across_users() {
|
|
fetch_other_drafts_users(db, w_id, email, kind, path).await?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok(Some(WithDraftOverlay {
|
|
// Best-effort stand-in for the missing deployed — same JSON as `draft`.
|
|
inner: Box::new(draft_json.clone()),
|
|
is_draft: true,
|
|
draft_saved_at: Some(row.created_at),
|
|
no_deployed: true,
|
|
draft: Some(draft_json),
|
|
other_drafts_users,
|
|
}))
|
|
}
|
|
|
|
/// Marker prefix for draft secret values encrypted at rest with the workspace
|
|
/// crypt key (`build_crypt`). Written by `update_draft` for secret variables;
|
|
/// resolved back to plaintext by the variable deploy endpoints.
|
|
pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:";
|
|
|
|
fn draft_decrypt_error() -> crate::error::Error {
|
|
crate::error::Error::BadRequest(
|
|
"An encrypted draft secret could not be decrypted (the workspace encryption key may \
|
|
have changed since the draft was saved). Reset the field and re-enter the secret."
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
/// Decrypt a `$encrypted:`-marked draft value back to plaintext with the
|
|
/// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt
|
|
/// (e.g. the workspace key was rotated after the draft save).
|
|
pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result<String> {
|
|
let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value);
|
|
let mc = crate::variables::build_crypt(db, w_id).await?;
|
|
crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error())
|
|
}
|