mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
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:
co-authored by
Claude Opus 5
parent
e774ed2e48
commit
37045ce3bd
Generated
+33
@@ -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
@@ -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)
|
||||
+75
@@ -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)
|
||||
Generated
+33
@@ -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
|
||||
|
||||
Generated
+12
-1
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
+118
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+30
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+127
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+150
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+89
@@ -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 });
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+322
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user