feat(core): Add node type policy tables and repositories (no-changelog) (#37254)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart
2026-08-31 14:40:11 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent e774ed2e48
commit 37045ce3bd
24 changed files with 2106 additions and 2 deletions
+33
View File
@@ -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
+12 -1
View File
@@ -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
@@ -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)
@@ -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)
@@ -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)
+33
View File
@@ -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
+12 -1
View File
@@ -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
+67
View File
@@ -0,0 +1,67 @@
# type_availability_policy
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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')))
```
</details>
## 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)
@@ -0,0 +1,82 @@
# type_availability_policy_attachment
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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"))
```
</details>
## 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)
@@ -0,0 +1,85 @@
# type_availability_policy_scope
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
## 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)
@@ -170,6 +170,9 @@ type EntityName =
| 'DynamicCredentialEntry'
| 'DynamicCredentialResolver'
| 'DynamicCredentialUserEntry'
| 'TypeAvailabilityPolicy'
| 'TypeAvailabilityPolicyScope'
| 'TypeAvailabilityPolicyAttachment'
| 'TokenExchangeJti'
| 'TrustedKeySourceEntity'
| 'TrustedKeyEntity'
@@ -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);
}
}
@@ -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',
]);
});
});
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<TransactionRunner>();
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<TransactionRunner>();
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();
});
});
});
@@ -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<TypeAvailabilityPolicyAttachment> {
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<PolicyAttachment[]> {
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<void> {
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<void> {
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<string[]> {
const attachments = await this.managerFor(ctx).findBy(TypeAvailabilityPolicyAttachment, {
policyId,
});
return attachments.map((a) => a.scopeId);
}
}
@@ -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<TypeAvailabilityPolicyScope> {
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<TypeAvailabilityPolicyScope | null> {
return await this.managerFor(ctx).findOneBy(TypeAvailabilityPolicyScope, {
kind,
projectId: projectId ?? IsNull(),
});
}
async findScopeById(
id: string,
ctx: OperationContext,
): Promise<TypeAvailabilityPolicyScope | null> {
return await this.managerFor(ctx).findOneBy(TypeAvailabilityPolicyScope, { id });
}
async createScope(
input: NewPolicyScope,
ctx: OperationContext,
): Promise<TypeAvailabilityPolicyScope> {
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<TypeAvailabilityPolicyScope | null> {
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<void> {
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<void> {
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);
}
});
}
}
@@ -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<TypeAvailabilityPolicy> {
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
super(TypeAvailabilityPolicy, dataSource.manager, transactionRunner);
}
async findById(id: string, ctx: OperationContext): Promise<TypeAvailabilityPolicy | null> {
return await this.managerFor(ctx).findOneBy(TypeAvailabilityPolicy, { id });
}
async findManyByIds(ids: string[], ctx: OperationContext): Promise<TypeAvailabilityPolicy[]> {
if (ids.length === 0) return [];
return await this.managerFor(ctx).findBy(TypeAvailabilityPolicy, { id: In(ids) });
}
async createPolicy(input: NewPolicy, ctx: OperationContext): Promise<TypeAvailabilityPolicy> {
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<TypeAvailabilityPolicy | null> {
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<void> {
await this.managerFor(ctx).delete(TypeAvailabilityPolicy, { id });
}
}
@@ -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];
}
}
@@ -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",
@@ -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);
});
});
});
@@ -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<Array<{ c: number }>>(
`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<Array<{ c: number }>>(
`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<Array<{ c: number }>>(
`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();
});
});
});