Files
hugocasa efb4a27d51 fix: replace email with permissioned_as for triggers/schedules (#8439)
* refactor: replace email with permissioned_as for triggers/schedules

Add a new `permissioned_as` column (format: `u/{username}`, `g/{group}`,
or raw email) to all trigger tables and schedule. This value is used
directly for job permission checks, removing the need for email lookups
when creating/updating triggers.

- Migration: add permissioned_as to all 9 trigger tables + schedule,
  drop email from trigger tables (schedule keeps it for backwards compat)
- Backend: resolve_email() (async, DB) -> resolve_permissioned_as() (sync)
- Email cache: get_email_from_permissioned_as() with quick_cache for
  places that still need email (fetch_api_authed, schedule backwards compat)
- Frontend: rename email/preserve_email -> permissioned_as/preserve_permissioned_as
  in deploy data and OpenAPI schemas
- Tests updated for new field names and u/{username} format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix sqlx/build

* update ee ref

* refactor: simplify resolve_edited_by to always use authed username

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix compile + migration

* update ref

* test: add trigger trait method tests for permissioned_as queries

Add tests that call TriggerCrud and Listener trait methods directly
to verify dynamic SQL correctly references the permissioned_as column.
Covers get_trigger_by_path, list_triggers, set_trigger_mode, and
fetch_enabled_unlistened_triggers for all trigger types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update sqlx

* fix: use permissioned_as directly for schedules and fix audit RLS for groups

- Schedule: permissioned_as only set on create, not on edit/set_enabled
- Schedule: stop reading email column, use get_email_from_permissioned_as
- Triggers: use fetch_api_authed_from_permissioned_as instead of edited_by
- Triggers: rename listener fields for clarity (username -> edited_by)
- Fix audit author username for group permissioned_as (g/test -> group-test)
  to match session.user, preventing RLS policy violations on audit_partitioned
- OpenAPI: remove permissioned_as/preserve_permissioned_as from EditSchedule
- Add backwards-compat comments for schedule email writes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts for permissioned_as field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix build

* refactor: generalize onBehalfOf naming, add permissioned_as to EditSchedule

- Frontend: rename onBehalfOfPermissionedAs -> onBehalfOf with comments
  explaining it carries emails for flows/scripts and permissioned_as for
  triggers/schedules
- Frontend: rename getOnBehalfOfEmail -> getOnBehalfOf,
  getOnBehalfOfPermissionedAsForDeploy -> getOnBehalfOfForDeploy,
  customOnBehalfOfEmails -> customOnBehalfOf
- Backend: add optional permissioned_as/preserve_permissioned_as to
  EditSchedule with COALESCE (only updates when provided)
- Backend: add on_behalf_of audit log for schedule edit
- Backend: remove unused resolve_on_behalf_of_permissioned_as
- Tests: remove email assertions from schedule update test (email is
  just backwards compat, only permissioned_as matters)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve email column when permissioned_as is preserved on schedule edit

Derive email from the preserved permissioned_as via cache lookup instead
of always writing authed.email. This keeps the email column consistent
with the old behavior for backwards compat with old workers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update deploy UI labels from "edited by" to "run as" for triggers

Triggers now use permissioned_as (not edited_by) for permissions, so
update the deploy UI wording to reflect this. Also update wm_deployers
group description to mention schedules and permissioned_as.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use u/username format for custom trigger/schedule deploy selection

When picking a custom user for trigger/schedule deployment, store
u/${username} (permissioned_as format) instead of the email. Flows/scripts
continue to use email format for on_behalf_of_email.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show u/username format for "me" option in trigger deploy selector

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify OnBehalfOfSelector to return the right format per kind

OnBehalfOfSelector now handles the email vs permissioned_as format
internally based on kind:
- triggers: returns u/username, displays u/username in all options
- flows/scripts/apps: returns email, displays username

The onSelect callback now takes (choice, value?) where value is already
in the correct format. Parent components just store it directly without
needing to know about the format difference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: always show u/username format in OnBehalfOfSelector for all kinds

Display is now consistent: all kinds show u/username in the selector.
The returned value still differs (email for flows/scripts, u/username
for triggers) since the backend APIs expect different formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace email with permissioned_as in http_trigger test insert

The email column was dropped from trigger tables in the migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review fixes — migration, app policy, capture cleanup, naming

- Migration: remove DEFAULT '', use nullable → populate → SET NOT NULL
- App policy: set both on_behalf_of and on_behalf_of_email for all choices
- OnBehalfOfSelector: return OnBehalfOfDetails {email, permissionedAs} instead of ambiguous value
- Remove unused email field from Capture struct and query
- Rename getSourceEmail/getTargetEmail → getSourceOnBehalfOf/getTargetOnBehalfOf
- Rename test functions from preserve_email to preserve_permissioned_as

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add permissioned_as to all test schedule INSERTs

Since the migration no longer uses DEFAULT '', all INSERTs must
explicitly provide permissioned_as. Updated test fixtures and
schedule_push tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: strip permissioned_as from exports/sync, fix OpenAPI required field

- Add permissioned_as to workspace export strip list (like edited_by)
- Add permissioned_as to CLI TriggerFile Omit list
- Fix TriggerExtraProperty.required: email → permissioned_as
- Regenerate frontend and CLI types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove accidentally committed generated files

These directories are gitignored and should not be tracked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts for permissioned_as schema changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove permissioned_as from CLI TriggerFile Omit list

Already stripped in workspace export, no need to also omit from the type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: optimize email cache key and revert TriggerFile Omit change

- Use single concatenated string for cache key instead of (String, String) tuple
- Remove permissioned_as from CLI TriggerFile Omit (already stripped in export)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: zero-allocation email cache lookups using Equivalent trait

Use a borrowed EmailCacheKey(&str, &str) for cache lookups via
quick_cache's Equivalent support. Only allocates (String, String)
on cache miss for insert. This is called on every trigger fire
and schedule push.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add permissioned_as to Schedule required fields in OpenAPI spec

The backend always returns permissioned_as (non-optional String),
so the schema should reflect that.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle group- prefix in migration UPDATE statements

edited_by can be 'group-{name}' for group-owned triggers/schedules.
The migration now correctly maps these to 'g/{name}' format instead
of incorrectly producing 'u/group-{name}'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Revert "fix: handle group- prefix in migration UPDATE statements"

This reverts commit 0971392b38.

* fix: use superadmin email to resolve permissioned_as in schedule migration

For users upgrading from older versions where edited_by may not reflect
the actual schedule owner, check if the email belongs to a superadmin
and look up their username. Otherwise fall back to edited_by.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fall back to superadmin email when not in workspace usr table

If the superadmin isn't a member of the workspace, use their email
as raw permissioned_as instead of falling back to edited_by.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: always update permissioned_as and email on schedule edit

Consistent with pre-refactor behavior where email and edited_by
were always updated on every edit. permissioned_as is now always
set (to editing user or preserved value), removing the COALESCE
that previously preserved it when not provided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add schedule permission tests and centralize group prefix constants

Tests: schedule create/update for normal user, workspace admin, and
superadmin not in workspace. Verifies schedule fields (email,
permissioned_as, edited_by) and pushed job fields (permissioned_as,
permissioned_as_email).

Constants: centralize "u/", "g/", "group-" as PERMISSIONED_AS_USER_PREFIX,
PERMISSIONED_AS_GROUP_PREFIX, USERNAME_GROUP_PREFIX.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use @unknown.windmill.dev for synthetic email fallback

Prevents privilege escalation: a user with username like
'superadmin_secret' would get superadmin via the synthetic
email matching SUPERADMIN_SECRET_EMAIL. Using a different
subdomain avoids any collision with hardcoded @windmill.dev emails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update ee ref

* sqlx

* chore: regenerate system prompts after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to bda51bc33bcb573659e7ff07d0a23ff6e23b8148

This commit updates the EE repository reference after PR #468 was merged in windmill-ee-private.

Previous ee-repo-ref: 8cf1802f8fe183f430830590b4f3172a50207843

New ee-repo-ref: bda51bc33bcb573659e7ff07d0a23ff6e23b8148

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-20 16:28:38 +00:00

1107 lines
34 KiB
Rust

use std::collections::HashMap;
use axum::{
async_trait,
extract::Path,
routing::{delete, get, post},
Extension, Json, Router,
};
use itertools::Itertools;
use lazy_static::lazy_static;
use pg_escape::{quote_identifier, quote_literal};
use quick_cache::sync::Cache;
use rust_postgres::{types::Type, Client};
use sqlx::PgConnection;
use uuid;
use windmill_common::{
db::UserDB,
error::{self, to_anyhow, Error, Result},
DB,
};
use windmill_git_sync::DeployedObject;
use windmill_api_auth::ApiAuthed;
use windmill_trigger::{Trigger, TriggerCrud, TriggerData};
use super::{
check_if_valid_publication_for_postgres_version, create_logical_replication_slot,
create_pg_publication, drop_publication, generate_random_string, get_default_pg_connection,
mapper::{Mapper, MappingInfo},
PostgresConfig, PostgresConfigRequest, PostgresPublicationReplication, PostgresTrigger,
PublicationData, Relations, Slot, SlotList, TableToTrack, TemplateScript, TestPostgresConfig,
ERROR_PUBLICATION_NAME_NOT_EXISTS,
};
// Lazy static template cache
lazy_static! {
pub static ref TEMPLATE: Cache<String, String> = Cache::new(50);
}
#[async_trait]
impl TriggerCrud for PostgresTrigger {
type TriggerConfig = PostgresConfig;
type Trigger = Trigger<Self::TriggerConfig>;
type TriggerConfigRequest = PostgresConfigRequest;
type TestConnectionConfig = TestPostgresConfig;
const TABLE_NAME: &'static str = "postgres_trigger";
const TRIGGER_TYPE: &'static str = "postgres";
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/postgres_triggers";
const DEPLOYMENT_NAME: &'static str = "PostgreSQL trigger";
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[
"postgres_resource_path",
"replication_slot_name",
"publication_name",
"NULL::text AS basic_mode",
];
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String, parent_path: Option<String>) -> DeployedObject {
DeployedObject::PostgresTrigger { path, parent_path }
}
async fn create_trigger(
&self,
db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
let Self::TriggerConfigRequest {
postgres_resource_path,
publication_name,
replication_slot_name,
publication,
} = trigger.config;
let (pub_name, slot_name) =
if publication_name.is_empty() && replication_slot_name.is_empty() {
if publication.is_none() {
return Err(Error::BadRequest("publication must be set".to_string()));
}
let PostgresPublicationReplication { publication_name, replication_slot_name } =
create_custom_slot_and_publication_inner(
authed.clone(),
UserDB::new(db.clone()),
&db,
&postgres_resource_path,
&w_id,
&publication.unwrap(),
)
.await?;
(publication_name, replication_slot_name)
} else {
if publication_name.is_empty() {
return Err(Error::BadRequest(
"Publication name must not be empty".to_string(),
));
} else if replication_slot_name.is_empty() {
return Err(Error::BadRequest(
"Replication slot name must not be empty".to_string(),
));
}
(publication_name, replication_slot_name)
};
sqlx::query!(
r#"
INSERT INTO postgres_trigger (
workspace_id,
path,
postgres_resource_path,
replication_slot_name,
publication_name,
script_path,
is_flow,
mode,
edited_by,
permissioned_as,
edited_at,
error_handler_path,
error_handler_args,
retry
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), $11, $12, $13
)
"#,
w_id,
trigger.base.path,
postgres_resource_path,
slot_name,
pub_name,
trigger.base.script_path,
trigger.base.is_flow,
trigger.base.mode() as _,
&resolved_edited_by,
resolved_permissioned_as,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(tx)
.await?;
Ok(())
}
async fn update_trigger(
&self,
db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
path: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let resolved_edited_by = trigger.base.resolve_edited_by(authed);
let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed);
let Self::TriggerConfigRequest {
replication_slot_name,
publication_name,
postgres_resource_path,
publication,
} = trigger.config;
let mut pg_connection = get_default_pg_connection(
authed.clone(),
Some(UserDB::new(db.clone())),
db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let exists =
check_if_logical_replication_slot_exist(&mut pg_connection, &replication_slot_name)
.await?;
let remote_db_tx = pg_connection.transaction().await.map_err(to_anyhow)?;
if !exists {
tracing::debug!(
"Logical replication slot named: {} does not exists creating it...",
&replication_slot_name
);
create_logical_replication_slot(remote_db_tx.client(), &replication_slot_name)
.await
.map_err(to_anyhow)?;
}
if let Some(publication) = publication {
let publication_data =
get_publication_scope_and_transaction(remote_db_tx.client(), &publication_name)
.await
.map_err(to_anyhow)?;
update_pg_publication(
remote_db_tx.client(),
&publication_name,
publication,
publication_data.map(|publication| publication.0),
)
.await
.map_err(to_anyhow)?;
}
remote_db_tx.commit().await.map_err(to_anyhow)?;
sqlx::query!(
r#"
UPDATE postgres_trigger
SET
postgres_resource_path = $1,
replication_slot_name = $2,
publication_name = $3,
script_path = $4,
path = $5,
is_flow = $6,
edited_by = $7,
permissioned_as = $8,
edited_at = now(),
server_id = NULL,
error = NULL,
error_handler_path = $11,
error_handler_args = $12,
retry = $13
WHERE
workspace_id = $9 AND path = $10
"#,
postgres_resource_path,
replication_slot_name,
publication_name,
trigger.base.script_path,
trigger.base.path,
trigger.base.is_flow,
&resolved_edited_by,
resolved_permissioned_as,
w_id,
path,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(tx)
.await?;
Ok(())
}
async fn test_connection(
&self,
db: &DB,
authed: &ApiAuthed,
user_db: &UserDB,
workspace_id: &str,
config: Self::TestConnectionConfig,
) -> Result<()> {
let connect_f = async {
get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
db,
&config.postgres_resource_path,
workspace_id,
)
.await
.map_err(|err| {
Error::BadConfig(format!("Error connecting to postgres: {}", err.to_string()))
})?;
Ok::<(), Error>(())
};
connect_f.await?;
Ok(())
}
fn additional_routes(&self) -> Router {
Router::new()
.route("/get_template_script/:id", get(get_template_script))
.route("/create_template_script", post(create_template_script))
.route(
"/is_valid_postgres_configuration/*path",
get(is_database_in_logical_level),
)
.nest("/publication", publication_service())
.nest("/slot", slot_service())
.nest("/postgres", postgres_service())
}
}
fn publication_service() -> Router {
Router::new()
.route("/get/:publication_name/*path", get(get_publication_info))
.route("/create/:publication_name/*path", post(create_publication))
.route("/update/:publication_name/*path", post(alter_publication))
.route(
"/delete/:publication_name/*path",
delete(delete_publication),
)
.route("/list/*path", get(list_database_publication))
}
fn slot_service() -> Router {
Router::new()
.route("/list/*path", get(list_slot_name))
.route("/create/*path", post(create_slot))
.route("/delete/*path", delete(drop_slot_name))
}
fn postgres_service() -> Router {
Router::new().route("/version/*path", get(get_postgres_version))
}
async fn check_if_logical_replication_slot_exist(
pg_connection: &mut Client,
replication_slot_name: &str,
) -> Result<bool> {
let row = pg_connection
.query_opt(
"SELECT slot_name FROM pg_replication_slots WHERE slot_name = $1",
&[&replication_slot_name],
)
.await
.map_err(to_anyhow)?;
Ok(row.is_some())
}
async fn create_custom_slot_and_publication_inner(
authed: ApiAuthed,
user_db: UserDB,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
publication: &PublicationData,
) -> Result<PostgresPublicationReplication> {
let mut pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let tx = pg_connection.transaction().await.map_err(to_anyhow)?;
let publication_name = format!("windmill_trigger_{}", generate_random_string());
let replication_slot_name = publication_name.clone();
create_logical_replication_slot(tx.client(), &replication_slot_name).await?;
create_pg_publication(
&tx.client(),
&publication_name,
publication.table_to_track.as_deref(),
&publication.transaction_to_track,
)
.await?;
tx.commit().await.map_err(to_anyhow)?;
Ok(PostgresPublicationReplication::new(
publication_name,
replication_slot_name,
))
}
pub async fn get_postgres_version_internal(pg_connection: &Client) -> Result<String> {
let row = pg_connection
.query_one("SHOW server_version;", &[])
.await
.map_err(to_anyhow)?;
let postgres_version: String = row.get(0);
Ok(postgres_version)
}
pub async fn get_postgres_version(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
) -> Result<String> {
let pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let postgres_version = get_postgres_version_internal(&pg_connection).await?;
Ok(postgres_version)
}
pub async fn list_slot_name(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
) -> Result<Json<Vec<SlotList>>> {
let pg_connection: Client = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let rows = pg_connection
.query(
r#"
SELECT
slot_name,
active
FROM
pg_replication_slots
WHERE
plugin = 'pgoutput' AND
slot_type = 'logical';
"#,
&[],
)
.await
.map_err(to_anyhow)?;
let slots = rows
.into_iter()
.map(|row| SlotList { slot_name: row.get("slot_name"), active: row.get("active") })
.collect();
Ok(Json(slots))
}
pub async fn create_slot(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
Json(Slot { name }): Json<Slot>,
) -> Result<String> {
let pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
create_logical_replication_slot(&pg_connection, &name).await?;
Ok(format!("Replication slot {} created!", name))
}
pub async fn drop_logical_replication_slot(pg_connection: &Client, slot_name: &str) -> Result<()> {
let row = pg_connection
.query_opt(
r#"
SELECT
active_pid
FROM
pg_replication_slots
WHERE
slot_name = $1
"#,
&[&slot_name],
)
.await
.map_err(to_anyhow)?;
let active_pid = row.map(|r| r.get::<_, Option<i32>>(0)).flatten();
if let Some(pid) = active_pid {
pg_connection
.execute("SELECT pg_terminate_backend($1)", &[&pid])
.await
.map_err(to_anyhow)?;
}
pg_connection
.execute("SELECT pg_drop_replication_slot($1)", &[&slot_name])
.await
.map_err(to_anyhow)?;
Ok(())
}
pub async fn drop_slot_name(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
Json(Slot { name }): Json<Slot>,
) -> Result<String> {
let pg_connection =
get_default_pg_connection(authed, Some(user_db), &db, &postgres_resource_path, &w_id)
.await
.map_err(to_anyhow)?;
drop_logical_replication_slot(&pg_connection, &name)
.await
.map_err(to_anyhow)?;
Ok(format!("Replication slot {} deleted!", name))
}
pub async fn list_database_publication(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
) -> Result<Json<Vec<String>>> {
let pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let rows = pg_connection
.query(
"SELECT pubname AS publication_name FROM pg_publication;",
&[],
)
.await
.map_err(to_anyhow)?;
let publications = rows
.into_iter()
.map(|row| row.get::<_, String>("publication_name"))
.collect_vec();
Ok(Json(publications))
}
pub async fn get_publication_info(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
) -> Result<Json<PublicationData>> {
let mut pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let publication_data =
get_publication_scope_and_transaction(&mut pg_connection, &publication_name).await;
let (all_table, transaction_to_track) = match publication_data {
Ok(Some(pub_data)) => pub_data,
Ok(None) => {
return Err(Error::NotFound(
ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(),
))
}
Err(e) => return Err(e),
};
let table_to_track = if !all_table {
Some(get_tracked_relations(&mut pg_connection, &publication_name).await?)
} else {
None
};
Ok(Json(PublicationData::new(
table_to_track,
transaction_to_track,
)))
}
pub async fn create_publication(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
Json(publication_data): Json<PublicationData>,
) -> Result<String> {
let mut pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let PublicationData { table_to_track, transaction_to_track } = publication_data;
let tx = pg_connection.transaction().await.map_err(to_anyhow)?;
create_pg_publication(
tx.client(),
&publication_name,
table_to_track.as_deref(),
&transaction_to_track,
)
.await?;
tx.commit().await.map_err(to_anyhow)?;
Ok(format!(
"Publication {} successfully created!",
publication_name
))
}
pub async fn delete_publication(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
) -> Result<String> {
let mut pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
drop_publication(&mut pg_connection, &publication_name).await?;
Ok(format!(
"Publication {} successfully deleted!",
publication_name
))
}
pub async fn update_pg_publication(
pg_connection: &Client,
publication_name: &str,
PublicationData { table_to_track, transaction_to_track }: PublicationData,
all_table: Option<bool>,
) -> Result<()> {
let quoted_publication_name = quote_identifier(publication_name);
let transaction_to_track_as_str = transaction_to_track.iter().join(",");
match table_to_track {
Some(ref relations) if !relations.is_empty() => {
// If all_table is None, the publication does not exist yet
if all_table.unwrap_or(true) {
if all_table.is_some_and(|all_table| all_table) {
drop_publication(pg_connection, publication_name)
.await
.map_err(to_anyhow)?;
}
create_pg_publication(
pg_connection,
publication_name,
table_to_track.as_deref(),
&transaction_to_track,
)
.await
.map_err(to_anyhow)?;
} else {
let pg_14 = check_if_valid_publication_for_postgres_version(
pg_connection,
table_to_track.as_deref(),
)
.await
.map_err(to_anyhow)?;
let mut query = format!("ALTER PUBLICATION {} SET ", quoted_publication_name);
let mut first = true;
for (i, schema) in relations.iter().enumerate() {
if schema.table_to_track.is_empty() {
query.push_str("TABLES IN SCHEMA ");
query.push_str(&quote_identifier(&schema.schema_name));
} else {
if pg_14 && first {
query.push_str("TABLE ONLY ");
first = false;
} else if !pg_14 {
query.push_str("TABLE ONLY ");
}
for (j, table) in schema.table_to_track.iter().enumerate() {
let table_name = quote_identifier(&table.table_name);
let schema_name = quote_identifier(&schema.schema_name);
let full_name = format!("{}.{}", schema_name, table_name);
query.push_str(&full_name);
if let Some(columns) = table.columns_name.as_ref() {
let cols =
columns.iter().map(|col| quote_identifier(col)).join(", ");
query.push_str(&format!(" ({})", cols));
}
if let Some(where_clause) = &table.where_clause {
query.push_str(&format!(" WHERE ({})", where_clause));
}
if j + 1 != schema.table_to_track.len() {
query.push_str(", ");
}
}
}
if i + 1 != relations.len() {
query.push_str(", ");
}
}
pg_connection
.execute(&query, &[])
.await
.map_err(to_anyhow)?;
let publish_query = format!(
"ALTER PUBLICATION {} SET (publish = '{}');",
quoted_publication_name, transaction_to_track_as_str
);
pg_connection
.execute(&publish_query, &[])
.await
.map_err(to_anyhow)?;
}
}
_ => {
drop_publication(pg_connection, publication_name)
.await
.map_err(to_anyhow)?;
let create_all_query = format!(
"CREATE PUBLICATION {} FOR ALL TABLES WITH (publish = '{}');",
quoted_publication_name, transaction_to_track_as_str
);
pg_connection
.execute(&create_all_query, &[])
.await
.map_err(to_anyhow)?;
}
}
Ok(())
}
pub async fn alter_publication(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, publication_name, postgres_resource_path)): Path<(String, String, String)>,
Json(publication_data): Json<PublicationData>,
) -> Result<String> {
let mut pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let tx = pg_connection.transaction().await.map_err(to_anyhow)?;
let publication = get_publication_scope_and_transaction(tx.client(), &publication_name)
.await
.map_err(to_anyhow)?;
update_pg_publication(
tx.client(),
&publication_name,
publication_data,
publication.map(|publication| publication.0),
)
.await
.map_err(to_anyhow)?;
tx.commit().await.map_err(to_anyhow)?;
Ok(format!(
"Publication {} updated with success",
publication_name
))
}
pub async fn get_publication_scope_and_transaction(
pg_connection: &Client,
publication_name: &str,
) -> Result<Option<(bool, Vec<String>)>> {
let row_opt = pg_connection
.query_opt(
r#"
SELECT
puballtables AS all_table,
pubinsert AS insert,
pubupdate AS update,
pubdelete AS delete
FROM
pg_publication
WHERE
pubname = $1
"#,
&[&publication_name],
)
.await
.map_err(to_anyhow)?;
let row = match row_opt {
Some(r) => r,
None => return Ok(None),
};
let all_table: bool = row.get("all_table");
let pub_insert: bool = row.get("insert");
let pub_update: bool = row.get("update");
let pub_delete: bool = row.get("delete");
let mut transaction_to_track = Vec::with_capacity(3);
if pub_insert {
transaction_to_track.push("insert".to_string());
}
if pub_update {
transaction_to_track.push("update".to_string());
}
if pub_delete {
transaction_to_track.push("delete".to_string());
}
Ok(Some((all_table, transaction_to_track)))
}
pub async fn get_tracked_relations(
pg_connection: &Client,
publication_name: &str,
) -> Result<Vec<Relations>> {
let pg_version = get_postgres_version_internal(pg_connection).await?;
let query = if pg_version.starts_with("14") {
r#"
SELECT
schemaname AS schema_name,
tablename AS table_name,
NULL::text[] AS columns,
NULL::text AS where_clause
FROM
pg_publication_tables
WHERE
pubname = $1;
"#
} else {
r#"
SELECT
schemaname AS schema_name,
tablename AS table_name,
attnames AS columns,
rowfilter AS where_clause
FROM
pg_publication_tables
WHERE
pubname = $1;
"#
};
let rows = pg_connection
.query(query, &[&publication_name])
.await
.map_err(to_anyhow)?;
let mut table_to_track: HashMap<String, Relations> = HashMap::new();
for row in rows {
let schema_name: Option<String> = row.get("schema_name");
let table_name: Option<String> = row.get("table_name");
let columns: Option<Vec<String>> = row.get("columns");
let where_clause: Option<String> = row.get("where_clause");
let schema_name = schema_name.ok_or_else::<Error, _>(|| {
anyhow::anyhow!(
"Unexpected NULL `schema_name` in publication entry (pubname: `{}`). This should never happen unless PostgreSQL internals are corrupted.",
publication_name,
)
.into()
})?;
let table_name = table_name.ok_or_else::<Error, _>(|| {
anyhow::anyhow!(
"Unexpected NULL `table_name` for schema `{}` in publication `{}`. This should never happen unless PostgreSQL internals are corrupted.",
schema_name,
publication_name,
)
.into()
})?;
let entry = table_to_track.entry(schema_name.clone());
let table_to_track_item = TableToTrack::new(table_name, where_clause, columns);
match entry {
std::collections::hash_map::Entry::Occupied(mut occupied) => {
occupied.get_mut().add_new_table(table_to_track_item);
}
std::collections::hash_map::Entry::Vacant(vacant) => {
vacant.insert(Relations::new(schema_name, vec![table_to_track_item]));
}
}
}
Ok(table_to_track.into_values().collect_vec())
}
pub async fn get_template_script(Path((_, id)): Path<(String, String)>) -> Result<String> {
let template = if let Some((_, template)) = TEMPLATE.remove(&id) {
template
} else {
"".to_string()
};
Ok(template)
}
pub async fn create_template_script(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(template_script): Json<TemplateScript>,
) -> Result<String> {
let TemplateScript { postgres_resource_path, relations, language } = template_script;
let relations = match relations {
Some(r) => r,
None => {
return Err(
anyhow::anyhow!("You must at least choose schema to fetch table from").into(),
)
}
};
let pg_connection: Client = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let mut schema_or_fully_qualified_name = Vec::with_capacity(relations.len());
let mut columns_list = Vec::with_capacity(relations.len());
for relation in relations {
if !relation.table_to_track.is_empty() {
for table in relation.table_to_track {
let fully_qualified_name = format!("{}.{}", relation.schema_name, table.table_name);
schema_or_fully_qualified_name.push(quote_literal(&fully_qualified_name));
let columns = table
.columns_name
.map(|c| quote_literal(&c.join(",")))
.unwrap_or_else(|| "''".to_string());
columns_list.push(columns);
}
} else {
schema_or_fully_qualified_name.push(quote_literal(&relation.schema_name));
columns_list.push("''".to_string());
}
}
let tables_name = schema_or_fully_qualified_name.join(",");
let columns_list = columns_list.join(",");
let query = format!(
r#"
WITH table_column_mapping AS (
SELECT
unnest(ARRAY[{}]) AS table_name,
unnest(ARRAY[{}]) AS column_list
),
parsed_columns AS (
SELECT
tcm.table_name,
CASE
WHEN tcm.column_list = '' THEN NULL
ELSE string_to_array(tcm.column_list, ',')
END AS columns
FROM table_column_mapping tcm
)
SELECT
ns.nspname AS table_schema,
cls.relname AS table_name,
attr.attname AS column_name,
attr.atttypid AS oid,
attr.attnotnull AS is_nullable
FROM pg_attribute attr
JOIN pg_class cls ON attr.attrelid = cls.oid
JOIN pg_namespace ns ON cls.relnamespace = ns.oid
JOIN parsed_columns pc
ON ns.nspname || '.' || cls.relname = pc.table_name
OR ns.nspname = pc.table_name
WHERE
attr.attnum > 0
AND NOT attr.attisdropped
AND cls.relkind = 'r'
AND (
pc.columns IS NULL
OR attr.attname = ANY(pc.columns)
);
"#,
tables_name, columns_list
);
let rows = pg_connection.query(&query, &[]).await.map_err(to_anyhow)?;
let mut schema_map: HashMap<String, HashMap<String, Vec<MappingInfo>>> = HashMap::new();
#[derive(Debug)]
struct ColumnInfo {
table_schema: String,
table_name: String,
column_name: String,
oid: u32,
is_nullable: bool,
}
for row in rows {
let info = ColumnInfo {
table_schema: row.get("table_schema"),
table_name: row.get("table_name"),
column_name: row.get("column_name"),
oid: row.get::<_, u32>("oid"),
is_nullable: row.get::<_, bool>("is_nullable"),
};
let mapped_info =
MappingInfo::new(info.column_name, Type::from_oid(info.oid), info.is_nullable);
match schema_map.entry(info.table_schema) {
std::collections::hash_map::Entry::Occupied(mut schema_entry) => {
match schema_entry.get_mut().entry(info.table_name) {
std::collections::hash_map::Entry::Occupied(mut table_entry) => {
table_entry.get_mut().push(mapped_info);
}
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(vec![mapped_info]);
}
}
}
std::collections::hash_map::Entry::Vacant(schema_vacant) => {
let mut table_map = HashMap::new();
table_map.insert(info.table_name, vec![mapped_info]);
schema_vacant.insert(table_map);
}
}
}
let mapper = Mapper::new(schema_map, language);
let template = mapper.get_template();
let id = format!("{}-{}", w_id, uuid::Uuid::new_v4());
TEMPLATE.insert(id.clone(), template);
Ok(id)
}
pub async fn is_database_in_logical_level(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, postgres_resource_path)): Path<(String, String)>,
) -> error::JsonResult<bool> {
let pg_connection = get_default_pg_connection(
authed.clone(),
Some(user_db.clone()),
&db,
&postgres_resource_path,
&w_id,
)
.await
.map_err(to_anyhow)?;
let row_opt = pg_connection
.query_opt("SHOW wal_level;", &[])
.await
.map_err(to_anyhow)?;
let wal_level: Option<String> = row_opt.map(|row| row.get(0));
let is_logical = matches!(wal_level.as_deref(), Some("logical"));
Ok(Json(is_logical))
}