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();
+ });
+ });
+});