diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 773be6d53a3..0bb78ccf199 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -117,6 +117,9 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.token_exchange_jti](public.token_exchange_jti.md) | 3 | | BASE TABLE | | [public.trusted_key](public.trusted_key.md) | 4 | | BASE TABLE | | [public.trusted_key_source](public.trusted_key_source.md) | 8 | | BASE TABLE | +| [public.type_availability_policy](public.type_availability_policy.md) | 7 | | BASE TABLE | +| [public.type_availability_policy_attachment](public.type_availability_policy_attachment.md) | 6 | | BASE TABLE | +| [public.type_availability_policy_scope](public.type_availability_policy_scope.md) | 8 | | BASE TABLE | | [public.user](public.user.md) | 15 | | BASE TABLE | | [public.user_api_keys](public.user_api_keys.md) | 9 | | BASE TABLE | | [public.user_favorites](public.user_favorites.md) | 4 | | BASE TABLE | @@ -312,6 +315,9 @@ erDiagram "public.test_run" }o--o| "public.evaluation_config" : "FOREIGN KEY (#quot;evaluationConfigId#quot;) REFERENCES evaluation_config(id) ON DELETE SET NULL" "public.test_run" }o--o| "public.evaluation_collection" : "FOREIGN KEY (#quot;collectionId#quot;) REFERENCES evaluation_collection(id) ON DELETE SET NULL" "public.trusted_key" }o--|| "public.trusted_key_source" : "FOREIGN KEY (#quot;sourceId#quot;) REFERENCES trusted_key_source(id) ON DELETE CASCADE" +"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy" : "FOREIGN KEY (#quot;policyId#quot;) REFERENCES type_availability_policy(id) ON DELETE RESTRICT" +"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy_scope" : "FOREIGN KEY (#quot;scopeId#quot;) REFERENCES type_availability_policy_scope(id) ON DELETE CASCADE" +"public.type_availability_policy_scope" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.user" }o--|| "public.role" : "FOREIGN KEY (#quot;roleSlug#quot;) REFERENCES role(slug)" "public.user_api_keys" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.user_favorites" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" @@ -1430,6 +1436,33 @@ erDiagram varchar_32_ type timestamp_3__with_time_zone updatedAt } +"public.type_availability_policy" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_64_ kind + json rules + timestamp_3__with_time_zone updatedAt + varchar_36_ updatedBy + integer version +} +"public.type_availability_policy_attachment" { + timestamp_3__with_time_zone createdAt + boolean isFloor + varchar_36_ policyId FK + integer priority + varchar_36_ scopeId FK + timestamp_3__with_time_zone updatedAt +} +"public.type_availability_policy_scope" { + timestamp_3__with_time_zone createdAt + varchar_16_ defaultAction + varchar_36_ id + varchar_64_ kind + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt + varchar_36_ updatedBy + integer version +} "public.user" { timestamp_3__with_time_zone createdAt boolean disabled diff --git a/docs/generated/postgres-schema/public.project.md b/docs/generated/postgres-schema/public.project.md index 01f8e3d5f54..bba433c2bac 100644 --- a/docs/generated/postgres-schema/public.project.md +++ b/docs/generated/postgres-schema/public.project.md @@ -9,7 +9,7 @@ | customTelemetryTags | json | '[]'::json | false | | | | | description | varchar(512) | | true | | | | | icon | json | | true | | | | -| id | varchar(36) | | false | [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.git_connection_project](public.git_connection_project.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | | +| id | varchar(36) | | false | [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.git_connection_project](public.git_connection_project.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.type_availability_policy_scope](public.type_availability_policy_scope.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | | | name | varchar(255) | | false | | | | | type | varchar(36) | | false | | | | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | @@ -52,6 +52,7 @@ erDiagram "public.role_mapping_rule_project" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.shared_credentials" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.shared_workflow" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" +"public.type_availability_policy_scope" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.variables" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.workflow_review_request" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" @@ -201,6 +202,16 @@ erDiagram timestamp_3__with_time_zone updatedAt varchar_36_ workflowId FK } +"public.type_availability_policy_scope" { + timestamp_3__with_time_zone createdAt + varchar_16_ defaultAction + varchar_36_ id + varchar_64_ kind + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt + varchar_36_ updatedBy + integer version +} "public.variables" { varchar_36_ id varchar_50_ key diff --git a/docs/generated/postgres-schema/public.type_availability_policy.md b/docs/generated/postgres-schema/public.type_availability_policy.md new file mode 100644 index 00000000000..79d19f1f108 --- /dev/null +++ b/docs/generated/postgres-schema/public.type_availability_policy.md @@ -0,0 +1,62 @@ +# public.type_availability_policy + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| id | varchar(36) | | false | [public.type_availability_policy_attachment](public.type_availability_policy_attachment.md) | | | +| kind | varchar(64) | | false | | | | +| rules | json | | false | | | | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| updatedBy | varchar(36) | | false | | | | +| version | integer | 1 | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| PK_7dc9809c6d53a6b8b8a62cd7798 | PRIMARY KEY | PRIMARY KEY (id) | +| type_availability_policy_createdAt_not_null | n | NOT NULL "createdAt" | +| type_availability_policy_id_not_null | n | NOT NULL id | +| type_availability_policy_kind_not_null | n | NOT NULL kind | +| type_availability_policy_rules_not_null | n | NOT NULL rules | +| type_availability_policy_updatedAt_not_null | n | NOT NULL "updatedAt" | +| type_availability_policy_updatedBy_not_null | n | NOT NULL "updatedBy" | +| type_availability_policy_version_not_null | n | NOT NULL version | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PK_7dc9809c6d53a6b8b8a62cd7798 | CREATE UNIQUE INDEX "PK_7dc9809c6d53a6b8b8a62cd7798" ON public.type_availability_policy USING btree (id) | + +## Relations + +```mermaid +erDiagram + +"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy" : "FOREIGN KEY (#quot;policyId#quot;) REFERENCES type_availability_policy(id) ON DELETE RESTRICT" + +"public.type_availability_policy" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_64_ kind + json rules + timestamp_3__with_time_zone updatedAt + varchar_36_ updatedBy + integer version +} +"public.type_availability_policy_attachment" { + timestamp_3__with_time_zone createdAt + boolean isFloor + varchar_36_ policyId FK + integer priority + varchar_36_ scopeId FK + timestamp_3__with_time_zone updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/postgres-schema/public.type_availability_policy_attachment.md b/docs/generated/postgres-schema/public.type_availability_policy_attachment.md new file mode 100644 index 00000000000..59dfadc799c --- /dev/null +++ b/docs/generated/postgres-schema/public.type_availability_policy_attachment.md @@ -0,0 +1,75 @@ +# public.type_availability_policy_attachment + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| isFloor | boolean | false | false | | | | +| policyId | varchar(36) | | false | | [public.type_availability_policy](public.type_availability_policy.md) | | +| priority | integer | | false | | | | +| scopeId | varchar(36) | | false | | [public.type_availability_policy_scope](public.type_availability_policy_scope.md) | | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| FK_118709fc9fe21d3406f81665a53 | FOREIGN KEY | FOREIGN KEY ("scopeId") REFERENCES type_availability_policy_scope(id) ON DELETE CASCADE | +| FK_674705e4c8a3d3eb0736f8e8eee | FOREIGN KEY | FOREIGN KEY ("policyId") REFERENCES type_availability_policy(id) ON DELETE RESTRICT | +| PK_96c1ba2b399ed0aabb047d04f07 | PRIMARY KEY | PRIMARY KEY ("scopeId", "policyId") | +| type_availability_policy_attachment_createdAt_not_null | n | NOT NULL "createdAt" | +| type_availability_policy_attachment_isFloor_not_null | n | NOT NULL "isFloor" | +| type_availability_policy_attachment_policyId_not_null | n | NOT NULL "policyId" | +| type_availability_policy_attachment_priority_not_null | n | NOT NULL priority | +| type_availability_policy_attachment_scopeId_not_null | n | NOT NULL "scopeId" | +| type_availability_policy_attachment_updatedAt_not_null | n | NOT NULL "updatedAt" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_674705e4c8a3d3eb0736f8e8ee | CREATE INDEX "IDX_674705e4c8a3d3eb0736f8e8ee" ON public.type_availability_policy_attachment USING btree ("policyId") | +| PK_96c1ba2b399ed0aabb047d04f07 | CREATE UNIQUE INDEX "PK_96c1ba2b399ed0aabb047d04f07" ON public.type_availability_policy_attachment USING btree ("scopeId", "policyId") | +| uq_type_availability_attachment_slot | CREATE UNIQUE INDEX uq_type_availability_attachment_slot ON public.type_availability_policy_attachment USING btree ("scopeId", "isFloor", priority) | + +## Relations + +```mermaid +erDiagram + +"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy" : "FOREIGN KEY (#quot;policyId#quot;) REFERENCES type_availability_policy(id) ON DELETE RESTRICT" +"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy_scope" : "FOREIGN KEY (#quot;scopeId#quot;) REFERENCES type_availability_policy_scope(id) ON DELETE CASCADE" + +"public.type_availability_policy_attachment" { + timestamp_3__with_time_zone createdAt + boolean isFloor + varchar_36_ policyId FK + integer priority + varchar_36_ scopeId FK + timestamp_3__with_time_zone updatedAt +} +"public.type_availability_policy" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_64_ kind + json rules + timestamp_3__with_time_zone updatedAt + varchar_36_ updatedBy + integer version +} +"public.type_availability_policy_scope" { + timestamp_3__with_time_zone createdAt + varchar_16_ defaultAction + varchar_36_ id + varchar_64_ kind + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt + varchar_36_ updatedBy + integer version +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/postgres-schema/public.type_availability_policy_scope.md b/docs/generated/postgres-schema/public.type_availability_policy_scope.md new file mode 100644 index 00000000000..c9eec2230b6 --- /dev/null +++ b/docs/generated/postgres-schema/public.type_availability_policy_scope.md @@ -0,0 +1,80 @@ +# public.type_availability_policy_scope + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| defaultAction | varchar(16) | | false | | | | +| id | varchar(36) | | false | [public.type_availability_policy_attachment](public.type_availability_policy_attachment.md) | | | +| kind | varchar(64) | | false | | | | +| projectId | varchar(36) | | true | | [public.project](public.project.md) | | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| updatedBy | varchar(36) | | false | | | | +| version | integer | 1 | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| CHK_type_availability_policy_scope_defaultAction | CHECK | CHECK ((("defaultAction")::text = ANY ((ARRAY['allow'::character varying, 'deny'::character varying, 'delegate'::character varying])::text[]))) | +| FK_73c152c542cea477208c3106193 | FOREIGN KEY | FOREIGN KEY ("projectId") REFERENCES project(id) ON DELETE CASCADE | +| PK_26178b723c376150b45eabdb8e7 | PRIMARY KEY | PRIMARY KEY (id) | +| type_availability_policy_scope_createdAt_not_null | n | NOT NULL "createdAt" | +| type_availability_policy_scope_defaultAction_not_null | n | NOT NULL "defaultAction" | +| type_availability_policy_scope_id_not_null | n | NOT NULL id | +| type_availability_policy_scope_kind_not_null | n | NOT NULL kind | +| type_availability_policy_scope_updatedAt_not_null | n | NOT NULL "updatedAt" | +| type_availability_policy_scope_updatedBy_not_null | n | NOT NULL "updatedBy" | +| type_availability_policy_scope_version_not_null | n | NOT NULL version | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PK_26178b723c376150b45eabdb8e7 | CREATE UNIQUE INDEX "PK_26178b723c376150b45eabdb8e7" ON public.type_availability_policy_scope USING btree (id) | +| uq_type_availability_policy_scope_instance | CREATE UNIQUE INDEX uq_type_availability_policy_scope_instance ON public.type_availability_policy_scope USING btree (kind) WHERE ("projectId" IS NULL) | +| uq_type_availability_policy_scope_project | CREATE UNIQUE INDEX uq_type_availability_policy_scope_project ON public.type_availability_policy_scope USING btree (kind, "projectId") WHERE ("projectId" IS NOT NULL) | + +## Relations + +```mermaid +erDiagram + +"public.type_availability_policy_attachment" }o--|| "public.type_availability_policy_scope" : "FOREIGN KEY (#quot;scopeId#quot;) REFERENCES type_availability_policy_scope(id) ON DELETE CASCADE" +"public.type_availability_policy_scope" }o--o| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" + +"public.type_availability_policy_scope" { + timestamp_3__with_time_zone createdAt + varchar_16_ defaultAction + varchar_36_ id + varchar_64_ kind + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt + varchar_36_ updatedBy + integer version +} +"public.type_availability_policy_attachment" { + timestamp_3__with_time_zone createdAt + boolean isFloor + varchar_36_ policyId FK + integer priority + varchar_36_ scopeId FK + timestamp_3__with_time_zone updatedAt +} +"public.project" { + timestamp_3__with_time_zone createdAt + uuid creatorId FK + json customTelemetryTags + varchar_512_ description + json icon + varchar_36_ id + varchar_255_ name + varchar_36_ type + timestamp_3__with_time_zone updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 5a254ca8855..d56ca1ebbb6 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -117,6 +117,9 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [token_exchange_jti](token_exchange_jti.md) | 3 | | table | | [trusted_key](trusted_key.md) | 4 | | table | | [trusted_key_source](trusted_key_source.md) | 8 | | table | +| [type_availability_policy](type_availability_policy.md) | 7 | | table | +| [type_availability_policy_attachment](type_availability_policy_attachment.md) | 6 | | table | +| [type_availability_policy_scope](type_availability_policy_scope.md) | 8 | | table | | [user](user.md) | 15 | | table | | [user_api_keys](user_api_keys.md) | 9 | | table | | [user_favorites](user_favorites.md) | 4 | | table | @@ -297,6 +300,9 @@ erDiagram "test_run" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "test_run" }o--o| "evaluation_config" : "FOREIGN KEY (evaluationConfigId) REFERENCES evaluation_config (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "trusted_key" |o--|| "trusted_key_source" : "FOREIGN KEY (sourceId) REFERENCES trusted_key_source (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"type_availability_policy_attachment" |o--|| "type_availability_policy" : "FOREIGN KEY (policyId) REFERENCES type_availability_policy (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" +"type_availability_policy_attachment" |o--|| "type_availability_policy_scope" : "FOREIGN KEY (scopeId) REFERENCES type_availability_policy_scope (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"type_availability_policy_scope" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "user" }o--|| "role" : "FOREIGN KEY (roleSlug) REFERENCES role (slug) ON UPDATE NO ACTION ON DELETE NO ACTION MATCH NONE" "user_api_keys" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "user_favorites" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -1419,6 +1425,33 @@ erDiagram varchar_32_ type datetime_3_ updatedAt } +"type_availability_policy" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_64_ kind + TEXT rules + datetime_3_ updatedAt + varchar_36_ updatedBy + INTEGER version +} +"type_availability_policy_attachment" { + datetime_3_ createdAt + boolean isFloor + varchar_36_ policyId PK + INTEGER priority + varchar_36_ scopeId PK + datetime_3_ updatedAt +} +"type_availability_policy_scope" { + datetime_3_ createdAt + varchar_16_ defaultAction + varchar_36_ id PK + varchar_64_ kind + varchar_36_ projectId FK + datetime_3_ updatedAt + varchar_36_ updatedBy + INTEGER version +} "user" { datetime_3_ createdAt boolean disabled diff --git a/docs/generated/sqlite-schema/project.md b/docs/generated/sqlite-schema/project.md index c88b3ac120f..ba36950bdc6 100644 --- a/docs/generated/sqlite-schema/project.md +++ b/docs/generated/sqlite-schema/project.md @@ -20,7 +20,7 @@ CREATE TABLE "project" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(25 | customTelemetryTags | TEXT | '[]' | false | | | | | description | varchar(512) | | true | | | | | icon | TEXT | | true | | | | -| id | varchar(36) | | false | [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | | +| id | varchar(36) | | false | [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | | | name | varchar(255) | | false | | | | | type | varchar(36) | | false | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | @@ -58,6 +58,7 @@ erDiagram "role_mapping_rule_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "shared_credentials" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "shared_workflow" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"type_availability_policy_scope" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "variables" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_review_request" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -207,6 +208,16 @@ erDiagram datetime_3_ updatedAt varchar_36_ workflowId PK } +"type_availability_policy_scope" { + datetime_3_ createdAt + varchar_16_ defaultAction + varchar_36_ id PK + varchar_64_ kind + varchar_36_ projectId FK + datetime_3_ updatedAt + varchar_36_ updatedBy + INTEGER version +} "variables" { varchar_36_ id PK TEXT key diff --git a/docs/generated/sqlite-schema/type_availability_policy.md b/docs/generated/sqlite-schema/type_availability_policy.md new file mode 100644 index 00000000000..ef7ad2e4ce7 --- /dev/null +++ b/docs/generated/sqlite-schema/type_availability_policy.md @@ -0,0 +1,67 @@ +# type_availability_policy + +## Description + +
+Table Definition + +```sql +CREATE TABLE "type_availability_policy" ("id" varchar(36) PRIMARY KEY NOT NULL, "kind" varchar(64) NOT NULL, "rules" text NOT NULL, "version" integer NOT NULL DEFAULT (1), "updatedBy" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| id | varchar(36) | | false | [type_availability_policy_attachment](type_availability_policy_attachment.md) | | | +| kind | varchar(64) | | false | | | | +| rules | TEXT | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| updatedBy | varchar(36) | | false | | | | +| version | INTEGER | 1 | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_type_availability_policy_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| sqlite_autoindex_type_availability_policy_1 | PRIMARY KEY (id) | + +## Relations + +```mermaid +erDiagram + +"type_availability_policy_attachment" |o--|| "type_availability_policy" : "FOREIGN KEY (policyId) REFERENCES type_availability_policy (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" + +"type_availability_policy" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_64_ kind + TEXT rules + datetime_3_ updatedAt + varchar_36_ updatedBy + INTEGER version +} +"type_availability_policy_attachment" { + datetime_3_ createdAt + boolean isFloor + varchar_36_ policyId PK + INTEGER priority + varchar_36_ scopeId PK + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/type_availability_policy_attachment.md b/docs/generated/sqlite-schema/type_availability_policy_attachment.md new file mode 100644 index 00000000000..e8f1cb8cfca --- /dev/null +++ b/docs/generated/sqlite-schema/type_availability_policy_attachment.md @@ -0,0 +1,82 @@ +# type_availability_policy_attachment + +## Description + +
+Table Definition + +```sql +CREATE TABLE "type_availability_policy_attachment" ("scopeId" varchar(36) NOT NULL, "policyId" varchar(36) NOT NULL, "priority" integer NOT NULL, "isFloor" boolean NOT NULL DEFAULT (false), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_118709fc9fe21d3406f81665a53" FOREIGN KEY ("scopeId") REFERENCES "type_availability_policy_scope" ("id") ON DELETE CASCADE, CONSTRAINT "FK_674705e4c8a3d3eb0736f8e8eee" FOREIGN KEY ("policyId") REFERENCES "type_availability_policy" ("id") ON DELETE RESTRICT, PRIMARY KEY ("scopeId", "policyId")) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| isFloor | boolean | false | false | | | | +| policyId | varchar(36) | | false | | [type_availability_policy](type_availability_policy.md) | | +| priority | INTEGER | | false | | | | +| scopeId | varchar(36) | | false | | [type_availability_policy_scope](type_availability_policy_scope.md) | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (policyId) REFERENCES type_availability_policy (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE | +| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (scopeId) REFERENCES type_availability_policy_scope (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| policyId | PRIMARY KEY | PRIMARY KEY (policyId) | +| scopeId | PRIMARY KEY | PRIMARY KEY (scopeId) | +| sqlite_autoindex_type_availability_policy_attachment_1 | PRIMARY KEY | PRIMARY KEY (scopeId, policyId) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_674705e4c8a3d3eb0736f8e8ee | CREATE INDEX "IDX_674705e4c8a3d3eb0736f8e8ee" ON "type_availability_policy_attachment" ("policyId") | +| sqlite_autoindex_type_availability_policy_attachment_1 | PRIMARY KEY (scopeId, policyId) | +| uq_type_availability_attachment_slot | CREATE UNIQUE INDEX "uq_type_availability_attachment_slot" ON "type_availability_policy_attachment" ("scopeId", "isFloor", "priority") | + +## Relations + +```mermaid +erDiagram + +"type_availability_policy_attachment" |o--|| "type_availability_policy" : "FOREIGN KEY (policyId) REFERENCES type_availability_policy (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" +"type_availability_policy_attachment" |o--|| "type_availability_policy_scope" : "FOREIGN KEY (scopeId) REFERENCES type_availability_policy_scope (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"type_availability_policy_attachment" { + datetime_3_ createdAt + boolean isFloor + varchar_36_ policyId PK + INTEGER priority + varchar_36_ scopeId PK + datetime_3_ updatedAt +} +"type_availability_policy" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_64_ kind + TEXT rules + datetime_3_ updatedAt + varchar_36_ updatedBy + INTEGER version +} +"type_availability_policy_scope" { + datetime_3_ createdAt + varchar_16_ defaultAction + varchar_36_ id PK + varchar_64_ kind + varchar_36_ projectId FK + datetime_3_ updatedAt + varchar_36_ updatedBy + INTEGER version +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/type_availability_policy_scope.md b/docs/generated/sqlite-schema/type_availability_policy_scope.md new file mode 100644 index 00000000000..93d20aa5090 --- /dev/null +++ b/docs/generated/sqlite-schema/type_availability_policy_scope.md @@ -0,0 +1,85 @@ +# type_availability_policy_scope + +## Description + +
+Table Definition + +```sql +CREATE TABLE "type_availability_policy_scope" ("id" varchar(36) PRIMARY KEY NOT NULL, "kind" varchar(64) NOT NULL, "projectId" varchar(36), "defaultAction" varchar(16) NOT NULL, "version" integer NOT NULL DEFAULT (1), "updatedBy" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_type_availability_policy_scope_defaultAction" CHECK ("defaultAction" IN ('allow', 'deny', 'delegate')), CONSTRAINT "FK_73c152c542cea477208c3106193" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| defaultAction | varchar(16) | | false | | | | +| id | varchar(36) | | false | [type_availability_policy_attachment](type_availability_policy_attachment.md) | | | +| kind | varchar(64) | | false | | | | +| projectId | varchar(36) | | true | | [project](project.md) | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| updatedBy | varchar(36) | | false | | | | +| version | INTEGER | 1 | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - | CHECK | CHECK ("defaultAction" IN ('allow', 'deny', 'delegate')) | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_type_availability_policy_scope_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| sqlite_autoindex_type_availability_policy_scope_1 | PRIMARY KEY (id) | +| uq_type_availability_policy_scope_instance | CREATE UNIQUE INDEX "uq_type_availability_policy_scope_instance" ON "type_availability_policy_scope" ("kind") WHERE "projectId" IS NULL | +| uq_type_availability_policy_scope_project | CREATE UNIQUE INDEX "uq_type_availability_policy_scope_project" ON "type_availability_policy_scope" ("kind", "projectId") WHERE "projectId" IS NOT NULL | + +## Relations + +```mermaid +erDiagram + +"type_availability_policy_attachment" |o--|| "type_availability_policy_scope" : "FOREIGN KEY (scopeId) REFERENCES type_availability_policy_scope (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"type_availability_policy_scope" }o--o| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"type_availability_policy_scope" { + datetime_3_ createdAt + varchar_16_ defaultAction + varchar_36_ id PK + varchar_64_ kind + varchar_36_ projectId FK + datetime_3_ updatedAt + varchar_36_ updatedBy + INTEGER version +} +"type_availability_policy_attachment" { + datetime_3_ createdAt + boolean isFloor + varchar_36_ policyId PK + INTEGER priority + varchar_36_ scopeId PK + datetime_3_ updatedAt +} +"project" { + datetime_3_ createdAt + varchar creatorId FK + TEXT customTelemetryTags + varchar_512_ description + TEXT icon + varchar_36_ id PK + varchar_255_ name + varchar_36_ type + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 0676deb6f8d..c003a383946 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -170,6 +170,9 @@ type EntityName = | 'DynamicCredentialEntry' | 'DynamicCredentialResolver' | 'DynamicCredentialUserEntry' + | 'TypeAvailabilityPolicy' + | 'TypeAvailabilityPolicyScope' + | 'TypeAvailabilityPolicyAttachment' | 'TokenExchangeJti' | 'TrustedKeySourceEntity' | 'TrustedKeyEntity' diff --git a/packages/@n8n/db/src/migrations/common/1787841960965-CreateTypeAvailabilityPolicyTables.ts b/packages/@n8n/db/src/migrations/common/1787841960965-CreateTypeAvailabilityPolicyTables.ts new file mode 100644 index 00000000000..45dbbe8ecde --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1787841960965-CreateTypeAvailabilityPolicyTables.ts @@ -0,0 +1,118 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const POLICY_TABLE = 'type_availability_policy'; +const SCOPE_TABLE = 'type_availability_policy_scope'; +const ATTACHMENT_TABLE = 'type_availability_policy_attachment'; +const PROJECT_TABLE = 'project'; + +/** The three actions a rule or a scope default may take. */ +const ACTIONS = ['allow', 'deny', 'delegate']; + +/** + * Storage for type availability policies: a reusable rules document + * (`type_availability_policy`), what a scope owns (`…_scope`), and the ordered link + * between them (`…_attachment`). + * + * Scoped to type availability rather than to policies at large, because the columns encode + * one rule model — a selector list resolved first-match to allow/deny/delegate. Policy + * features with a different model (quotas, egress) need their own tables, not these. + * Within this model `kind` distinguishes the type family, so credential-type policies join + * the same tables without a migration. + */ +export class CreateTypeAvailabilityPolicyTables1787841960965 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, createIndex, column }, tablePrefix }: MigrationContext) { + // A policy is scope-free and attachable to any number of scopes. `version` + // increments on content changes only, and drives optimistic concurrency, + // cache keys, and the audit trail. + await createTable(POLICY_TABLE).withColumns( + column('id').varchar(36).primary, + column('kind').varchar(64).notNull, + column('rules').json.notNull, + column('version').int.notNull.default(1), + // Either a user id or the literal 'environment' for env-bootstrap + // writes, so this deliberately has no FK to `user`. + column('updatedBy').varchar(36).notNull, + ).withTimestamps; + + // What one scope owns: nothing but its own default action. Whether a row is the + // instance scope is derived — `projectId IS NULL` — rather than stored, so there + // is no instance-vs-project flag that could disagree with `projectId`. + await createTable(SCOPE_TABLE) + .withColumns( + column('id').varchar(36).primary, + column('kind').varchar(64).notNull, + column('projectId').varchar(36), + column('defaultAction').varchar(16).notNull.withEnumCheck(ACTIONS), + // Also bumped when an attachment changes, so it stays a single + // freshness signal for the scope's *effective* policy. + column('version').int.notNull.default(1), + column('updatedBy').varchar(36).notNull, + ) + .withForeignKey('projectId', { + tableName: PROJECT_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + // NULLs are distinct in unique constraints on both Postgres and SQLite, so a + // plain UNIQUE(kind, projectId) would admit any number of instance rows. Two + // partial indexes split the nullable and non-nullable halves instead. + await createIndex( + SCOPE_TABLE, + ['kind', 'projectId'], + true, + `${tablePrefix}uq_type_availability_policy_scope_project`, + '"projectId" IS NOT NULL', + ); + await createIndex( + SCOPE_TABLE, + ['kind'], + true, + `${tablePrefix}uq_type_availability_policy_scope_instance`, + '"projectId" IS NULL', + ); + + // `(scopeId, policyId)` is the primary key: a policy attaches to a scope at + // most once, and the pair is the only candidate key the table has. + // + // The policy side is RESTRICT, not CASCADE: cascading would drop attachments + // without bumping the scope's version, leaving caches serving a policy that no + // longer exists. Callers detach first; the FK is the backstop. + await createTable(ATTACHMENT_TABLE) + .withColumns( + column('scopeId').varchar(36).primary, + column('policyId').varchar(36).primary, + column('priority').int.notNull, + column('isFloor').bool.notNull.default(false), + ) + .withForeignKey('scopeId', { + tableName: SCOPE_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('policyId', { + tableName: POLICY_TABLE, + columnName: 'id', + onDelete: 'RESTRICT', + }) + // Reverse index: every scope a policy is attached to, for the version + // bump that fans out when the policy's own content changes. + .withIndexOn(['policyId']).withTimestamps; + + // Priority ties inside one partition are rejected at write time rather than + // broken arbitrarily at evaluation time. Floor and normal are separate + // partitions, so the same priority in each is fine. + await createIndex( + ATTACHMENT_TABLE, + ['scopeId', 'isFloor', 'priority'], + true, + `${tablePrefix}uq_type_availability_attachment_slot`, + ); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(ATTACHMENT_TABLE); + await dropTable(SCOPE_TABLE); + await dropTable(POLICY_TABLE); + } +} diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts index 212cd81d178..241b970aa03 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policies.module.test.ts @@ -16,4 +16,16 @@ describe('TypeAvailabilityPoliciesModule', () => { await expect(module.init()).resolves.toBeUndefined(); }); + + it('exposes its entities so the datasource picks them up', async () => { + const module = new TypeAvailabilityPoliciesModule(); + + const entities = (await module.entities()) as unknown as Array<{ name: string }>; + + expect(entities.map((entity) => entity.name)).toEqual([ + 'TypeAvailabilityPolicy', + 'TypeAvailabilityPolicyScope', + 'TypeAvailabilityPolicyAttachment', + ]); + }); }); diff --git a/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy-attachment.entity.ts b/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy-attachment.entity.ts new file mode 100644 index 00000000000..0864ceba7e2 --- /dev/null +++ b/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy-attachment.entity.ts @@ -0,0 +1,30 @@ +import { WithTimestamps } from '@n8n/db'; +import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm'; + +/** + * Attaches one policy to one scope, at a position in that scope's evaluation order. + * + * `(scopeId, policyId)` is the primary key — a policy attaches to a scope at most once, + * and the pair is the table's only candidate key. A second unique index (declared in the + * migration) rejects two attachments sharing a priority within the same partition. + * + * Both FKs are declared in the migration rather than as relations, to keep the entity + * decoupled; note they differ in delete behaviour (scope cascades, policy restricts). + */ +@Entity('type_availability_policy_attachment') +export class TypeAvailabilityPolicyAttachment extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 36 }) + scopeId: string; + + @Index() + @PrimaryColumn({ type: 'varchar', length: 36 }) + policyId: string; + + /** Ascending order within the partition `isFloor` selects. */ + @Column({ type: 'int' }) + priority: number; + + /** Floor attachments evaluate before all normal ones, so they cannot be shadowed. */ + @Column({ type: 'boolean', default: false }) + isFloor: boolean; +} diff --git a/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy-scope.entity.ts b/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy-scope.entity.ts new file mode 100644 index 00000000000..04eba9555be --- /dev/null +++ b/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy-scope.entity.ts @@ -0,0 +1,38 @@ +import { WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity } from '@n8n/typeorm'; + +import type { PolicyAction } from '../../policy-rule.types'; + +/** + * What one scope owns: its default action, plus the attachments that point at it. + * + * Whether a row is the instance scope is derived — `projectId IS NULL` — rather than stored, + * so there is no instance-vs-project flag that could disagree with `projectId`. Two partial + * unique indexes (declared in the migration) enforce at most one row per kind per project, + * and at most one instance row per kind. + * + * The FK to `project` is declared in the migration rather than as a `@ManyToOne`, to keep + * the module entity decoupled from the core `Project` entity. + */ +@Entity('type_availability_policy_scope') +export class TypeAvailabilityPolicyScope extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 64 }) + kind: string; + + @Column({ type: 'varchar', length: 36, nullable: true }) + projectId: string | null; + + @Column({ type: 'varchar', length: 16 }) + defaultAction: PolicyAction; + + /** + * Freshness signal for the scope's *effective* policy, so it is bumped when an + * attachment is added, removed or reordered — not only on `defaultAction` edits. + */ + @Column({ type: 'int', default: 1 }) + version: number; + + /** A user id, or the literal `environment` for env-bootstrap writes. */ + @Column({ type: 'varchar', length: 36 }) + updatedBy: string; +} diff --git a/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy.entity.ts b/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy.entity.ts new file mode 100644 index 00000000000..8dc1e9ddb87 --- /dev/null +++ b/packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy.entity.ts @@ -0,0 +1,31 @@ +import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity } from '@n8n/typeorm'; + +import type { PolicyRule } from '../../policy-rule.types'; + +/** + * A reusable rules document. It carries no scope and no default action, and may be + * attached to any number of scopes via `policy_attachment`. + * + * `kind` partitions the table by policy feature (node types today) so a sibling kind + * needs no migration. It has no CHECK constraint for that reason — callers validate it. + */ +@Entity('type_availability_policy') +export class TypeAvailabilityPolicy extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 64 }) + kind: string; + + @JsonColumn() + rules: PolicyRule[]; + + /** + * Incremented on every content change. Drives optimistic concurrency, cache keys + * and the audit trail — so an upsert with unchanged rules must not bump it. + */ + @Column({ type: 'int', default: 1 }) + version: number; + + /** A user id, or the literal `environment` for env-bootstrap writes. */ + @Column({ type: 'varchar', length: 36 }) + updatedBy: string; +} diff --git a/packages/cli/src/modules/type-availability-policies/database/repositories/__tests__/version-bumps.test.ts b/packages/cli/src/modules/type-availability-policies/database/repositories/__tests__/version-bumps.test.ts new file mode 100644 index 00000000000..66aaf6553ba --- /dev/null +++ b/packages/cli/src/modules/type-availability-policies/database/repositories/__tests__/version-bumps.test.ts @@ -0,0 +1,127 @@ +import type { OperationContext, TransactionRunner } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import { mockEntityManager } from '@test/mocking'; + +import type { PolicyRule } from '../../../policy-rule.types'; +import { TypeAvailabilityPolicyScope } from '../../entities/type-availability-policy-scope.entity'; +import { TypeAvailabilityPolicy } from '../../entities/type-availability-policy.entity'; +import { TypeAvailabilityPolicyScopeRepository } from '../type-availability-policy-scope.repository'; +import { TypeAvailabilityPolicyRepository } from '../type-availability-policy.repository'; + +/** + * `version` is the cache-invalidation signal, so a bump must never be computed from a row + * read earlier in the transaction: two overlapping edits would both write `read version + 1` + * and one bump would vanish, leaving a stale cache behind. + * + * These assert the mechanism rather than the race, because the race is not reproducible in + * this suite — Postgres runs at pool size 1 and SQLite serialises writers, so two "concurrent" + * repository calls run one after the other and both implementations look identical. Asserting + * the delegation is what actually fails if someone reverts to `entity.version += 1`. + */ +describe('policy version bumps are delegated to the database', () => { + const RULE: PolicyRule = { + id: 'r1', + action: 'deny', + selector: { kind: 'name', value: 'n8n-nodes-base.slack' }, + }; + + const ROOT: OperationContext = {}; + + describe('TypeAvailabilityPolicyRepository.updateRules', () => { + const entityManager = mockEntityManager(TypeAvailabilityPolicy); + const transactionRunner = mock(); + const repository = new TypeAvailabilityPolicyRepository( + entityManager.connection, + transactionRunner, + ); + + beforeEach(() => { + vi.clearAllMocks(); + transactionRunner.run.mockImplementation(async (_ctx, fn) => await fn(ROOT)); + }); + + it('increments in SQL instead of saving a version computed here', async () => { + const stored = Object.assign(new TypeAvailabilityPolicy(), { + id: 'p1', + kind: 'node-types', + rules: [], + version: 1, + updatedBy: 'user-1', + }); + entityManager.findOneBy.mockResolvedValue(stored); + + await repository.updateRules('p1', [RULE], 'user-2', ROOT); + + expect(entityManager.update).toHaveBeenCalledWith( + TypeAvailabilityPolicy, + { id: 'p1' }, + { rules: [RULE], updatedBy: 'user-2' }, + ); + expect(entityManager.increment).toHaveBeenCalledWith( + TypeAvailabilityPolicy, + { id: 'p1' }, + 'version', + 1, + ); + expect(entityManager.save).not.toHaveBeenCalled(); + }); + + it('writes nothing at all when the rules are unchanged', async () => { + const stored = Object.assign(new TypeAvailabilityPolicy(), { + id: 'p1', + kind: 'node-types', + rules: [RULE], + version: 1, + updatedBy: 'user-1', + }); + entityManager.findOneBy.mockResolvedValue(stored); + + await repository.updateRules('p1', [RULE], 'user-2', ROOT); + + expect(entityManager.update).not.toHaveBeenCalled(); + expect(entityManager.increment).not.toHaveBeenCalled(); + }); + }); + + describe('TypeAvailabilityPolicyScopeRepository.updateDefaultAction', () => { + const entityManager = mockEntityManager(TypeAvailabilityPolicyScope); + const transactionRunner = mock(); + const repository = new TypeAvailabilityPolicyScopeRepository( + entityManager.connection, + transactionRunner, + ); + + beforeEach(() => { + vi.clearAllMocks(); + transactionRunner.run.mockImplementation(async (_ctx, fn) => await fn(ROOT)); + }); + + it('increments in SQL instead of saving a version computed here', async () => { + const stored = Object.assign(new TypeAvailabilityPolicyScope(), { + id: 's1', + kind: 'node-types', + projectId: null, + defaultAction: 'allow', + version: 1, + updatedBy: 'user-1', + }); + entityManager.findOneBy.mockResolvedValue(stored); + + await repository.updateDefaultAction('s1', 'deny', 'user-2', ROOT); + + expect(entityManager.update).toHaveBeenCalledWith( + TypeAvailabilityPolicyScope, + { id: 's1' }, + { defaultAction: 'deny', updatedBy: 'user-2' }, + ); + expect(entityManager.increment).toHaveBeenCalledWith( + TypeAvailabilityPolicyScope, + { id: 's1' }, + 'version', + 1, + ); + expect(entityManager.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy-attachment.repository.ts b/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy-attachment.repository.ts new file mode 100644 index 00000000000..428ad8b9629 --- /dev/null +++ b/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy-attachment.repository.ts @@ -0,0 +1,150 @@ +import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, In, type EntityManager } from '@n8n/typeorm'; +import { UserError } from 'n8n-workflow'; + +import type { PolicyAttachment } from '../../policy-rule.types'; +import { TypeAvailabilityPolicyAttachment } from '../entities/type-availability-policy-attachment.entity'; +import { TypeAvailabilityPolicyScope } from '../entities/type-availability-policy-scope.entity'; +import { TypeAvailabilityPolicy } from '../entities/type-availability-policy.entity'; + +type AttachmentSlot = { + policyId: string; + priority: number; + isFloor: boolean; +}; + +@Service() +export class TypeAvailabilityPolicyAttachmentRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(TypeAvailabilityPolicyAttachment, dataSource.manager, transactionRunner); + } + + /** + * The scope's attachments with each policy's rules resolved, in the shape the evaluator + * consumes. Unordered — ordering by floor then priority is the evaluator's job. + * + * An attachment whose policy row is missing is skipped; the `RESTRICT` FK means that + * should be unreachable, and dropping it beats evaluating a policy with no rules. + */ + async listAttachmentsForScope( + scopeId: string, + ctx: OperationContext, + ): Promise { + const manager = this.managerFor(ctx); + + const attachments = await manager.findBy(TypeAvailabilityPolicyAttachment, { scopeId }); + if (attachments.length === 0) return []; + + const policies = await manager.findBy(TypeAvailabilityPolicy, { + id: In(attachments.map((a) => a.policyId)), + }); + const rulesByPolicyId = new Map(policies.map((p) => [p.id, p.rules])); + + return attachments.flatMap((attachment) => { + const rules = rulesByPolicyId.get(attachment.policyId); + if (!rules) return []; + + return [ + { + policyId: attachment.policyId, + rules, + priority: attachment.priority, + isFloor: attachment.isFloor, + }, + ]; + }); + } + + /** + * Replaces the scope's whole attachment list in one unit of work, mirroring the + * wholesale `PUT` the REST surface exposes. Reordering is a replace with new priorities. + * + * Throws if the new list repeats a policy or reuses a priority within one partition; + * the primary key and `uq_type_availability_attachment_slot` reject both. + * + * Does not bump the scope's version — callers thread the same `ctx` into + * `TypeAvailabilityPolicyScopeRepository.bumpVersion` so both land in one transaction. + */ + async replaceAttachmentsForScope( + scopeId: string, + attachments: readonly AttachmentSlot[], + ctx: OperationContext, + ): Promise { + await this.runInTransaction(ctx, async (tx) => { + await this.assertAttachableToScope(scopeId, attachments, tx); + + await tx.delete(TypeAvailabilityPolicyAttachment, { scopeId }); + if (attachments.length === 0) return; + + await tx.insert( + TypeAvailabilityPolicyAttachment, + attachments.map((a) => ({ scopeId, ...a })), + ); + }); + } + + /** + * Rejects a policy whose `kind` differs from the scope's. + * + * The schema can't express this: `kind` lives on both parent tables but the attachment + * carries neither, and the migration DSL has no composite foreign key to tie them + * together. A mismatch would attach cleanly and then match nothing at evaluation time, + * so the scope would silently stop enforcing what it appears to enforce. This is the + * only write path for attachments, so the invariant is enforced here. + * + * What the check reads stays true until the insert not because it shares a transaction + * with it — at READ COMMITTED a concurrent commit is still visible to a later statement + * — but because `kind` is write-once (no method updates it) and the foreign key stops + * the policy being deleted out from under the insert. + * + * Missing rows are reported here too, rather than surfacing as an opaque FK violation. + * The scope is checked even when the new list is empty, so clearing the attachments of + * a scope that does not exist fails the same way attaching to it would. + */ + private async assertAttachableToScope( + scopeId: string, + attachments: readonly AttachmentSlot[], + tx: EntityManager, + ): Promise { + const scope = await tx.findOne(TypeAvailabilityPolicyScope, { + select: { id: true, kind: true }, + where: { id: scopeId }, + }); + if (!scope) { + throw new UserError('Cannot attach policies to an unknown scope', { extra: { scopeId } }); + } + + if (attachments.length === 0) return; + + const policyIds = attachments.map((a) => a.policyId); + const policies = await tx.find(TypeAvailabilityPolicy, { + select: { id: true, kind: true }, + where: { id: In(policyIds) }, + }); + const kindByPolicyId = new Map(policies.map((p) => [p.id, p.kind])); + + for (const policyId of policyIds) { + const kind = kindByPolicyId.get(policyId); + + if (kind === undefined) { + throw new UserError('Cannot attach an unknown policy', { extra: { policyId, scopeId } }); + } + + if (kind !== scope.kind) { + throw new UserError(`Cannot attach a "${kind}" policy to a "${scope.kind}" scope`, { + extra: { policyId, scopeId }, + }); + } + } + } + + /** Every scope a policy is attached to — the reverse lookup a policy edit fans out over. */ + async listScopeIdsAttachedToPolicy(policyId: string, ctx: OperationContext): Promise { + const attachments = await this.managerFor(ctx).findBy(TypeAvailabilityPolicyAttachment, { + policyId, + }); + + return attachments.map((a) => a.scopeId); + } +} diff --git a/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy-scope.repository.ts b/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy-scope.repository.ts new file mode 100644 index 00000000000..2a3552f9d58 --- /dev/null +++ b/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy-scope.repository.ts @@ -0,0 +1,116 @@ +import { BaseRepository, TransactionRunner, chunkIds, type OperationContext } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, In, IsNull } from '@n8n/typeorm'; + +import type { PolicyAction } from '../../policy-rule.types'; +import { TypeAvailabilityPolicyScope } from '../entities/type-availability-policy-scope.entity'; + +type NewPolicyScope = { + kind: string; + projectId: string | null; + defaultAction: PolicyAction; + updatedBy: string; +}; + +@Service() +export class TypeAvailabilityPolicyScopeRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(TypeAvailabilityPolicyScope, dataSource.manager, transactionRunner); + } + + /** + * `projectId: null` looks up the instance scope. At most one row can match either way — + * the two partial unique indexes guarantee it. + */ + async findScopeByKindAndProject( + kind: string, + projectId: string | null, + ctx: OperationContext, + ): Promise { + return await this.managerFor(ctx).findOneBy(TypeAvailabilityPolicyScope, { + kind, + projectId: projectId ?? IsNull(), + }); + } + + async findScopeById( + id: string, + ctx: OperationContext, + ): Promise { + return await this.managerFor(ctx).findOneBy(TypeAvailabilityPolicyScope, { id }); + } + + async createScope( + input: NewPolicyScope, + ctx: OperationContext, + ): Promise { + const scope = this.create({ ...input, version: 1 }); + + return await this.managerFor(ctx).save(TypeAvailabilityPolicyScope, scope); + } + + /** + * Sets the scope's default action, bumping `version` unless nothing changed. + * + * As with policy rules, the bump is computed by the database — a default-action edit + * racing an attachment change would otherwise write the same version twice, and the + * second change would inherit the first one's cache key. + */ + async updateDefaultAction( + id: string, + defaultAction: PolicyAction, + updatedBy: string, + ctx: OperationContext, + ): Promise { + return await this.runInTransaction(ctx, async (tx) => { + const scope = await tx.findOneBy(TypeAvailabilityPolicyScope, { id }); + if (!scope) return null; + if (scope.defaultAction === defaultAction) return scope; + + await tx.update(TypeAvailabilityPolicyScope, { id }, { defaultAction, updatedBy }); + await tx.increment(TypeAvailabilityPolicyScope, { id }, 'version', 1); + + return await tx.findOneBy(TypeAvailabilityPolicyScope, { id }); + }); + } + + /** + * Bumps the scope's freshness signal without touching its own fields. + * + * Call this under the same `ctx` as whatever changed the scope's *effective* policy — + * an attachment added, removed or reordered, or an attached policy's content edited — + * so the version and the change commit together. + */ + async bumpVersion(id: string, ctx: OperationContext): Promise { + await this.managerFor(ctx).increment(TypeAvailabilityPolicyScope, { id }, 'version', 1); + } + + /** + * Bumps every named scope, for a policy edit fanning out to the scopes it is attached to. + * + * Chunked, because one `IN (…)` binds a parameter per id and a policy may be attached to a + * scope in every project. Wrapped in a transaction so the chunks still land together for a + * caller that passed the root context — one statement was atomic on its own, several are + * not. + */ + async bumpVersions(ids: string[], ctx: OperationContext): Promise { + if (ids.length === 0) return; + + const batches = chunkIds(ids); + if (batches.length === 1) { + await this.managerFor(ctx).increment( + TypeAvailabilityPolicyScope, + { id: In(batches[0]) }, + 'version', + 1, + ); + return; + } + + await this.runInTransaction(ctx, async (tx) => { + for (const batch of batches) { + await tx.increment(TypeAvailabilityPolicyScope, { id: In(batch) }, 'version', 1); + } + }); + } +} diff --git a/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy.repository.ts b/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy.repository.ts new file mode 100644 index 00000000000..94798d64963 --- /dev/null +++ b/packages/cli/src/modules/type-availability-policies/database/repositories/type-availability-policy.repository.ts @@ -0,0 +1,89 @@ +import { BaseRepository, TransactionRunner, type OperationContext } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, In } from '@n8n/typeorm'; +import { isDeepStrictEqual } from 'node:util'; + +import type { PolicyRule } from '../../policy-rule.types'; +import { TypeAvailabilityPolicy } from '../entities/type-availability-policy.entity'; + +type NewPolicy = { + kind: string; + rules: readonly PolicyRule[]; + updatedBy: string; +}; + +/** + * Structural, not serialised: rule order is part of the content, but object-key order is + * not. Comparing JSON text would report a change when a client happens to serialise + * `{ action, id }` instead of `{ id, action }`, bumping the version and invalidating caches + * for a policy that behaves identically. + */ +function rulesEqual(a: readonly PolicyRule[], b: readonly PolicyRule[]): boolean { + return isDeepStrictEqual([...a], [...b]); +} + +@Service() +export class TypeAvailabilityPolicyRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(TypeAvailabilityPolicy, dataSource.manager, transactionRunner); + } + + async findById(id: string, ctx: OperationContext): Promise { + return await this.managerFor(ctx).findOneBy(TypeAvailabilityPolicy, { id }); + } + + async findManyByIds(ids: string[], ctx: OperationContext): Promise { + if (ids.length === 0) return []; + return await this.managerFor(ctx).findBy(TypeAvailabilityPolicy, { id: In(ids) }); + } + + async createPolicy(input: NewPolicy, ctx: OperationContext): Promise { + const policy = this.create({ + kind: input.kind, + rules: [...input.rules], + updatedBy: input.updatedBy, + version: 1, + }); + + return await this.managerFor(ctx).save(TypeAvailabilityPolicy, policy); + } + + /** + * Replaces the rules document and bumps `version`. Returns `null` if no such policy. + * + * Unchanged content is a no-op that leaves `version` alone, so an env-bootstrap upsert + * repeated on every main during a rolling restart doesn't invalidate caches. + * + * The bump is computed by the database rather than from the row read above: two + * overlapping edits would otherwise both write `read version + 1`, losing a bump and + * with it the cache invalidation that version signals. Last writer still wins on the + * rules themselves — rejecting a caller's stale version needs an expected version from + * the client, which only the API layer can supply. + */ + async updateRules( + id: string, + rules: readonly PolicyRule[], + updatedBy: string, + ctx: OperationContext, + ): Promise { + return await this.runInTransaction(ctx, async (tx) => { + const policy = await tx.findOneBy(TypeAvailabilityPolicy, { id }); + if (!policy) return null; + if (rulesEqual(policy.rules, rules)) return policy; + + await tx.update(TypeAvailabilityPolicy, { id }, { rules: [...rules], updatedBy }); + await tx.increment(TypeAvailabilityPolicy, { id }, 'version', 1); + + return await tx.findOneBy(TypeAvailabilityPolicy, { id }); + }); + } + + /** + * Callers must detach the policy from every scope first — the attachment FK is `RESTRICT`, + * so deleting one that is still attached throws rather than silently un-enforcing those + * scopes. + */ + async deletePolicy(id: string, ctx: OperationContext): Promise { + await this.managerFor(ctx).delete(TypeAvailabilityPolicy, { id }); + } +} diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts index ce040dd27d7..6cb989c8f1f 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policies.module.ts @@ -4,4 +4,18 @@ import { BackendModule } from '@n8n/decorators'; @BackendModule({ name: 'type-availability-policies' }) export class TypeAvailabilityPoliciesModule implements ModuleInterface { async init() {} + + async entities() { + const { TypeAvailabilityPolicy } = await import( + './database/entities/type-availability-policy.entity.js' + ); + const { TypeAvailabilityPolicyScope } = await import( + './database/entities/type-availability-policy-scope.entity.js' + ); + const { TypeAvailabilityPolicyAttachment } = await import( + './database/entities/type-availability-policy-attachment.entity.js' + ); + + return [TypeAvailabilityPolicy, TypeAvailabilityPolicyScope, TypeAvailabilityPolicyAttachment]; + } } diff --git a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json index 33738ccf1bb..ebafb96c4c6 100644 --- a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json +++ b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json @@ -34,6 +34,11 @@ "path": "packages/@n8n/db/src/entities/role-mapping-rule.ts", "reason": "SSO role-mapping config attached to the project, not user data." }, + { + "name": "TypeAvailabilityPolicyScope", + "path": "packages/cli/src/modules/type-availability-policies/database/entities/type-availability-policy-scope.entity.ts", + "reason": "Governance config attached to the project, not user data; dropped via FK cascade. Transferring it could not work anyway: a destination project may already own a scope of the same kind, which uq_type_availability_policy_scope_project rejects." + }, { "name": "Variables", "path": "packages/@n8n/db/src/entities/variables.ts", diff --git a/packages/cli/test/integration/policy/type-availability-policy-repositories.test.ts b/packages/cli/test/integration/policy/type-availability-policy-repositories.test.ts new file mode 100644 index 00000000000..2e5322daa59 --- /dev/null +++ b/packages/cli/test/integration/policy/type-availability-policy-repositories.test.ts @@ -0,0 +1,510 @@ +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { TransactionRunner, type OperationContext } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { TypeAvailabilityPolicyAttachmentRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy-attachment.repository'; +import { TypeAvailabilityPolicyScopeRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy-scope.repository'; +import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository'; +import type { PolicyRule } from '@/modules/type-availability-policies/policy-rule.types'; + +const KIND = 'node-types'; + +/** A non-transactional caller passes the root context. */ +const ROOT: OperationContext = {}; + +const DENY_SLACK: PolicyRule = { + id: 'rule-1', + action: 'deny', + selector: { kind: 'name', value: 'n8n-nodes-base.slack' }, +}; + +const ALLOW_BASE: PolicyRule = { + id: 'rule-2', + action: 'allow', + selector: { kind: 'package', value: 'n8n-nodes-base' }, +}; + +describe('type availability policy repositories', () => { + let policyRepo: TypeAvailabilityPolicyRepository; + let scopeRepo: TypeAvailabilityPolicyScopeRepository; + let attachmentRepo: TypeAvailabilityPolicyAttachmentRepository; + let transactionRunner: TransactionRunner; + + beforeAll(async () => { + await testModules.loadModules(['type-availability-policies']); + await testDb.init(); + policyRepo = Container.get(TypeAvailabilityPolicyRepository); + scopeRepo = Container.get(TypeAvailabilityPolicyScopeRepository); + attachmentRepo = Container.get(TypeAvailabilityPolicyAttachmentRepository); + transactionRunner = Container.get(TransactionRunner); + }); + + beforeEach(async () => { + await testDb.truncate([ + 'TypeAvailabilityPolicyAttachment', + 'TypeAvailabilityPolicyScope', + 'TypeAvailabilityPolicy', + ]); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + async function createPolicy(rules: PolicyRule[] = [DENY_SLACK]) { + return await policyRepo.createPolicy({ kind: KIND, rules, updatedBy: 'user-1' }, ROOT); + } + + async function createInstanceScope() { + return await scopeRepo.createScope( + { kind: KIND, projectId: null, defaultAction: 'allow', updatedBy: 'user-1' }, + ROOT, + ); + } + + describe('TypeAvailabilityPolicyRepository', () => { + it('creates a policy with a generated id at version 1', async () => { + const policy = await createPolicy(); + + expect(policy.id).toEqual(expect.any(String)); + expect(policy.version).toBe(1); + expect(await policyRepo.findById(policy.id, ROOT)).toMatchObject({ rules: [DENY_SLACK] }); + }); + + it('round-trips the rules document', async () => { + const policy = await createPolicy([DENY_SLACK, ALLOW_BASE]); + + const stored = await policyRepo.findById(policy.id, ROOT); + + expect(stored?.rules).toEqual([DENY_SLACK, ALLOW_BASE]); + }); + + it('bumps the version when the rules change', async () => { + const policy = await createPolicy(); + + const updated = await policyRepo.updateRules(policy.id, [ALLOW_BASE], 'user-2', ROOT); + + expect(updated?.version).toBe(2); + expect(updated?.rules).toEqual([ALLOW_BASE]); + expect(updated?.updatedBy).toBe('user-2'); + }); + + it('leaves the version alone when the rules are unchanged', async () => { + const policy = await createPolicy([DENY_SLACK, ALLOW_BASE]); + + const updated = await policyRepo.updateRules( + policy.id, + [DENY_SLACK, ALLOW_BASE], + 'user-2', + ROOT, + ); + + expect(updated?.version).toBe(1); + expect(updated?.updatedBy).toBe('user-1'); + }); + + it('treats reordered rules as a change, since order decides first match', async () => { + const policy = await createPolicy([DENY_SLACK, ALLOW_BASE]); + + const updated = await policyRepo.updateRules( + policy.id, + [ALLOW_BASE, DENY_SLACK], + 'user-2', + ROOT, + ); + + expect(updated?.version).toBe(2); + }); + + // Sequential only — the overlapping case is covered by the unit test on the + // repository, since neither database lets this suite interleave two writers. + it('bumps the version once per change, and round-trips the rules each time', async () => { + const policy = await createPolicy([DENY_SLACK]); + + await policyRepo.updateRules(policy.id, [ALLOW_BASE], 'user-2', ROOT); + await policyRepo.updateRules(policy.id, [DENY_SLACK], 'user-3', ROOT); + const third = await policyRepo.updateRules(policy.id, [ALLOW_BASE, DENY_SLACK], 'u', ROOT); + + expect(third?.version).toBe(4); + expect(third?.rules).toEqual([ALLOW_BASE, DENY_SLACK]); + }); + + it('treats a differently-ordered rule object as unchanged', async () => { + const policy = await createPolicy([DENY_SLACK]); + // Same rule, keys serialised in another order — as a client or an env + // config could plausibly send it. + const reordered = { + selector: { value: 'n8n-nodes-base.slack', kind: 'name' }, + action: 'deny', + id: 'rule-1', + } as unknown as PolicyRule; + + const updated = await policyRepo.updateRules(policy.id, [reordered], 'user-2', ROOT); + + expect(updated?.version).toBe(1); + expect(updated?.updatedBy).toBe('user-1'); + }); + + it('returns null when updating a policy that does not exist', async () => { + expect(await policyRepo.updateRules('missing', [ALLOW_BASE], 'user-1', ROOT)).toBeNull(); + }); + + it('finds many by id and ignores unknown ids', async () => { + const a = await createPolicy(); + const b = await createPolicy([ALLOW_BASE]); + + const found = await policyRepo.findManyByIds([a.id, b.id, 'missing'], ROOT); + + expect(found.map((p) => p.id).sort()).toEqual([a.id, b.id].sort()); + }); + + it('refuses to delete a policy that is still attached', async () => { + const policy = await createPolicy(); + const scope = await createInstanceScope(); + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: policy.id, priority: 0, isFloor: false }], + ROOT, + ); + + await expect(policyRepo.deletePolicy(policy.id, ROOT)).rejects.toThrow(); + expect(await policyRepo.findById(policy.id, ROOT)).not.toBeNull(); + }); + + it('deletes a policy once it is detached', async () => { + const policy = await createPolicy(); + const scope = await createInstanceScope(); + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: policy.id, priority: 0, isFloor: false }], + ROOT, + ); + + await attachmentRepo.replaceAttachmentsForScope(scope.id, [], ROOT); + await policyRepo.deletePolicy(policy.id, ROOT); + + expect(await policyRepo.findById(policy.id, ROOT)).toBeNull(); + }); + }); + + describe('TypeAvailabilityPolicyScopeRepository', () => { + it('finds the instance scope by a null projectId', async () => { + const scope = await createInstanceScope(); + + const found = await scopeRepo.findScopeByKindAndProject(KIND, null, ROOT); + + expect(found?.id).toBe(scope.id); + expect(found?.projectId).toBeNull(); + }); + + it('does not confuse a project scope with the instance scope', async () => { + const project = await createTeamProject(); + await createInstanceScope(); + const projectScope = await scopeRepo.createScope( + { kind: KIND, projectId: project.id, defaultAction: 'deny', updatedBy: 'user-1' }, + ROOT, + ); + + const found = await scopeRepo.findScopeByKindAndProject(KIND, project.id, ROOT); + + expect(found?.id).toBe(projectScope.id); + expect(found?.defaultAction).toBe('deny'); + }); + + it('rejects a second instance scope for the same kind', async () => { + await createInstanceScope(); + + await expect(createInstanceScope()).rejects.toThrow(); + }); + + it('bumps the version when the default action changes', async () => { + const scope = await createInstanceScope(); + + const updated = await scopeRepo.updateDefaultAction(scope.id, 'deny', 'user-2', ROOT); + + expect(updated?.version).toBe(2); + expect(updated?.defaultAction).toBe('deny'); + }); + + it('leaves the version alone when the default action is unchanged', async () => { + const scope = await createInstanceScope(); + + const updated = await scopeRepo.updateDefaultAction(scope.id, 'allow', 'user-2', ROOT); + + expect(updated?.version).toBe(1); + }); + + it('bumps many versions at once', async () => { + const project = await createTeamProject(); + const instanceScope = await createInstanceScope(); + const projectScope = await scopeRepo.createScope( + { kind: KIND, projectId: project.id, defaultAction: 'allow', updatedBy: 'user-1' }, + ROOT, + ); + + await scopeRepo.bumpVersions([instanceScope.id, projectScope.id], ROOT); + + expect((await scopeRepo.findScopeById(instanceScope.id, ROOT))?.version).toBe(2); + expect((await scopeRepo.findScopeById(projectScope.id, ROOT))?.version).toBe(2); + }); + }); + + describe('TypeAvailabilityPolicyAttachmentRepository', () => { + it('returns attachments with their policy rules resolved', async () => { + const scope = await createInstanceScope(); + const floor = await createPolicy([DENY_SLACK]); + const normal = await createPolicy([ALLOW_BASE]); + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [ + { policyId: normal.id, priority: 0, isFloor: false }, + { policyId: floor.id, priority: 0, isFloor: true }, + ], + ROOT, + ); + + const attachments = await attachmentRepo.listAttachmentsForScope(scope.id, ROOT); + + expect(attachments).toHaveLength(2); + expect(attachments).toContainEqual({ + policyId: floor.id, + rules: [DENY_SLACK], + priority: 0, + isFloor: true, + }); + expect(attachments).toContainEqual({ + policyId: normal.id, + rules: [ALLOW_BASE], + priority: 0, + isFloor: false, + }); + }); + + it('replaces the whole list rather than appending', async () => { + const scope = await createInstanceScope(); + const first = await createPolicy(); + const second = await createPolicy([ALLOW_BASE]); + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: first.id, priority: 0, isFloor: false }], + ROOT, + ); + + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: second.id, priority: 0, isFloor: false }], + ROOT, + ); + + const attachments = await attachmentRepo.listAttachmentsForScope(scope.id, ROOT); + expect(attachments.map((a) => a.policyId)).toEqual([second.id]); + }); + + it('reorders by replacing with new priorities, without a transient collision', async () => { + const scope = await createInstanceScope(); + const a = await createPolicy(); + const b = await createPolicy([ALLOW_BASE]); + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [ + { policyId: a.id, priority: 0, isFloor: false }, + { policyId: b.id, priority: 1, isFloor: false }, + ], + ROOT, + ); + + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [ + { policyId: a.id, priority: 1, isFloor: false }, + { policyId: b.id, priority: 0, isFloor: false }, + ], + ROOT, + ); + + const attachments = await attachmentRepo.listAttachmentsForScope(scope.id, ROOT); + const byPolicy = new Map(attachments.map((at) => [at.policyId, at.priority])); + expect(byPolicy.get(a.id)).toBe(1); + expect(byPolicy.get(b.id)).toBe(0); + }); + + it('rejects a list that reuses a priority within one partition', async () => { + const scope = await createInstanceScope(); + const a = await createPolicy(); + const b = await createPolicy([ALLOW_BASE]); + + await expect( + attachmentRepo.replaceAttachmentsForScope( + scope.id, + [ + { policyId: a.id, priority: 0, isFloor: false }, + { policyId: b.id, priority: 0, isFloor: false }, + ], + ROOT, + ), + ).rejects.toThrow(); + }); + + it('admits the same priority across the floor and normal partitions', async () => { + const scope = await createInstanceScope(); + const a = await createPolicy(); + const b = await createPolicy([ALLOW_BASE]); + + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [ + { policyId: a.id, priority: 0, isFloor: true }, + { policyId: b.id, priority: 0, isFloor: false }, + ], + ROOT, + ); + + expect(await attachmentRepo.listAttachmentsForScope(scope.id, ROOT)).toHaveLength(2); + }); + + it('rejects a policy whose kind differs from the scope', async () => { + const scope = await createInstanceScope(); + const otherKind = await policyRepo.createPolicy( + { kind: 'credential-types', rules: [DENY_SLACK], updatedBy: 'user-1' }, + ROOT, + ); + + await expect( + attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: otherKind.id, priority: 0, isFloor: false }], + ROOT, + ), + ).rejects.toThrow('Cannot attach a "credential-types" policy to a "node-types" scope'); + }); + + it('leaves the existing attachments untouched when the new list is rejected', async () => { + const scope = await createInstanceScope(); + const good = await createPolicy(); + const otherKind = await policyRepo.createPolicy( + { kind: 'credential-types', rules: [DENY_SLACK], updatedBy: 'user-1' }, + ROOT, + ); + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: good.id, priority: 0, isFloor: false }], + ROOT, + ); + + await expect( + attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: otherKind.id, priority: 0, isFloor: false }], + ROOT, + ), + ).rejects.toThrow(); + + const attachments = await attachmentRepo.listAttachmentsForScope(scope.id, ROOT); + expect(attachments.map((a) => a.policyId)).toEqual([good.id]); + }); + + it('reports an unknown policy rather than an opaque constraint error', async () => { + const scope = await createInstanceScope(); + + await expect( + attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: 'does-not-exist', priority: 0, isFloor: false }], + ROOT, + ), + ).rejects.toThrow('Cannot attach an unknown policy'); + }); + + it('reports an unknown scope', async () => { + const policy = await createPolicy(); + + await expect( + attachmentRepo.replaceAttachmentsForScope( + 'does-not-exist', + [{ policyId: policy.id, priority: 0, isFloor: false }], + ROOT, + ), + ).rejects.toThrow('Cannot attach policies to an unknown scope'); + }); + + it('reports an unknown scope even when the new list is empty', async () => { + await expect( + attachmentRepo.replaceAttachmentsForScope('does-not-exist', [], ROOT), + ).rejects.toThrow('Cannot attach policies to an unknown scope'); + }); + + it('clears the attachments of a scope that does exist', async () => { + const scope = await createInstanceScope(); + const policy = await createPolicy(); + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: policy.id, priority: 0, isFloor: false }], + ROOT, + ); + + await attachmentRepo.replaceAttachmentsForScope(scope.id, [], ROOT); + + expect(await attachmentRepo.listAttachmentsForScope(scope.id, ROOT)).toEqual([]); + }); + + it('lists every scope a policy is attached to', async () => { + const project = await createTeamProject(); + const policy = await createPolicy(); + const instanceScope = await createInstanceScope(); + const projectScope = await scopeRepo.createScope( + { kind: KIND, projectId: project.id, defaultAction: 'allow', updatedBy: 'user-1' }, + ROOT, + ); + for (const scopeId of [instanceScope.id, projectScope.id]) { + await attachmentRepo.replaceAttachmentsForScope( + scopeId, + [{ policyId: policy.id, priority: 0, isFloor: false }], + ROOT, + ); + } + + const scopeIds = await attachmentRepo.listScopeIdsAttachedToPolicy(policy.id, ROOT); + + expect(scopeIds.sort()).toEqual([instanceScope.id, projectScope.id].sort()); + }); + }); + + describe('transactional writes', () => { + it('commits an attachment change and its version bump together', async () => { + const scope = await createInstanceScope(); + const policy = await createPolicy(); + + await transactionRunner.run({}, async (ctx) => { + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: policy.id, priority: 0, isFloor: false }], + ctx, + ); + await scopeRepo.bumpVersion(scope.id, ctx); + }); + + expect(await attachmentRepo.listAttachmentsForScope(scope.id, ROOT)).toHaveLength(1); + expect((await scopeRepo.findScopeById(scope.id, ROOT))?.version).toBe(2); + }); + + it('rolls both back when the unit of work fails', async () => { + const scope = await createInstanceScope(); + const policy = await createPolicy(); + + await expect( + transactionRunner.run({}, async (ctx) => { + await attachmentRepo.replaceAttachmentsForScope( + scope.id, + [{ policyId: policy.id, priority: 0, isFloor: false }], + ctx, + ); + await scopeRepo.bumpVersion(scope.id, ctx); + throw new Error('fan-out failed'); + }), + ).rejects.toThrow('fan-out failed'); + + expect(await attachmentRepo.listAttachmentsForScope(scope.id, ROOT)).toHaveLength(0); + expect((await scopeRepo.findScopeById(scope.id, ROOT))?.version).toBe(1); + }); + }); +}); diff --git a/packages/cli/test/migration/1787841960965-create-type-availability-policy-tables.test.ts b/packages/cli/test/migration/1787841960965-create-type-availability-policy-tables.test.ts new file mode 100644 index 00000000000..fe39c4ef7c4 --- /dev/null +++ b/packages/cli/test/migration/1787841960965-create-type-availability-policy-tables.test.ts @@ -0,0 +1,322 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'CreateTypeAvailabilityPolicyTables1787841960965'; + +const KIND = 'node-types'; + +describe('CreateTypeAvailabilityPolicyTables migration', () => { + let dataSource: DataSource; + + beforeAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + dataSource = Container.get(DataSource); + const context = createTestMigrationContext(dataSource); + await context.queryRunner.clearDatabase(); + await context.queryRunner.release(); + await initDbUpToMigration(MIGRATION_NAME); + await runSingleMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + await Container.get(DbConnection).close(); + }); + + async function insertProject(context: TestMigrationContext, id: string) { + const table = context.escape.tableName('project'); + const now = new Date(); + await context.runQuery( + `INSERT INTO ${table} ("id", "name", "type", "customTelemetryTags", "createdAt", "updatedAt") + VALUES (:id, :name, :type, :tags, :createdAt, :updatedAt)`, + { id, name: 'Project', type: 'team', tags: '[]', createdAt: now, updatedAt: now }, + ); + } + + async function insertPolicy(context: TestMigrationContext, id: string) { + const table = context.escape.tableName('type_availability_policy'); + const now = new Date(); + await context.runQuery( + `INSERT INTO ${table} ("id", "kind", "rules", "version", "updatedBy", "createdAt", "updatedAt") + VALUES (:id, :kind, :rules, :version, :updatedBy, :createdAt, :updatedAt)`, + { + id, + kind: KIND, + rules: '[]', + version: 1, + updatedBy: 'environment', + createdAt: now, + updatedAt: now, + }, + ); + } + + async function insertScope( + context: TestMigrationContext, + id: string, + projectId: string | null, + defaultAction = 'allow', + ) { + const table = context.escape.tableName('type_availability_policy_scope'); + const now = new Date(); + await context.runQuery( + `INSERT INTO ${table} ("id", "kind", "projectId", "defaultAction", "version", "updatedBy", "createdAt", "updatedAt") + VALUES (:id, :kind, :projectId, :defaultAction, :version, :updatedBy, :createdAt, :updatedAt)`, + { + id, + kind: KIND, + projectId, + defaultAction, + version: 1, + updatedBy: 'environment', + createdAt: now, + updatedAt: now, + }, + ); + } + + async function insertAttachment( + context: TestMigrationContext, + scopeId: string, + policyId: string, + { priority = 0, isFloor = false }: { priority?: number; isFloor?: boolean } = {}, + ) { + const table = context.escape.tableName('type_availability_policy_attachment'); + const now = new Date(); + await context.runQuery( + `INSERT INTO ${table} ("scopeId", "policyId", "priority", "isFloor", "createdAt", "updatedAt") + VALUES (:scopeId, :policyId, :priority, :isFloor, :createdAt, :updatedAt)`, + { scopeId, policyId, priority, isFloor, createdAt: now, updatedAt: now }, + ); + } + + /** + * Only one instance scope may exist per kind, and rows persist across cases here, + * so tests that just need *a* scope take a project-scoped one of their own. + */ + async function insertScopeInFreshProject(context: TestMigrationContext) { + const projectId = randomUUID(); + const scopeId = randomUUID(); + await insertProject(context, projectId); + await insertScope(context, scopeId, projectId); + return scopeId; + } + + async function countAttachments(context: TestMigrationContext, scopeId: string) { + const table = context.escape.tableName('type_availability_policy_attachment'); + const rows = await context.runQuery>( + `SELECT COUNT(*) as c FROM ${table} WHERE "scopeId" = :scopeId`, + { scopeId }, + ); + return Number(rows[0].c); + } + + describe('uq_type_availability_policy_scope_instance', () => { + // An instance scope is a singleton per kind and this suite has no per-test + // cleanup, so the row is removed here rather than left to collide with + // whatever runs next. + afterEach(async () => { + const context = createTestMigrationContext(dataSource); + await context.runQuery( + `DELETE FROM ${context.escape.tableName('type_availability_policy_scope')} + WHERE "projectId" IS NULL AND "kind" = :kind`, + { kind: KIND }, + ); + await context.queryRunner.release(); + }); + + it('rejects a second instance scope for the same kind', async () => { + const context = createTestMigrationContext(dataSource); + await insertScope(context, randomUUID(), null); + + await expect(insertScope(context, randomUUID(), null)).rejects.toThrow(); + await context.queryRunner.release(); + }); + + it('admits an instance scope for a different kind', async () => { + const context = createTestMigrationContext(dataSource); + await insertScope(context, randomUUID(), null); + + await context.runQuery( + `INSERT INTO ${context.escape.tableName('type_availability_policy_scope')} + ("id", "kind", "projectId", "defaultAction", "version", "updatedBy", "createdAt", "updatedAt") + VALUES (:id, :kind, NULL, :defaultAction, 1, :updatedBy, :now, :now)`, + { + id: randomUUID(), + kind: 'credential-types', + defaultAction: 'allow', + updatedBy: 'environment', + now: new Date(), + }, + ); + + const rows = await context.runQuery>( + `SELECT COUNT(*) as c FROM ${context.escape.tableName('type_availability_policy_scope')} + WHERE "projectId" IS NULL`, + ); + expect(Number(rows[0].c)).toBe(2); + + await context.runQuery( + `DELETE FROM ${context.escape.tableName('type_availability_policy_scope')} + WHERE "projectId" IS NULL AND "kind" = :kind`, + { kind: 'credential-types' }, + ); + await context.queryRunner.release(); + }); + }); + + describe('uq_type_availability_policy_scope_project', () => { + it('rejects a second scope for the same kind and project', async () => { + const context = createTestMigrationContext(dataSource); + const projectId = randomUUID(); + await insertProject(context, projectId); + await insertScope(context, randomUUID(), projectId); + + await expect(insertScope(context, randomUUID(), projectId)).rejects.toThrow(); + await context.queryRunner.release(); + }); + + it('admits the same kind in a different project', async () => { + const context = createTestMigrationContext(dataSource); + const projectA = randomUUID(); + const projectB = randomUUID(); + await insertProject(context, projectA); + await insertProject(context, projectB); + await insertScope(context, randomUUID(), projectA); + + await expect(insertScope(context, randomUUID(), projectB)).resolves.not.toThrow(); + await context.queryRunner.release(); + }); + }); + + describe('attachment uniqueness (composite primary key)', () => { + it('rejects attaching the same policy to a scope twice', async () => { + const context = createTestMigrationContext(dataSource); + const scopeId = await insertScopeInFreshProject(context); + const policyId = randomUUID(); + await insertPolicy(context, policyId); + await insertAttachment(context, scopeId, policyId, { priority: 0 }); + + await expect(insertAttachment(context, scopeId, policyId, { priority: 1 })).rejects.toThrow(); + await context.queryRunner.release(); + }); + }); + + describe('uq_type_availability_attachment_slot', () => { + it('rejects two attachments sharing a priority in the same partition', async () => { + const context = createTestMigrationContext(dataSource); + const scopeId = await insertScopeInFreshProject(context); + const policyA = randomUUID(); + const policyB = randomUUID(); + await insertPolicy(context, policyA); + await insertPolicy(context, policyB); + await insertAttachment(context, scopeId, policyA, { priority: 0, isFloor: false }); + + await expect( + insertAttachment(context, scopeId, policyB, { priority: 0, isFloor: false }), + ).rejects.toThrow(); + await context.queryRunner.release(); + }); + + it('admits the same priority in the floor and normal partitions', async () => { + const context = createTestMigrationContext(dataSource); + const scopeId = await insertScopeInFreshProject(context); + const policyA = randomUUID(); + const policyB = randomUUID(); + await insertPolicy(context, policyA); + await insertPolicy(context, policyB); + await insertAttachment(context, scopeId, policyA, { priority: 0, isFloor: false }); + + await expect( + insertAttachment(context, scopeId, policyB, { priority: 0, isFloor: true }), + ).resolves.not.toThrow(); + await context.queryRunner.release(); + }); + }); + + describe('foreign keys', () => { + it('cascades attachments away when the scope is deleted', async () => { + const context = createTestMigrationContext(dataSource); + const scopeId = await insertScopeInFreshProject(context); + const policyId = randomUUID(); + await insertPolicy(context, policyId); + await insertAttachment(context, scopeId, policyId); + + await context.runQuery( + `DELETE FROM ${context.escape.tableName('type_availability_policy_scope')} WHERE "id" = :id`, + { id: scopeId }, + ); + + expect(await countAttachments(context, scopeId)).toBe(0); + await context.queryRunner.release(); + }); + + it('cascades the scope away when its project is deleted', async () => { + const context = createTestMigrationContext(dataSource); + const projectId = randomUUID(); + const scopeId = randomUUID(); + await insertProject(context, projectId); + await insertScope(context, scopeId, projectId); + + await context.runQuery( + `DELETE FROM ${context.escape.tableName('project')} WHERE "id" = :id`, + { id: projectId }, + ); + + const rows = await context.runQuery>( + `SELECT COUNT(*) as c FROM ${context.escape.tableName('type_availability_policy_scope')} WHERE "id" = :id`, + { id: scopeId }, + ); + expect(Number(rows[0].c)).toBe(0); + await context.queryRunner.release(); + }); + + it('refuses to delete a policy that is still attached', async () => { + const context = createTestMigrationContext(dataSource); + const scopeId = await insertScopeInFreshProject(context); + const policyId = randomUUID(); + await insertPolicy(context, policyId); + await insertAttachment(context, scopeId, policyId); + + await expect( + context.runQuery( + `DELETE FROM ${context.escape.tableName('type_availability_policy')} WHERE "id" = :id`, + { id: policyId }, + ), + ).rejects.toThrow(); + await context.queryRunner.release(); + }); + }); + + describe('Down migration', () => { + it('drops all three tables and can be re-applied', async () => { + await dataSource.undoLastMigration({ transaction: 'each' }); + + const context = createTestMigrationContext(dataSource); + const tables = [ + 'type_availability_policy_attachment', + 'type_availability_policy_scope', + 'type_availability_policy', + ].map((name) => `${context.tablePrefix}${name}`); + for (const table of tables) { + expect(await context.queryRunner.hasTable(table)).toBe(false); + } + await context.queryRunner.release(); + + await runSingleMigration(MIGRATION_NAME); + const context2 = createTestMigrationContext(dataSource); + for (const table of tables) { + expect(await context2.queryRunner.hasTable(table)).toBe(true); + } + await context2.queryRunner.release(); + }); + }); +});