From 8e94c9162af7f5807e96a46ae0428fa8e63c1238 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Thu, 16 Jul 2026 22:11:21 +0200 Subject: [PATCH] Identity consistency: slug webhooks, pipeline refs by slug, drop dead UUID code Closes the remaining UUID inconsistencies an audit surfaced after the slug/number redesign, so every workspace-owned config resource is addressed the same way. Webhooks by slug - Add an immutable per-workspace slug (migration + UNIQUE(workspace_id, slug) + format/length checks), mirroring connections/pipelines/policies/contexts. - Route is {webhookSlug}; create takes a required slug; a duplicate is a 409. Response is slug-only (drops the webhook UUID). find_webhook resolves by slug. Pipeline policy/context references by slug - PipelineDefinition exposes policySlugs/contextSlugs instead of raw UUIDs. - On write, the slugs are resolved to ids (scoped to the pipeline's workspace, 404 on any unknown reference) before the join tables are written; the join tables keep UUID foreign keys. On read, the referenced slugs are listed back via a join. The engine run path still resolves by id internally. Dead code - Remove the unused create_connection_with_unique_slug (connections require an explicit slug; a duplicate is a 409) and the orphaned WorkspacePathParams / FilePathParams / VersionPathParams path structs. Co-Authored-By: Claude Opus 4.8 --- crates/nvisy-postgres/src/model/workspace.rs | 3 - .../src/model/workspace_pipeline.rs | 3 - .../src/model/workspace_webhook.rs | 8 +- .../src/query/pipeline_reference.rs | 136 +++++++++++++++++- .../src/query/workspace_connection.rs | 51 ------- .../src/query/workspace_webhook.rs | 27 ++++ crates/nvisy-postgres/src/schema.rs | 1 + .../types/constraint/workspace_webhooks.rs | 13 +- .../src/handler/error/pg_workspace.rs | 8 ++ crates/nvisy-server/src/handler/pipelines.rs | 82 +++++++---- .../nvisy-server/src/handler/request/paths.rs | 34 +---- .../src/handler/request/pipelines.rs | 56 ++++---- .../src/handler/request/webhooks.rs | 7 +- .../src/handler/response/pipelines.rs | 36 +++-- .../src/handler/response/webhooks.rs | 6 +- crates/nvisy-server/src/handler/webhooks.rs | 35 +++-- migrations/2025-05-21-222842_webhooks/up.sql | 7 + .../2026-01-19-045013_connections/up.sql | 4 +- migrations/2026-01-19-045014_policies/up.sql | 4 +- migrations/2026-01-19-045015_contexts/up.sql | 4 +- migrations/2026-01-19-045016_pipelines/up.sql | 4 +- 21 files changed, 326 insertions(+), 203 deletions(-) diff --git a/crates/nvisy-postgres/src/model/workspace.rs b/crates/nvisy-postgres/src/model/workspace.rs index 8cb8ec9e..0970ef1e 100644 --- a/crates/nvisy-postgres/src/model/workspace.rs +++ b/crates/nvisy-postgres/src/model/workspace.rs @@ -41,9 +41,6 @@ pub struct Workspace { } /// Data for creating a new workspace. -/// -/// Not `Default`: a workspace cannot exist without a [`Slug`], so the -/// slug must always be supplied explicitly. #[derive(Debug, Clone, Insertable)] #[diesel(table_name = workspaces)] #[diesel(check_for_backend(diesel::pg::Pg))] diff --git a/crates/nvisy-postgres/src/model/workspace_pipeline.rs b/crates/nvisy-postgres/src/model/workspace_pipeline.rs index 26297412..4e69d710 100644 --- a/crates/nvisy-postgres/src/model/workspace_pipeline.rs +++ b/crates/nvisy-postgres/src/model/workspace_pipeline.rs @@ -45,9 +45,6 @@ pub struct WorkspacePipeline { } /// Data for creating a new workspace pipeline. -/// -/// Not `Default`: a pipeline cannot exist without a [`Slug`], so the slug -/// must always be supplied explicitly. #[derive(Debug, Clone, Insertable)] #[diesel(table_name = workspace_pipelines)] #[diesel(check_for_backend(diesel::pg::Pg))] diff --git a/crates/nvisy-postgres/src/model/workspace_webhook.rs b/crates/nvisy-postgres/src/model/workspace_webhook.rs index 0a53c7db..d4a78cb5 100644 --- a/crates/nvisy-postgres/src/model/workspace_webhook.rs +++ b/crates/nvisy-postgres/src/model/workspace_webhook.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::schema::workspace_webhooks; use crate::types::{ - HasCreatedAt, HasDeletedAt, HasOwnership, HasUpdatedAt, WebhookEvent, WebhookStatus, + HasCreatedAt, HasDeletedAt, HasOwnership, HasUpdatedAt, Slug, WebhookEvent, WebhookStatus, }; /// Workspace webhook model representing a webhook configuration for a workspace. @@ -27,6 +27,8 @@ pub struct WorkspaceWebhook { pub id: Uuid, /// Reference to the workspace this webhook belongs to. pub workspace_id: Uuid, + /// URL-safe webhook identifier, unique within the workspace. + pub slug: Slug, /// Human-readable name for the webhook. pub display_name: String, /// Description of the webhook's purpose. @@ -54,12 +56,14 @@ pub struct WorkspaceWebhook { } /// Data structure for creating a new workspace webhook. -#[derive(Debug, Default, Clone, Insertable)] +#[derive(Debug, Clone, Insertable)] #[diesel(table_name = workspace_webhooks)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct NewWorkspaceWebhook { /// Reference to the workspace this webhook will belong to. pub workspace_id: Uuid, + /// URL-safe webhook identifier, unique within the workspace. + pub slug: Slug, /// Human-readable name for the webhook. pub display_name: String, /// Description of the webhook's purpose. diff --git a/crates/nvisy-postgres/src/query/pipeline_reference.rs b/crates/nvisy-postgres/src/query/pipeline_reference.rs index f9c7051c..2ab26bee 100644 --- a/crates/nvisy-postgres/src/query/pipeline_reference.rs +++ b/crates/nvisy-postgres/src/query/pipeline_reference.rs @@ -13,6 +13,7 @@ use diesel_async::RunQueryDsl; use uuid::Uuid; use crate::model::{PipelineContext, PipelinePolicy}; +use crate::types::Slug; use crate::{PgConnection, PgError, PgResult, schema}; /// Repository for pipeline reference join tables. @@ -36,17 +37,50 @@ pub trait PipelineReferenceRepository { context_ids: &[Uuid], ) -> impl Future> + Send; - /// Lists the policy ids a pipeline references. + /// Lists the ids of the policies a pipeline references. + /// + /// Used by the run path to resolve each referenced policy to its record for + /// the engine; the API-facing read path uses [`Self::list_pipeline_policy_slugs`]. fn list_pipeline_policy_ids( &mut self, pipeline_id: Uuid, ) -> impl Future>> + Send; - /// Lists the context ids a pipeline references. + /// Lists the ids of the contexts a pipeline references. fn list_pipeline_context_ids( &mut self, pipeline_id: Uuid, ) -> impl Future>> + Send; + + /// Lists the slugs of the policies a pipeline references. + fn list_pipeline_policy_slugs( + &mut self, + pipeline_id: Uuid, + ) -> impl Future>> + Send; + + /// Lists the slugs of the contexts a pipeline references. + fn list_pipeline_context_slugs( + &mut self, + pipeline_id: Uuid, + ) -> impl Future>> + Send; + + /// Resolves policy slugs to their ids within a workspace, preserving order. + /// + /// Returns `None` if any slug does not match a live policy in the workspace, + /// so the caller can reject the whole set rather than silently dropping an + /// unknown reference. + fn resolve_policy_slugs( + &mut self, + workspace_id: Uuid, + slugs: &[Slug], + ) -> impl Future>>> + Send; + + /// Resolves context slugs to their ids within a workspace, preserving order. + fn resolve_context_slugs( + &mut self, + workspace_id: Uuid, + slugs: &[Slug], + ) -> impl Future>>> + Send; } impl PipelineReferenceRepository for PgConnection { @@ -119,8 +153,6 @@ impl PipelineReferenceRepository for PgConnection { async fn list_pipeline_policy_ids(&mut self, pipeline_id: Uuid) -> PgResult> { use schema::{workspace_pipeline_policies, workspace_policies}; - // Join to the parent so soft-deleted policies (deleted_at set, join row - // still present since CASCADE only fires on hard delete) are excluded. let ids = workspace_pipeline_policies::table .inner_join( workspace_policies::table @@ -153,6 +185,102 @@ impl PipelineReferenceRepository for PgConnection { Ok(ids) } + + async fn list_pipeline_policy_slugs(&mut self, pipeline_id: Uuid) -> PgResult> { + use schema::{workspace_pipeline_policies, workspace_policies}; + + // Join to the parent so soft-deleted policies (deleted_at set, join row + // still present since CASCADE only fires on hard delete) are excluded. + let slugs = workspace_pipeline_policies::table + .inner_join( + workspace_policies::table + .on(workspace_policies::id.eq(workspace_pipeline_policies::policy_id)), + ) + .filter(workspace_pipeline_policies::pipeline_id.eq(pipeline_id)) + .filter(workspace_policies::deleted_at.is_null()) + .select(workspace_policies::slug) + .load(self) + .await + .map_err(PgError::from)?; + + Ok(slugs) + } + + async fn list_pipeline_context_slugs(&mut self, pipeline_id: Uuid) -> PgResult> { + use schema::{workspace_contexts, workspace_pipeline_contexts}; + + let slugs = workspace_pipeline_contexts::table + .inner_join( + workspace_contexts::table + .on(workspace_contexts::id.eq(workspace_pipeline_contexts::context_id)), + ) + .filter(workspace_pipeline_contexts::pipeline_id.eq(pipeline_id)) + .filter(workspace_contexts::deleted_at.is_null()) + .select(workspace_contexts::slug) + .load(self) + .await + .map_err(PgError::from)?; + + Ok(slugs) + } + + async fn resolve_policy_slugs( + &mut self, + workspace_id: Uuid, + slugs: &[Slug], + ) -> PgResult>> { + use schema::workspace_policies::{self, dsl}; + + if slugs.is_empty() { + return Ok(Some(Vec::new())); + } + + let wanted: Vec = slugs.iter().map(|slug| slug.as_str().to_owned()).collect(); + let found: Vec<(Slug, Uuid)> = workspace_policies::table + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .filter(dsl::slug.eq_any(&wanted)) + .select((dsl::slug, dsl::id)) + .load(self) + .await + .map_err(PgError::from)?; + + Ok(map_slugs_to_ids(slugs, found)) + } + + async fn resolve_context_slugs( + &mut self, + workspace_id: Uuid, + slugs: &[Slug], + ) -> PgResult>> { + use schema::workspace_contexts::{self, dsl}; + + if slugs.is_empty() { + return Ok(Some(Vec::new())); + } + + let wanted: Vec = slugs.iter().map(|slug| slug.as_str().to_owned()).collect(); + let found: Vec<(Slug, Uuid)> = workspace_contexts::table + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .filter(dsl::slug.eq_any(&wanted)) + .select((dsl::slug, dsl::id)) + .load(self) + .await + .map_err(PgError::from)?; + + Ok(map_slugs_to_ids(slugs, found)) + } +} + +/// Maps the requested slugs to ids in request order, returning `None` if any +/// requested slug is missing from the resolved set. +fn map_slugs_to_ids(requested: &[Slug], found: Vec<(Slug, Uuid)>) -> Option> { + let by_slug: std::collections::HashMap = found.into_iter().collect(); + requested + .iter() + .map(|slug| by_slug.get(slug).copied()) + .collect() } /// Deduplicates ids, preserving first-seen order. diff --git a/crates/nvisy-postgres/src/query/workspace_connection.rs b/crates/nvisy-postgres/src/query/workspace_connection.rs index 0427ea61..1aafb7ac 100644 --- a/crates/nvisy-postgres/src/query/workspace_connection.rs +++ b/crates/nvisy-postgres/src/query/workspace_connection.rs @@ -10,10 +10,6 @@ use crate::model::{NewWorkspaceConnection, UpdateWorkspaceConnection, WorkspaceC use crate::types::{CursorPage, CursorPagination, OffsetPagination}; use crate::{PgConnection, PgError, PgResult, schema}; -/// Maximum number of slug candidates tried before giving up when generating a -/// unique connection slug (the preferred slug plus numeric suffixes). -const MAX_SLUG_ATTEMPTS: u32 = 100; - /// Repository for workspace connection database operations. /// /// Handles connection lifecycle management including creation, updates, @@ -25,17 +21,6 @@ pub trait WorkspaceConnectionRepository { new_connection: NewWorkspaceConnection, ) -> impl Future> + Send; - /// Creates a connection, resolving slug collisions with a numeric suffix. - /// - /// The `new_connection.slug` is treated as the preferred slug. If it is - /// already taken within the workspace, the insert is retried with `-2`, - /// `-3`, … suffixes until one succeeds. The retry is driven by the - /// database's unique constraint, so it is race-safe. - fn create_connection_with_unique_slug( - &mut self, - new_connection: NewWorkspaceConnection, - ) -> impl Future> + Send; - /// Finds a connection by its unique identifier. fn find_workspace_connection_by_id( &mut self, @@ -126,42 +111,6 @@ impl WorkspaceConnectionRepository for PgConnection { Ok(connection) } - async fn create_connection_with_unique_slug( - &mut self, - new_connection: NewWorkspaceConnection, - ) -> PgResult { - let preferred = new_connection.slug.clone(); - - // Attempt the preferred slug first, then `-2`, `-3`, … on collision. - // Each attempt uses a fresh candidate cloned from the base record, so a - // failed insert never consumes the data needed for the next try. The - // loop is bounded; in practice the first suffix or two resolves any - // realistic collision. - for attempt in 0..MAX_SLUG_ATTEMPTS { - let candidate = if attempt == 0 { - new_connection.clone() - } else { - let slug = preferred.with_numeric_suffix(attempt + 1).ok_or_else(|| { - PgError::unexpected("connection slug cannot be disambiguated") - })?; - NewWorkspaceConnection { - slug, - ..new_connection.clone() - } - }; - - match self.create_workspace_connection(candidate).await { - Ok(created) => return Ok(created), - Err(error) if error.is_slug_conflict() => continue, - Err(error) => return Err(error), - } - } - - Err(PgError::unexpected( - "exhausted connection slug generation attempts", - )) - } - async fn find_workspace_connection_by_id( &mut self, connection_id: Uuid, diff --git a/crates/nvisy-postgres/src/query/workspace_webhook.rs b/crates/nvisy-postgres/src/query/workspace_webhook.rs index d7afe1a4..80f58c33 100644 --- a/crates/nvisy-postgres/src/query/workspace_webhook.rs +++ b/crates/nvisy-postgres/src/query/workspace_webhook.rs @@ -35,6 +35,13 @@ pub trait WorkspaceWebhookRepository { webhook_id: Uuid, ) -> impl Future>> + Send; + /// Finds a webhook by slug within a workspace, excluding soft-deleted rows. + fn find_webhook_in_workspace_by_slug( + &mut self, + workspace_id: Uuid, + slug: &str, + ) -> impl Future>> + Send; + /// Lists all webhooks for a workspace with offset pagination. fn offset_list_workspace_webhooks( &mut self, @@ -161,6 +168,26 @@ impl WorkspaceWebhookRepository for PgConnection { Ok(webhook) } + async fn find_webhook_in_workspace_by_slug( + &mut self, + workspace_id: Uuid, + slug_value: &str, + ) -> PgResult> { + use schema::workspace_webhooks::{self, dsl}; + + let webhook = workspace_webhooks::table + .filter(dsl::slug.eq(slug_value)) + .filter(dsl::workspace_id.eq(workspace_id)) + .filter(dsl::deleted_at.is_null()) + .select(WorkspaceWebhook::as_select()) + .first(self) + .await + .optional() + .map_err(PgError::from)?; + + Ok(webhook) + } + async fn offset_list_workspace_webhooks( &mut self, workspace_id: Uuid, diff --git a/crates/nvisy-postgres/src/schema.rs b/crates/nvisy-postgres/src/schema.rs index cc248a84..ff9612ad 100644 --- a/crates/nvisy-postgres/src/schema.rs +++ b/crates/nvisy-postgres/src/schema.rs @@ -366,6 +366,7 @@ diesel::table! { workspace_webhooks (id) { id -> Uuid, workspace_id -> Uuid, + slug -> Text, display_name -> Text, description -> Text, url -> Text, diff --git a/crates/nvisy-postgres/src/types/constraint/workspace_webhooks.rs b/crates/nvisy-postgres/src/types/constraint/workspace_webhooks.rs index b749b57d..37a4e856 100644 --- a/crates/nvisy-postgres/src/types/constraint/workspace_webhooks.rs +++ b/crates/nvisy-postgres/src/types/constraint/workspace_webhooks.rs @@ -13,8 +13,14 @@ pub enum WorkspaceWebhookConstraints { // Webhook unique constraints #[strum(serialize = "workspace_webhooks_workspace_id_id_key")] WorkspaceIdIdUnique, + #[strum(serialize = "workspace_webhooks_workspace_id_slug_key")] + SlugUnique, // Webhook validation constraints + #[strum(serialize = "workspace_webhooks_slug_length")] + SlugLength, + #[strum(serialize = "workspace_webhooks_slug_format")] + SlugFormat, #[strum(serialize = "workspace_webhooks_display_name_length")] DisplayNameLength, #[strum(serialize = "workspace_webhooks_description_length")] @@ -44,9 +50,12 @@ impl WorkspaceWebhookConstraints { /// Returns the category of this constraint violation. pub fn categorize(&self) -> ConstraintCategory { match self { - WorkspaceWebhookConstraints::WorkspaceIdIdUnique => ConstraintCategory::Uniqueness, + WorkspaceWebhookConstraints::WorkspaceIdIdUnique + | WorkspaceWebhookConstraints::SlugUnique => ConstraintCategory::Uniqueness, - WorkspaceWebhookConstraints::DisplayNameLength + WorkspaceWebhookConstraints::SlugLength + | WorkspaceWebhookConstraints::SlugFormat + | WorkspaceWebhookConstraints::DisplayNameLength | WorkspaceWebhookConstraints::DescriptionLength | WorkspaceWebhookConstraints::UrlLength | WorkspaceWebhookConstraints::UrlFormat diff --git a/crates/nvisy-server/src/handler/error/pg_workspace.rs b/crates/nvisy-server/src/handler/error/pg_workspace.rs index 19483ee2..9004f516 100644 --- a/crates/nvisy-server/src/handler/error/pg_workspace.rs +++ b/crates/nvisy-server/src/handler/error/pg_workspace.rs @@ -96,6 +96,14 @@ impl From for Error<'static> { impl From for Error<'static> { fn from(c: WorkspaceWebhookConstraints) -> Self { let error = match c { + WorkspaceWebhookConstraints::SlugLength => ErrorKind::BadRequest + .with_message("Webhook slug must be between 3 and 32 characters long"), + WorkspaceWebhookConstraints::SlugFormat => ErrorKind::BadRequest.with_message( + "Webhook slug must be lowercase alphanumeric with single internal dashes", + ), + WorkspaceWebhookConstraints::SlugUnique => { + ErrorKind::Conflict.with_message("A webhook with this slug already exists") + } WorkspaceWebhookConstraints::DisplayNameLength => ErrorKind::BadRequest .with_message("Webhook name must be between 3 and 64 characters long"), WorkspaceWebhookConstraints::DescriptionLength => { diff --git a/crates/nvisy-server/src/handler/pipelines.rs b/crates/nvisy-server/src/handler/pipelines.rs index f0fe182d..0d5d7b74 100644 --- a/crates/nvisy-server/src/handler/pipelines.rs +++ b/crates/nvisy-server/src/handler/pipelines.rs @@ -59,21 +59,24 @@ async fn create_pipeline( .into_parts(workspace.id, auth_state.account_id) .map_err(serialize_error)?; + let (policy_ids, context_ids) = + resolve_references(&mut conn, workspace.id, &references).await?; + let pipeline = conn .transaction(async |conn| { let pipeline = conn.create_workspace_pipeline(new_pipeline).await?; - replace_references(conn, &pipeline, &references).await?; + replace_references(conn, &pipeline, &policy_ids, &context_ids).await?; Ok::(pipeline) }) .await?; // The references were just written from the request, so build the response - // from them directly instead of reading the join tables back. + // from its slugs directly instead of reading the join tables back. let response = Pipeline::from_model( pipeline, workspace.slug, - references.policy_ids, - references.context_ids, + references.policy_slugs, + references.context_slugs, ) .map_err(serialize_error)?; @@ -176,17 +179,16 @@ async fn get_pipeline( let pipeline = find_pipeline(&mut conn, workspace.id, &path_params.pipeline_slug).await?; - // Fetch artifacts for all runs of this pipeline let artifacts = conn.list_workspace_pipeline_artifacts(pipeline.id).await?; - let policy_ids = conn.list_pipeline_policy_ids(pipeline.id).await?; - let context_ids = conn.list_pipeline_context_ids(pipeline.id).await?; + let policy_slugs = conn.list_pipeline_policy_slugs(pipeline.id).await?; + let context_slugs = conn.list_pipeline_context_slugs(pipeline.id).await?; let response = Pipeline::from_model_with_artifacts( pipeline, workspace.slug, artifacts, - policy_ids, - context_ids, + policy_slugs, + context_slugs, ) .map_err(serialize_error)?; @@ -236,7 +238,13 @@ async fn update_pipeline( let (update_data, references) = request.into_parts().map_err(serialize_error)?; let pipeline_id = existing.id; - let references_for_response = references.clone(); + + // When a definition is supplied, resolve its slugs to ids up front so an + // unknown reference rejects with 404 before any write. + let resolved = match &references { + Some(references) => Some(resolve_references(&mut conn, workspace.id, references).await?), + None => None, + }; let pipeline = conn .transaction(async |conn| { @@ -244,20 +252,20 @@ async fn update_pipeline( .update_workspace_pipeline(pipeline_id, update_data) .await?; // Only touch the join tables when the request supplied a definition. - if let Some(references) = references { - replace_references(conn, &pipeline, &references).await?; + if let Some((policy_ids, context_ids)) = &resolved { + replace_references(conn, &pipeline, policy_ids, context_ids).await?; } Ok::(pipeline) }) .await?; - let response = match references_for_response { + let response = match references { // A definition was supplied: the references we just wrote are current. Some(references) => Pipeline::from_model( pipeline, workspace.slug, - references.policy_ids, - references.context_ids, + references.policy_slugs, + references.context_slugs, ) .map_err(serialize_error)?, // Partial update left the references untouched: read them back. @@ -342,23 +350,34 @@ async fn find_pipeline( async fn replace_references( conn: &mut PgConnection, pipeline: &WorkspacePipeline, - references: &PipelineReferences, + policy_ids: &[Uuid], + context_ids: &[Uuid], ) -> PgResult<()> { - conn.replace_workspace_pipeline_policies( - pipeline.workspace_id, - pipeline.id, - &references.policy_ids, - ) - .await?; - conn.replace_workspace_pipeline_contexts( - pipeline.workspace_id, - pipeline.id, - &references.context_ids, - ) - .await?; + conn.replace_workspace_pipeline_policies(pipeline.workspace_id, pipeline.id, policy_ids) + .await?; + conn.replace_workspace_pipeline_contexts(pipeline.workspace_id, pipeline.id, context_ids) + .await?; Ok(()) } +/// Resolves a set of policy and context slugs to their ids within a workspace, +/// rejecting the whole request with a 404 if any slug is unknown. +async fn resolve_references( + conn: &mut PgConnection, + workspace_id: Uuid, + references: &PipelineReferences, +) -> Result<(Vec, Vec)> { + let policy_ids = conn + .resolve_policy_slugs(workspace_id, &references.policy_slugs) + .await? + .ok_or_else(|| Error::not_found("policy"))?; + let context_ids = conn + .resolve_context_slugs(workspace_id, &references.context_slugs) + .await? + .ok_or_else(|| Error::not_found("context"))?; + Ok((policy_ids, context_ids)) +} + /// Builds a [`Pipeline`] response, reading the pipeline's (live) references back /// from the join tables. Used when the caller did not just write them. async fn build_response( @@ -366,9 +385,10 @@ async fn build_response( pipeline: WorkspacePipeline, workspace_slug: Slug, ) -> Result { - let policy_ids = conn.list_pipeline_policy_ids(pipeline.id).await?; - let context_ids = conn.list_pipeline_context_ids(pipeline.id).await?; - Pipeline::from_model(pipeline, workspace_slug, policy_ids, context_ids).map_err(serialize_error) + let policy_slugs = conn.list_pipeline_policy_slugs(pipeline.id).await?; + let context_slugs = conn.list_pipeline_context_slugs(pipeline.id).await?; + Pipeline::from_model(pipeline, workspace_slug, policy_slugs, context_slugs) + .map_err(serialize_error) } /// Maps a definition (de)serialization failure to an internal error. diff --git a/crates/nvisy-server/src/handler/request/paths.rs b/crates/nvisy-server/src/handler/request/paths.rs index b44d0360..6cd939f0 100644 --- a/crates/nvisy-server/src/handler/request/paths.rs +++ b/crates/nvisy-server/src/handler/request/paths.rs @@ -4,15 +4,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// Path parameters for workspace-level operations. -#[must_use] -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct WorkspacePathParams { - /// Unique identifier of the workspace. - pub workspace_id: Uuid, -} - /// Path parameters for workspace member operations. #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -49,34 +40,13 @@ pub struct WorkspaceFilePathParams { pub file_id: Uuid, } -/// Path parameters for file operations (file ID only). -/// -/// Since file IDs are globally unique UUIDs, workspace context can be -/// derived from the file record itself for authorization purposes. -#[must_use] -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FilePathParams { - /// Unique identifier of the file. - pub file_id: Uuid, -} - -/// Path parameters for version operations. -#[must_use] -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct VersionPathParams { - /// Unique identifier of the version. - pub version_id: Uuid, -} - /// Path parameters for webhook operations. #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct WebhookPathParams { - /// Unique identifier of the webhook. - pub webhook_id: Uuid, + /// URL slug of the webhook, unique within its workspace. + pub webhook_slug: String, } /// Path parameters for API token operations. diff --git a/crates/nvisy-server/src/handler/request/pipelines.rs b/crates/nvisy-server/src/handler/request/pipelines.rs index 83c827a7..c605192b 100644 --- a/crates/nvisy-server/src/handler/request/pipelines.rs +++ b/crates/nvisy-server/src/handler/request/pipelines.rs @@ -26,8 +26,8 @@ use validator::Validate; /// - `recognizers` / `enrichers` / `deduplication` / `label_catalog` — the /// detection machinery, assembled into an engine `AnalyzerParams` per request. /// - `default_scope` — optional pipeline-wide scope a document may override. -/// - `policy_ids` / `context_ids` — references resolved live at run time against -/// the workspace's policies and contexts. +/// - `policy_slugs` / `context_slugs` — references to the workspace's policies +/// and contexts, resolved at run time. #[must_use] #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -54,37 +54,37 @@ pub struct PipelineDefinition { /// the document must assert its own. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_scope: Option, - /// Workspace policies applied at redaction, resolved live by id. + /// Slugs of workspace policies applied at redaction. /// /// Stored relationally in the `workspace_pipeline_policies` join table, not the JSON /// definition; surfaced here so the API exposes one coherent object. #[serde(default, skip_serializing_if = "Vec::is_empty")] #[validate(length(max = 64))] - pub policy_ids: Vec, - /// Workspace contexts supplied to detection, resolved live by id. + pub policy_slugs: Vec, + /// Slugs of workspace contexts supplied to detection. /// /// Stored relationally in the `workspace_pipeline_contexts` join table, not the JSON /// definition; surfaced here so the API exposes one coherent object. #[serde(default, skip_serializing_if = "Vec::is_empty")] #[validate(length(max = 64))] - pub context_ids: Vec, + pub context_slugs: Vec, } impl PipelineDefinition { /// Splits the definition into its stored parts: the engine config JSON (with - /// the relational references removed) and the policy / context reference ids. + /// the relational references removed) and the policy / context reference slugs. /// /// The references live in join tables, so they are stripped from the JSON to /// keep a single source of truth. Serialization failure is surfaced rather /// than swallowed so a bad config never gets silently persisted as empty. - pub fn into_parts(mut self) -> serde_json::Result<(serde_json::Value, Vec, Vec)> { - let policy_ids = std::mem::take(&mut self.policy_ids); - let context_ids = std::mem::take(&mut self.context_ids); + pub fn into_parts(mut self) -> serde_json::Result<(serde_json::Value, Vec, Vec)> { + let policy_slugs = std::mem::take(&mut self.policy_slugs); + let context_slugs = std::mem::take(&mut self.context_slugs); let config = serde_json::to_value(&self)?; - Ok((config, policy_ids, context_ids)) + Ok((config, policy_slugs, context_slugs)) } - /// Rebuilds a definition from stored config JSON and the reference ids read + /// Rebuilds a definition from stored config JSON and the reference slugs read /// back from the join tables. /// /// Decoding failure is surfaced rather than swallowed: a stored config that @@ -92,12 +92,12 @@ impl PipelineDefinition { /// config to return silently. pub fn from_parts( config: serde_json::Value, - policy_ids: Vec, - context_ids: Vec, + policy_slugs: Vec, + context_slugs: Vec, ) -> serde_json::Result { let mut definition: Self = serde_json::from_value(config)?; - definition.policy_ids = policy_ids; - definition.context_ids = context_ids; + definition.policy_slugs = policy_slugs; + definition.context_slugs = context_slugs; Ok(definition) } } @@ -124,14 +124,14 @@ pub struct CreatePipeline { pub definition: Option, } -/// A pipeline's reference ids, split out to be written to the join tables after -/// the pipeline row exists. +/// A pipeline's reference slugs, split out to be resolved to ids and written to +/// the join tables after the pipeline row exists. #[derive(Debug, Default, Clone)] pub struct PipelineReferences { - /// Policy ids the pipeline references. - pub policy_ids: Vec, - /// Context ids the pipeline references. - pub context_ids: Vec, + /// Slugs of the policies the pipeline references. + pub policy_slugs: Vec, + /// Slugs of the contexts the pipeline references. + pub context_slugs: Vec, } impl CreatePipeline { @@ -175,10 +175,10 @@ impl CreatePipeline { fn split_definition( definition: Option, ) -> serde_json::Result<(serde_json::Value, PipelineReferences)> { - let (config, policy_ids, context_ids) = definition.unwrap_or_default().into_parts()?; + let (config, policy_slugs, context_slugs) = definition.unwrap_or_default().into_parts()?; let references = PipelineReferences { - policy_ids, - context_ids, + policy_slugs, + context_slugs, }; Ok((config, references)) } @@ -215,12 +215,12 @@ impl UpdatePipeline { ) -> serde_json::Result<(UpdatePipelineModel, Option)> { let (definition, references) = match self.definition { Some(definition) => { - let (config, policy_ids, context_ids) = definition.into_parts()?; + let (config, policy_slugs, context_slugs) = definition.into_parts()?; ( Some(config), Some(PipelineReferences { - policy_ids, - context_ids, + policy_slugs, + context_slugs, }), ) } diff --git a/crates/nvisy-server/src/handler/request/webhooks.rs b/crates/nvisy-server/src/handler/request/webhooks.rs index 332a7cd5..a95297e1 100644 --- a/crates/nvisy-server/src/handler/request/webhooks.rs +++ b/crates/nvisy-server/src/handler/request/webhooks.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use nvisy_postgres::model::{ NewWorkspaceWebhook, UpdateWorkspaceWebhook as UpdateWorkspaceWebhookModel, }; -use nvisy_postgres::types::{WebhookEvent, WebhookStatus}; +use nvisy_postgres::types::{Slug, WebhookEvent, WebhookStatus}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -16,9 +16,11 @@ use validator::Validate; /// Request payload for creating a new workspace webhook. #[must_use] -#[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] +#[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] pub struct CreateWebhook { + /// URL slug, unique within the workspace and immutable after creation. + pub slug: Slug, /// Human-readable name for the webhook (1-100 characters). #[validate(length(min = 1, max = 100))] pub display_name: String, @@ -60,6 +62,7 @@ impl CreateWebhook { NewWorkspaceWebhook { workspace_id, + slug: self.slug, display_name: self.display_name, description: self.description, url: self.url, diff --git a/crates/nvisy-server/src/handler/response/pipelines.rs b/crates/nvisy-server/src/handler/response/pipelines.rs index 21c0dc27..a215c752 100644 --- a/crates/nvisy-server/src/handler/response/pipelines.rs +++ b/crates/nvisy-server/src/handler/response/pipelines.rs @@ -40,36 +40,42 @@ pub struct Pipeline { } impl Pipeline { - /// Creates a response from the database model and its reference ids. + /// Creates a response from the database model and its reference slugs. /// - /// The `policy_ids` / `context_ids` come from the join tables and are merged - /// with the stored engine config to rebuild the full definition. Fails if the - /// stored config JSON does not decode to the current schema. + /// The `policy_slugs` / `context_slugs` come from the join tables and are + /// merged with the stored engine config to rebuild the full definition. + /// Fails if the stored config JSON does not decode to the current schema. pub fn from_model( pipeline: model::WorkspacePipeline, workspace_slug: Slug, - policy_ids: Vec, - context_ids: Vec, + policy_slugs: Vec, + context_slugs: Vec, ) -> serde_json::Result { Self::assemble( pipeline, workspace_slug, Vec::new(), - policy_ids, - context_ids, + policy_slugs, + context_slugs, ) } - /// Creates a pipeline response with artifacts and reference ids. + /// Creates a pipeline response with artifacts and reference slugs. pub fn from_model_with_artifacts( pipeline: model::WorkspacePipeline, workspace_slug: Slug, artifacts: Vec, - policy_ids: Vec, - context_ids: Vec, + policy_slugs: Vec, + context_slugs: Vec, ) -> serde_json::Result { let artifacts = artifacts.into_iter().map(Artifact::from_model).collect(); - Self::assemble(pipeline, workspace_slug, artifacts, policy_ids, context_ids) + Self::assemble( + pipeline, + workspace_slug, + artifacts, + policy_slugs, + context_slugs, + ) } /// Shared assembly: decodes the stored config and merges the references. @@ -77,11 +83,11 @@ impl Pipeline { pipeline: model::WorkspacePipeline, workspace_slug: Slug, artifacts: Vec, - policy_ids: Vec, - context_ids: Vec, + policy_slugs: Vec, + context_slugs: Vec, ) -> serde_json::Result { let definition = - PipelineDefinition::from_parts(pipeline.definition, policy_ids, context_ids)?; + PipelineDefinition::from_parts(pipeline.definition, policy_slugs, context_slugs)?; Ok(Self { slug: pipeline.slug, workspace_slug, diff --git a/crates/nvisy-server/src/handler/response/webhooks.rs b/crates/nvisy-server/src/handler/response/webhooks.rs index db0f98d5..d16fd4f9 100644 --- a/crates/nvisy-server/src/handler/response/webhooks.rs +++ b/crates/nvisy-server/src/handler/response/webhooks.rs @@ -16,8 +16,8 @@ use super::Page; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct Webhook { - /// Unique webhook identifier. - pub webhook_id: Uuid, + /// URL slug of the webhook, unique within its workspace. + pub slug: Slug, /// Slug of the workspace this webhook belongs to. pub workspace_slug: Slug, /// Human-readable name for the webhook. @@ -49,7 +49,7 @@ impl Webhook { let headers = webhook.parsed_headers(); Self { - webhook_id: webhook.id, + slug: webhook.slug, workspace_slug, display_name: webhook.display_name, description: webhook.description, diff --git a/crates/nvisy-server/src/handler/webhooks.rs b/crates/nvisy-server/src/handler/webhooks.rs index 6bd3aa05..918fa9ce 100644 --- a/crates/nvisy-server/src/handler/webhooks.rs +++ b/crates/nvisy-server/src/handler/webhooks.rs @@ -68,7 +68,7 @@ async fn create_webhook( tracing::info!( target: TRACING_TARGET, - webhook_id = %webhook.id, + webhook_slug = %webhook.slug, "Webhook created", ); @@ -150,7 +150,7 @@ fn list_webhooks_docs(op: TransformOperation) -> TransformOperation { fields( account_id = %auth_state.account_id, workspace_id = %workspace.id, - webhook_id = %path_params.webhook_id, + webhook_slug = %path_params.webhook_slug, ) )] async fn read_webhook( @@ -167,7 +167,7 @@ async fn read_webhook( .authorize_workspace(&mut conn, workspace.id, Permission::ViewWebhooks) .await?; - let webhook = find_webhook(&mut conn, workspace.id, path_params.webhook_id).await?; + let webhook = find_webhook(&mut conn, workspace.id, &path_params.webhook_slug).await?; tracing::debug!(target: TRACING_TARGET, "Workspace webhook read"); @@ -194,7 +194,7 @@ fn read_webhook_docs(op: TransformOperation) -> TransformOperation { fields( account_id = %auth_state.account_id, workspace_id = %workspace.id, - webhook_id = %path_params.webhook_id, + webhook_slug = %path_params.webhook_slug, ) )] async fn update_webhook( @@ -212,11 +212,11 @@ async fn update_webhook( .authorize_workspace(&mut conn, workspace.id, Permission::UpdateWebhooks) .await?; - let existing = find_webhook(&mut conn, workspace.id, path_params.webhook_id).await?; + let existing = find_webhook(&mut conn, workspace.id, &path_params.webhook_slug).await?; let update_data = request.into_model(existing.status); let webhook = conn - .update_workspace_webhook(path_params.webhook_id, update_data) + .update_workspace_webhook(existing.id, update_data) .await?; tracing::info!(target: TRACING_TARGET, "Webhook updated"); @@ -245,7 +245,7 @@ fn update_webhook_docs(op: TransformOperation) -> TransformOperation { fields( account_id = %auth_state.account_id, workspace_id = %workspace.id, - webhook_id = %path_params.webhook_id, + webhook_slug = %path_params.webhook_slug, ) )] async fn delete_webhook( @@ -262,11 +262,9 @@ async fn delete_webhook( .authorize_workspace(&mut conn, workspace.id, Permission::DeleteWebhooks) .await?; - // Confirm the webhook exists in this workspace before deleting. - find_webhook(&mut conn, workspace.id, path_params.webhook_id).await?; + let existing = find_webhook(&mut conn, workspace.id, &path_params.webhook_slug).await?; - conn.delete_workspace_webhook(path_params.webhook_id) - .await?; + conn.delete_workspace_webhook(existing.id).await?; tracing::info!(target: TRACING_TARGET, "Webhook deleted"); @@ -291,7 +289,7 @@ fn delete_webhook_docs(op: TransformOperation) -> TransformOperation { fields( account_id = %auth_state.account_id, workspace_id = %workspace.id, - webhook_id = %path_params.webhook_id, + webhook_slug = %path_params.webhook_slug, ) )] async fn test_webhook( @@ -310,7 +308,7 @@ async fn test_webhook( .authorize_workspace(&mut conn, workspace.id, Permission::TestWebhooks) .await?; - let webhook = find_webhook(&mut conn, workspace.id, path_params.webhook_id).await?; + let webhook = find_webhook(&mut conn, workspace.id, &path_params.webhook_slug).await?; // Parse the webhook URL let url: Url = webhook.url.parse().map_err(|_| { @@ -349,13 +347,13 @@ fn test_webhook_docs(op: TransformOperation) -> TransformOperation { .response::<404, Json>() } -/// Finds a webhook within a workspace or returns NotFound error. +/// Finds a webhook within a workspace by slug or returns NotFound error. async fn find_webhook( conn: &mut PgConn, workspace_id: Uuid, - webhook_id: Uuid, + webhook_slug: &str, ) -> Result { - conn.find_webhook_in_workspace(workspace_id, webhook_id) + conn.find_webhook_in_workspace_by_slug(workspace_id, webhook_slug) .await? .ok_or_else(|| Error::not_found("webhook")) } @@ -365,20 +363,19 @@ pub fn routes() -> ApiRouter { use aide::axum::routing::*; ApiRouter::new() - // Workspace-scoped routes (require workspace context) .api_route( "/workspaces/{workspaceSlug}/webhooks/", post_with(create_webhook, create_webhook_docs) .get_with(list_webhooks, list_webhooks_docs), ) .api_route( - "/workspaces/{workspaceSlug}/webhooks/{webhookId}/", + "/workspaces/{workspaceSlug}/webhooks/{webhookSlug}/", get_with(read_webhook, read_webhook_docs) .put_with(update_webhook, update_webhook_docs) .delete_with(delete_webhook, delete_webhook_docs), ) .api_route( - "/workspaces/{workspaceSlug}/webhooks/{webhookId}/test/", + "/workspaces/{workspaceSlug}/webhooks/{webhookSlug}/test/", post_with(test_webhook, test_webhook_docs), ) .with_path_items(|item| item.tag("Webhooks")) diff --git a/migrations/2025-05-21-222842_webhooks/up.sql b/migrations/2025-05-21-222842_webhooks/up.sql index 35794e94..57718930 100644 --- a/migrations/2025-05-21-222842_webhooks/up.sql +++ b/migrations/2025-05-21-222842_webhooks/up.sql @@ -45,6 +45,13 @@ CREATE TABLE workspace_webhooks ( -- Composite key target for workspace-scoped access and foreign keys. CONSTRAINT workspace_webhooks_workspace_id_id_key UNIQUE (workspace_id, id), + -- URL identity, unique within the workspace: lowercase alphanumeric with + -- single internal dashes, 3-32 characters. + slug TEXT NOT NULL, + CONSTRAINT workspace_webhooks_workspace_id_slug_key UNIQUE (workspace_id, slug), + CONSTRAINT workspace_webhooks_slug_length CHECK (length(slug) BETWEEN 3 AND 32), + CONSTRAINT workspace_webhooks_slug_format CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + -- Webhook details display_name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', diff --git a/migrations/2026-01-19-045013_connections/up.sql b/migrations/2026-01-19-045013_connections/up.sql index 31214dba..e10b547f 100644 --- a/migrations/2026-01-19-045013_connections/up.sql +++ b/migrations/2026-01-19-045013_connections/up.sql @@ -34,8 +34,8 @@ CREATE TABLE workspace_connections ( -- Composite key target for workspace-scoped access and foreign keys. CONSTRAINT workspace_connections_workspace_id_id_key UNIQUE (workspace_id, id), - -- Immutable URL identity, unique within the workspace. Mirrors the Slug - -- newtype: lowercase alphanumeric with single internal dashes, 3-32 chars. + -- URL identity, unique within the workspace: lowercase alphanumeric with + -- single internal dashes, 3-32 characters. slug TEXT NOT NULL, CONSTRAINT workspace_connections_workspace_id_slug_key UNIQUE (workspace_id, slug), CONSTRAINT workspace_connections_slug_length CHECK (length(slug) BETWEEN 3 AND 32), diff --git a/migrations/2026-01-19-045014_policies/up.sql b/migrations/2026-01-19-045014_policies/up.sql index b1695c28..3f15236a 100644 --- a/migrations/2026-01-19-045014_policies/up.sql +++ b/migrations/2026-01-19-045014_policies/up.sql @@ -14,8 +14,8 @@ CREATE TABLE workspace_policies ( -- Composite key target for workspace-scoped foreign keys (join tables). CONSTRAINT workspace_policies_workspace_id_id_key UNIQUE (workspace_id, id), - -- Immutable URL identity, unique within the workspace. Mirrors the Slug - -- newtype: lowercase alphanumeric with single internal dashes, 3-32 chars. + -- URL identity, unique within the workspace: lowercase alphanumeric with + -- single internal dashes, 3-32 characters. slug TEXT NOT NULL, CONSTRAINT workspace_policies_workspace_id_slug_key UNIQUE (workspace_id, slug), CONSTRAINT workspace_policies_slug_length CHECK (length(slug) BETWEEN 3 AND 32), diff --git a/migrations/2026-01-19-045015_contexts/up.sql b/migrations/2026-01-19-045015_contexts/up.sql index a8e547a5..5f1ae89f 100644 --- a/migrations/2026-01-19-045015_contexts/up.sql +++ b/migrations/2026-01-19-045015_contexts/up.sql @@ -14,8 +14,8 @@ CREATE TABLE workspace_contexts ( -- Composite key target for workspace-scoped foreign keys (join tables). CONSTRAINT workspace_contexts_workspace_id_id_key UNIQUE (workspace_id, id), - -- Immutable URL identity, unique within the workspace. Mirrors the Slug - -- newtype: lowercase alphanumeric with single internal dashes, 3-32 chars. + -- URL identity, unique within the workspace: lowercase alphanumeric with + -- single internal dashes, 3-32 characters. slug TEXT NOT NULL, CONSTRAINT workspace_contexts_workspace_id_slug_key UNIQUE (workspace_id, slug), CONSTRAINT workspace_contexts_slug_length CHECK (length(slug) BETWEEN 3 AND 32), diff --git a/migrations/2026-01-19-045016_pipelines/up.sql b/migrations/2026-01-19-045016_pipelines/up.sql index 5af3c47b..1091402f 100644 --- a/migrations/2026-01-19-045016_pipelines/up.sql +++ b/migrations/2026-01-19-045016_pipelines/up.sql @@ -45,8 +45,8 @@ CREATE TABLE workspace_pipelines ( -- Composite key target for workspace-scoped foreign keys (join tables). CONSTRAINT workspace_pipelines_workspace_id_id_key UNIQUE (workspace_id, id), - -- Immutable URL identity, unique within the workspace. Mirrors the Slug - -- newtype: lowercase alphanumeric with single internal dashes, 3-32 chars. + -- URL identity, unique within the workspace: lowercase alphanumeric with + -- single internal dashes, 3-32 characters. slug TEXT NOT NULL, CONSTRAINT workspace_pipelines_workspace_id_slug_key UNIQUE (workspace_id, slug), CONSTRAINT workspace_pipelines_slug_length CHECK (length(slug) BETWEEN 3 AND 32),