mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
feat(core): Persist IAI execution permissions on DB
This commit is contained in:
@@ -73,6 +73,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
||||
| [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) | 12 | | BASE TABLE |
|
||||
| [public.instance_ai_resources](public.instance_ai_resources.md) | 5 | | BASE TABLE |
|
||||
| [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) | 11 | | BASE TABLE |
|
||||
| [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | 5 | | BASE TABLE |
|
||||
| [public.instance_ai_threads](public.instance_ai_threads.md) | 7 | | BASE TABLE |
|
||||
| [public.instance_ai_workflow_snapshots](public.instance_ai_workflow_snapshots.md) | 7 | | BASE TABLE |
|
||||
| [public.instance_version_history](public.instance_version_history.md) | 5 | | BASE TABLE |
|
||||
@@ -237,6 +238,8 @@ erDiagram
|
||||
"public.instance_ai_pending_confirmations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_pending_confirmations" }o--o| "public.instance_ai_checkpoints" : "FOREIGN KEY (#quot;checkpointKey#quot;) REFERENCES instance_ai_checkpoints(key) ON DELETE CASCADE"
|
||||
"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_thread_grants" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
|
||||
"public.oauth_access_tokens" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.oauth_access_tokens" }o--|| "public.oauth_clients" : "FOREIGN KEY (#quot;clientId#quot;) REFERENCES oauth_clients(id) ON DELETE CASCADE"
|
||||
@@ -937,6 +940,13 @@ erDiagram
|
||||
text tree
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.instance_ai_thread_grants" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_512_ grantKey
|
||||
uuid threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.instance_ai_threads" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
uuid id
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# public.instance_ai_thread_grants
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| grantKey | varchar(512) | | false | | | Namespaced "always allow" grant the user approved for the thread, e.g. "executions:run". Wide enough to hold a namespace prefix plus a resource identifier. |
|
||||
| threadId | uuid | | false | | [public.instance_ai_threads](public.instance_ai_threads.md) | |
|
||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| userId | uuid | | false | | [public.user](public.user.md) | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| FK_401b94abf83d1ac7a841f31330e | FOREIGN KEY | FOREIGN KEY ("userId") REFERENCES "user"(id) ON DELETE CASCADE |
|
||||
| FK_908202dbc0a9b52f669c11d730c | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES instance_ai_threads(id) ON DELETE CASCADE |
|
||||
| PK_56107d26ebeabf780c5cf311d66 | PRIMARY KEY | PRIMARY KEY ("threadId", "userId", "grantKey") |
|
||||
| instance_ai_thread_grants_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||
| instance_ai_thread_grants_grantKey_not_null | n | NOT NULL "grantKey" |
|
||||
| instance_ai_thread_grants_threadId_not_null | n | NOT NULL "threadId" |
|
||||
| instance_ai_thread_grants_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||
| instance_ai_thread_grants_userId_not_null | n | NOT NULL "userId" |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_401b94abf83d1ac7a841f31330 | CREATE INDEX "IDX_401b94abf83d1ac7a841f31330" ON public.instance_ai_thread_grants USING btree ("userId") |
|
||||
| PK_56107d26ebeabf780c5cf311d66 | CREATE UNIQUE INDEX "PK_56107d26ebeabf780c5cf311d66" ON public.instance_ai_thread_grants USING btree ("threadId", "userId", "grantKey") |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"public.instance_ai_thread_grants" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
|
||||
"public.instance_ai_thread_grants" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_512_ grantKey
|
||||
uuid threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.instance_ai_threads" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
uuid id
|
||||
json metadata
|
||||
varchar_36_ projectId FK
|
||||
varchar_255_ resourceId
|
||||
text title
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.user" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
boolean disabled
|
||||
varchar_255_ email
|
||||
varchar_32_ firstName
|
||||
uuid id
|
||||
date lastActiveAt
|
||||
varchar_32_ lastName
|
||||
boolean mfaEnabled
|
||||
text mfaRecoveryCodes
|
||||
text mfaSecret
|
||||
varchar_255_ password
|
||||
json personalizationAnswers
|
||||
varchar_128_ roleSlug FK
|
||||
json settings
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
@@ -5,7 +5,7 @@
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||
| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) | | |
|
||||
| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | | |
|
||||
| metadata | json | | true | | | |
|
||||
| projectId | varchar(36) | | false | | [public.project](public.project.md) | Project this thread is scoped to |
|
||||
| resourceId | varchar(255) | | false | | | |
|
||||
@@ -48,6 +48,7 @@ erDiagram
|
||||
"public.instance_ai_observations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;observationScopeId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_pending_confirmations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_thread_grants" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
|
||||
|
||||
"public.instance_ai_threads" {
|
||||
@@ -181,6 +182,13 @@ erDiagram
|
||||
text tree
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
}
|
||||
"public.instance_ai_thread_grants" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_512_ grantKey
|
||||
uuid threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.project" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
uuid creatorId FK
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| disabled | boolean | false | false | | | |
|
||||
| email | varchar(255) | | true | | | |
|
||||
| firstName | varchar(32) | | true | | | |
|
||||
| id | uuid | gen_random_uuid() | false | [public.agent_history](public.agent_history.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) | | |
|
||||
| id | uuid | gen_random_uuid() | false | [public.agent_history](public.agent_history.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) | | |
|
||||
| lastActiveAt | date | | true | | | |
|
||||
| lastName | varchar(32) | | true | | | |
|
||||
| mfaEnabled | boolean | false | false | | | |
|
||||
@@ -56,6 +56,7 @@ erDiagram
|
||||
"public.evaluation_collection" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
|
||||
"public.instance_ai_mcp_registry_connections" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_pending_confirmations" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.oauth_access_tokens" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.oauth_authorization_codes" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
"public.oauth_refresh_tokens" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
|
||||
@@ -186,6 +187,13 @@ erDiagram
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.instance_ai_thread_grants" {
|
||||
timestamp_3__with_time_zone createdAt
|
||||
varchar_512_ grantKey
|
||||
uuid threadId FK
|
||||
timestamp_3__with_time_zone updatedAt
|
||||
uuid userId FK
|
||||
}
|
||||
"public.oauth_access_tokens" {
|
||||
varchar clientId FK
|
||||
varchar token
|
||||
|
||||
@@ -73,6 +73,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
||||
| [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) | 12 | | table |
|
||||
| [instance_ai_resources](instance_ai_resources.md) | 5 | | table |
|
||||
| [instance_ai_run_snapshots](instance_ai_run_snapshots.md) | 11 | | table |
|
||||
| [instance_ai_thread_grants](instance_ai_thread_grants.md) | 5 | | table |
|
||||
| [instance_ai_threads](instance_ai_threads.md) | 7 | | table |
|
||||
| [instance_ai_workflow_snapshots](instance_ai_workflow_snapshots.md) | 7 | | table |
|
||||
| [instance_version_history](instance_version_history.md) | 5 | | table |
|
||||
@@ -221,6 +222,8 @@ erDiagram
|
||||
"instance_ai_pending_confirmations" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_pending_confirmations" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_thread_grants" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_access_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_access_tokens" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
@@ -925,6 +928,13 @@ erDiagram
|
||||
TEXT tree
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"instance_ai_thread_grants" {
|
||||
datetime_3_ createdAt
|
||||
varchar_512_ grantKey PK
|
||||
varchar threadId PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId PK
|
||||
}
|
||||
"instance_ai_threads" {
|
||||
datetime_3_ createdAt
|
||||
varchar id PK
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# instance_ai_thread_grants
|
||||
|
||||
## Description
|
||||
|
||||
<details>
|
||||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "instance_ai_thread_grants" ("threadId" varchar NOT NULL, "userId" varchar NOT NULL, "grantKey" varchar(512) 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 "FK_908202dbc0a9b52f669c11d730c" FOREIGN KEY ("threadId") REFERENCES "instance_ai_threads" ("id") ON DELETE CASCADE, CONSTRAINT "FK_401b94abf83d1ac7a841f31330e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE, PRIMARY KEY ("threadId", "userId", "grantKey"))
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Columns
|
||||
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| grantKey | varchar(512) | | false | | | |
|
||||
| threadId | varchar | | false | | [instance_ai_threads](instance_ai_threads.md) | |
|
||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| userId | varchar | | false | | [user](user.md) | |
|
||||
|
||||
## Constraints
|
||||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| grantKey | PRIMARY KEY | PRIMARY KEY (grantKey) |
|
||||
| sqlite_autoindex_instance_ai_thread_grants_1 | PRIMARY KEY | PRIMARY KEY (threadId, userId, grantKey) |
|
||||
| threadId | PRIMARY KEY | PRIMARY KEY (threadId) |
|
||||
| userId | PRIMARY KEY | PRIMARY KEY (userId) |
|
||||
|
||||
## Indexes
|
||||
|
||||
| Name | Definition |
|
||||
| ---- | ---------- |
|
||||
| IDX_401b94abf83d1ac7a841f31330 | CREATE INDEX "IDX_401b94abf83d1ac7a841f31330" ON "instance_ai_thread_grants" ("userId") |
|
||||
| sqlite_autoindex_instance_ai_thread_grants_1 | PRIMARY KEY (threadId, userId, grantKey) |
|
||||
|
||||
## Relations
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
"instance_ai_thread_grants" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"instance_ai_thread_grants" {
|
||||
datetime_3_ createdAt
|
||||
varchar_512_ grantKey PK
|
||||
varchar threadId PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId PK
|
||||
}
|
||||
"instance_ai_threads" {
|
||||
datetime_3_ createdAt
|
||||
varchar id PK
|
||||
TEXT metadata
|
||||
varchar_36_ projectId FK
|
||||
varchar_255_ resourceId
|
||||
TEXT title
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"user" {
|
||||
datetime_3_ createdAt
|
||||
boolean disabled
|
||||
varchar_255_ email
|
||||
varchar_32_ firstName
|
||||
varchar id PK
|
||||
date lastActiveAt
|
||||
varchar_32_ lastName
|
||||
boolean mfaEnabled
|
||||
TEXT mfaRecoveryCodes
|
||||
TEXT mfaSecret
|
||||
varchar password
|
||||
TEXT personalizationAnswers
|
||||
varchar_128_ roleSlug FK
|
||||
TEXT settings
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Generated by [tbls](https://github.com/k1LoW/tbls)
|
||||
@@ -16,7 +16,7 @@ CREATE TABLE "instance_ai_threads" ("id" varchar PRIMARY KEY NOT NULL, "resource
|
||||
| Name | Type | Default | Nullable | Children | Parents | Comment |
|
||||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_run_snapshots](instance_ai_run_snapshots.md) | | |
|
||||
| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_run_snapshots](instance_ai_run_snapshots.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) | | |
|
||||
| metadata | TEXT | | true | | | |
|
||||
| projectId | varchar(36) | | false | | [project](project.md) | |
|
||||
| resourceId | varchar(255) | | false | | | |
|
||||
@@ -54,6 +54,7 @@ erDiagram
|
||||
"instance_ai_observations" }o--|| "instance_ai_threads" : "FOREIGN KEY (observationScopeId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_pending_confirmations" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_thread_grants" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
|
||||
"instance_ai_threads" {
|
||||
@@ -187,6 +188,13 @@ erDiagram
|
||||
TEXT tree
|
||||
datetime_3_ updatedAt
|
||||
}
|
||||
"instance_ai_thread_grants" {
|
||||
datetime_3_ createdAt
|
||||
varchar_512_ grantKey PK
|
||||
varchar threadId PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId PK
|
||||
}
|
||||
"project" {
|
||||
datetime_3_ createdAt
|
||||
varchar creatorId FK
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE "user" ("id" varchar PRIMARY KEY, "email" varchar(255), "firstName"
|
||||
| disabled | boolean | FALSE | false | | | |
|
||||
| email | varchar(255) | | true | | | |
|
||||
| firstName | varchar(32) | | true | | | |
|
||||
| id | varchar | | true | [agent_history](agent_history.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) | | |
|
||||
| id | varchar | | true | [agent_history](agent_history.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) | | |
|
||||
| lastActiveAt | date | | true | | | |
|
||||
| lastName | varchar(32) | | true | | | |
|
||||
| mfaEnabled | boolean | FALSE | false | | | |
|
||||
@@ -62,6 +62,7 @@ erDiagram
|
||||
"evaluation_collection" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||
"instance_ai_mcp_registry_connections" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_pending_confirmations" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_access_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_authorization_codes" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
"oauth_refresh_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||
@@ -192,6 +193,13 @@ erDiagram
|
||||
datetime_3_ updatedAt
|
||||
varchar userId FK
|
||||
}
|
||||
"instance_ai_thread_grants" {
|
||||
datetime_3_ createdAt
|
||||
varchar_512_ grantKey PK
|
||||
varchar threadId PK
|
||||
datetime_3_ updatedAt
|
||||
varchar userId PK
|
||||
}
|
||||
"oauth_access_tokens" {
|
||||
varchar clientId FK
|
||||
varchar token PK
|
||||
|
||||
+15
@@ -26,6 +26,12 @@ describe('InstanceAiConfirmRequestDto', () => {
|
||||
'approval deny with userInput (plan feedback)',
|
||||
{ kind: 'approval', approved: false, userInput: 'please revise step 3' },
|
||||
],
|
||||
// InstanceAiConfirmationPanel: handleAlwaysAllow ("allow for rest of session")
|
||||
[
|
||||
'approval approve with session scope',
|
||||
{ kind: 'approval', approved: true, scope: 'session' },
|
||||
],
|
||||
['approval approve with once scope', { kind: 'approval', approved: true, scope: 'once' }],
|
||||
// InstanceAiConfirmationPanel: handlePlanDeny (hard-reject the plan)
|
||||
['planDeny', { kind: 'planDeny' }],
|
||||
// InstanceAiConfirmationPanel: handleQuestionsSubmit
|
||||
@@ -117,6 +123,15 @@ describe('InstanceAiConfirmRequestDto', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('approval with an unknown scope', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({
|
||||
kind: 'approval',
|
||||
approved: true,
|
||||
scope: 'forever',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('questions without answers array', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'questions' });
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
@@ -15,6 +15,9 @@ const approvalConfirmSchema = z.object({
|
||||
kind: z.literal('approval'),
|
||||
approved: z.boolean(),
|
||||
userInput: z.string().optional(),
|
||||
/** `'session'` grants the same tool/action without re-asking for the rest of the
|
||||
* thread ("always allow"). Absent/`'once'` approves this single request only. */
|
||||
scope: z.enum(['once', 'session']).optional(),
|
||||
});
|
||||
|
||||
/** Q&A wizard submission (inputType='questions'). */
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const table = 'instance_ai_thread_grants';
|
||||
|
||||
export class CreateInstanceAiThreadGrantTable1784000000035 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(table)
|
||||
.withColumns(
|
||||
column('threadId').uuid.primary,
|
||||
column('userId').uuid.primary,
|
||||
column('grantKey')
|
||||
.varchar(512)
|
||||
.primary.comment(
|
||||
'Namespaced "always allow" grant the user approved for the thread, e.g. "executions:run". ' +
|
||||
'Wide enough to hold a namespace prefix plus a resource identifier.',
|
||||
),
|
||||
)
|
||||
// `threadId` is the PK prefix (already indexed); index `userId` for its cascade.
|
||||
.withIndexOn(['userId'])
|
||||
.withForeignKey('threadId', {
|
||||
tableName: 'instance_ai_threads',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('userId', {
|
||||
tableName: 'user',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(table);
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,7 @@ import { CreateAgentChatSubscriptions1784000000030 } from '../common/17840000000
|
||||
import { AddBinaryDataSizeBytesToExecutionEntity1784000000033 } from '../common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity';
|
||||
import { AllowAzureStoredAt1784000000034 } from '../common/1784000000034-AllowAzureStoredAt';
|
||||
import type { Migration } from '../migration-types';
|
||||
import { CreateInstanceAiThreadGrantTable1784000000035 } from '../common/1784000000035-CreateInstanceAiThreadGrantTable';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
InitialMigration1587669153312,
|
||||
@@ -421,4 +422,5 @@ export const postgresMigrations: Migration[] = [
|
||||
AddExecutionEntityWorkflowStatusIndex1784000000031,
|
||||
AddBinaryDataSizeBytesToExecutionEntity1784000000033,
|
||||
AllowAzureStoredAt1784000000034,
|
||||
CreateInstanceAiThreadGrantTable1784000000035,
|
||||
];
|
||||
|
||||
@@ -201,6 +201,7 @@ import { CreateAgentTaskDefinitionTable1784000000021 } from './1784000000021-Cre
|
||||
import { MigrateRedactionEnforcementToFloor1784000000025 } from '../common/1784000000025-MigrateRedactionEnforcementToFloor';
|
||||
import { AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity1784000000029 } from '../common/1784000000029-AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity';
|
||||
import { AddBinaryDataSizeBytesToExecutionEntity1784000000033 } from '../common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity';
|
||||
import { CreateInstanceAiThreadGrantTable1784000000035 } from '../common/1784000000035-CreateInstanceAiThreadGrantTable';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
@@ -405,6 +406,7 @@ const sqliteMigrations: Migration[] = [
|
||||
CreateAgentChatSubscriptions1784000000030,
|
||||
AddBinaryDataSizeBytesToExecutionEntity1784000000033,
|
||||
AllowAzureStoredAt1784000000034,
|
||||
CreateInstanceAiThreadGrantTable1784000000035,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface ConfirmationData {
|
||||
resourceDecision?: string;
|
||||
/** Plan-review hard denial — distinct from a feedback-driven rejection. */
|
||||
denied?: boolean;
|
||||
/** `'session'` means the user chose "always allow": the resuming tool should
|
||||
* persist a thread-level grant so the same action isn't re-asked. */
|
||||
scope?: 'once' | 'session';
|
||||
}
|
||||
|
||||
export interface PendingConfirmation {
|
||||
|
||||
@@ -291,6 +291,103 @@ describe('executions tool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('session grant (always allow)', () => {
|
||||
it('runs without HITL when the session grant key is present', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: {},
|
||||
sessionApprovedToolKeys: new Set(['executions:run']),
|
||||
});
|
||||
(context.executionService.run as Mock).mockResolvedValue({
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createExecutionsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
{ action: 'run' as const, workflowId: 'wf-1' },
|
||||
createAgentCtx({ suspend: suspendFn }) as never,
|
||||
);
|
||||
|
||||
expect(suspendFn).not.toHaveBeenCalled();
|
||||
expect(context.executionService.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still requires HITL when an unrelated key is granted', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: {},
|
||||
sessionApprovedToolKeys: new Set(['some-other-tool:action']),
|
||||
});
|
||||
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createExecutionsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
{ action: 'run' as const, workflowId: 'wf-1' },
|
||||
createAgentCtx({ suspend: suspendFn }) as never,
|
||||
);
|
||||
|
||||
expect(suspendFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admin requireRunWorkflowApproval overrides the session grant', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: {},
|
||||
sessionApprovedToolKeys: new Set(['executions:run']),
|
||||
requireRunWorkflowApproval: true,
|
||||
});
|
||||
|
||||
const suspendFn = vi.fn();
|
||||
const tool = createExecutionsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
{ action: 'run' as const, workflowId: 'wf-1' },
|
||||
createAgentCtx({ suspend: suspendFn }) as never,
|
||||
);
|
||||
|
||||
expect(suspendFn).toHaveBeenCalled();
|
||||
expect(context.executionService.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists a grant when resumed with scope=session', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const context = createMockContext({ permissions: {}, grantSessionToolApproval });
|
||||
(context.executionService.run as Mock).mockResolvedValue({
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
const tool = createExecutionsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
{ action: 'run' as const, workflowId: 'wf-1' },
|
||||
createAgentCtx({ resumeData: { approved: true, scope: 'session' } }) as never,
|
||||
);
|
||||
|
||||
expect(grantSessionToolApproval).toHaveBeenCalledWith('executions:run');
|
||||
expect(context.executionService.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not persist a grant when resumed with a one-time approval', async () => {
|
||||
const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const context = createMockContext({ permissions: {}, grantSessionToolApproval });
|
||||
(context.executionService.run as Mock).mockResolvedValue({
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
const tool = createExecutionsTool(context);
|
||||
await executeTool(
|
||||
tool,
|
||||
{ action: 'run' as const, workflowId: 'wf-1' },
|
||||
createAgentCtx({ resumeData: { approved: true } }) as never,
|
||||
);
|
||||
|
||||
expect(grantSessionToolApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowedRunWorkflowIds scope', () => {
|
||||
it('runs without HITL when always_allow + workflow id is in the allow-list', async () => {
|
||||
const context = createMockContext({
|
||||
|
||||
@@ -14,6 +14,10 @@ import type { InstanceAiContext } from '../types';
|
||||
|
||||
const MAX_TIMEOUT_MS = 600_000;
|
||||
|
||||
/** Thread-level "always allow" grant key for `executions(action="run")`. Must match the
|
||||
* key the frontend builds (`<toolName>:<action>`) so a UI grant lines up with the backend. */
|
||||
const RUN_WORKFLOW_GRANT_KEY = 'executions:run';
|
||||
|
||||
// ── Action schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
const listAction = z.object({
|
||||
@@ -143,6 +147,9 @@ const suspendSchema = z.object({
|
||||
|
||||
const resumeSchema = z.object({
|
||||
approved: z.boolean(),
|
||||
/** `'session'` — the user chose "always allow"; persist a thread-level grant so
|
||||
* subsequent runs skip HITL for this action. */
|
||||
scope: z.enum(['once', 'session']).optional(),
|
||||
});
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
@@ -244,7 +251,12 @@ async function handleRun(
|
||||
context.requireRunWorkflowApproval !== true &&
|
||||
context.permissions?.runWorkflow === 'always_allow' &&
|
||||
(allowList === undefined || allowList.has(workflowId) || allowedByName);
|
||||
const needsApproval = !allowedByScope;
|
||||
// A thread-level "always allow" grant skips HITL for the rest of the session, but an
|
||||
// admin's `requireRunWorkflowApproval` always wins — same gate as `allowedByScope`.
|
||||
const allowedBySessionGrant =
|
||||
context.requireRunWorkflowApproval !== true &&
|
||||
context.sessionApprovedToolKeys?.has(RUN_WORKFLOW_GRANT_KEY) === true;
|
||||
const needsApproval = !allowedByScope && !allowedBySessionGrant;
|
||||
|
||||
// If approval is required and this is the first call, suspend for confirmation
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
@@ -266,6 +278,11 @@ async function handleRun(
|
||||
};
|
||||
}
|
||||
|
||||
// "Always allow" — persist the grant so subsequent runs skip HITL for this action.
|
||||
if (resumeData?.approved && resumeData.scope === 'session') {
|
||||
await context.grantSessionToolApproval?.(RUN_WORKFLOW_GRANT_KEY);
|
||||
}
|
||||
|
||||
// Approved or always_allow — execute
|
||||
return await context.executionService.run(workflowId, input.inputData, {
|
||||
timeout: input.timeout,
|
||||
|
||||
@@ -761,6 +761,13 @@ export interface InstanceAiContext {
|
||||
allowedRunWorkflowNames?: ReadonlySet<string>;
|
||||
/** Force `executions(action="run")` through HITL even when a scoped checkpoint override exists. */
|
||||
requireRunWorkflowApproval?: boolean;
|
||||
/** Thread-level "always allow" grants the user has approved (keys like `executions:run`).
|
||||
* Loaded per run from persisted thread state so a grant survives reload/navigation and
|
||||
* is visible across mains. Tools consult this to skip HITL for already-granted actions. */
|
||||
sessionApprovedToolKeys?: ReadonlySet<string>;
|
||||
/** Persist a thread-level "always allow" grant for the given key. Invoked by a tool when it
|
||||
* resumes from a `scope: 'session'` approval. No-op in contexts without persistence. */
|
||||
grantSessionToolApproval?: (key: string) => Promise<void>;
|
||||
/** When true, the instance is in read-only mode (source control branchReadOnly). */
|
||||
branchReadOnly?: boolean;
|
||||
/** When `false`, callers must avoid surfacing node parameter values (or anything derived from them
|
||||
|
||||
@@ -830,6 +830,7 @@ describe('InstanceAiService — runtime workspace setup', () => {
|
||||
sendCorrectionToTask: jest.Mock;
|
||||
sandboxService: InstanceAiSandboxService;
|
||||
domainAccessTrackersByThread: Map<string, unknown>;
|
||||
threadGrantRepo: { findKeys: jest.Mock };
|
||||
};
|
||||
service.settingsService = {
|
||||
getAdminSettings: jest.fn(() => ({ localGatewayDisabled: false, sandboxEnabled: true })),
|
||||
@@ -875,6 +876,7 @@ describe('InstanceAiService — runtime workspace setup', () => {
|
||||
service.schedulePlannedTasks = jest.fn();
|
||||
service.sendCorrectionToTask = jest.fn();
|
||||
service.domainAccessTrackersByThread = new Map();
|
||||
service.threadGrantRepo = { findKeys: jest.fn(async () => new Set<string>()) };
|
||||
service.sandboxService = new InstanceAiSandboxService({
|
||||
config: { sandboxEnabled: true, sandboxProvider: 'daytona' } as InstanceAiConfig,
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
|
||||
@@ -8,6 +8,7 @@ export { InstanceAiObservation } from './instance-ai-observation.entity';
|
||||
export { InstanceAiObservationCursor } from './instance-ai-observation-cursor.entity';
|
||||
export { InstanceAiObservationLock } from './instance-ai-observation-lock.entity';
|
||||
export { InstanceAiMcpRegistryConnection } from './instance-ai-mcp-registry-connection.entity';
|
||||
export { InstanceAiThreadGrant } from './instance-ai-thread-grant.entity';
|
||||
export type {
|
||||
InstanceAiObservationMarker,
|
||||
InstanceAiObservationStatus,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { User, WithTimestamps } from '@n8n/db';
|
||||
import { Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
|
||||
|
||||
import { InstanceAiThread } from './instance-ai-thread.entity';
|
||||
|
||||
/**
|
||||
* Durable "always allow" grants the user has approved within a thread — e.g.
|
||||
* `executions:run` records "execute workflows without re-asking for the rest of
|
||||
* this session". Persisting here (rather than in-memory) means a grant survives
|
||||
* reload/navigation and is visible across mains.
|
||||
*
|
||||
* Keyed by `(threadId, userId, grantKey)`: grants are per-user so a future
|
||||
* shared thread doesn't leak one participant's approval to another. `grantKey`
|
||||
* is a namespaced action string the granting tool owns (e.g. `executions:run`);
|
||||
* the namespace leaves room for other gated actions (domain access, etc.) to
|
||||
* move onto this table later.
|
||||
*/
|
||||
@Entity({ name: 'instance_ai_thread_grants' })
|
||||
export class InstanceAiThreadGrant extends WithTimestamps {
|
||||
// `threadId` is the composite-PK prefix, so it's already indexed for the thread cascade.
|
||||
@ManyToOne(() => InstanceAiThread, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'threadId' })
|
||||
thread: InstanceAiThread;
|
||||
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
threadId: string;
|
||||
|
||||
// `userId` isn't the PK prefix, so index it explicitly for the user cascade.
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
// Wide enough for a namespace prefix plus a resource identifier.
|
||||
@PrimaryColumn({ type: 'varchar', length: 512 })
|
||||
grantKey: string;
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export class InstanceAiModule implements ModuleInterface {
|
||||
const { InstanceAiMcpRegistryConnection } = await import(
|
||||
'./entities/instance-ai-mcp-registry-connection.entity'
|
||||
);
|
||||
const { InstanceAiThreadGrant } = await import('./entities/instance-ai-thread-grant.entity');
|
||||
|
||||
return [
|
||||
InstanceAiThread,
|
||||
@@ -81,6 +82,7 @@ export class InstanceAiModule implements ModuleInterface {
|
||||
InstanceAiObservationCursor,
|
||||
InstanceAiObservationLock,
|
||||
InstanceAiMcpRegistryConnection,
|
||||
InstanceAiThreadGrant,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ import { AUTO_FOLLOW_UP_MESSAGE, withCurrentDateTime } from './internal-messages
|
||||
import { INSTANCE_AI_RUN_TIMEOUT_REASON, InstanceAiLivenessService } from './liveness';
|
||||
import { InstanceAiMcpRegistryService } from './mcp';
|
||||
import { InstanceAiPendingConfirmationRepository } from './repositories/instance-ai-pending-confirmation.repository';
|
||||
import { InstanceAiThreadGrantRepository } from './repositories/instance-ai-thread-grant.repository';
|
||||
import { InstanceAiThreadRepository } from './repositories/instance-ai-thread.repository';
|
||||
import {
|
||||
buildInstanceAiObservabilityContext,
|
||||
@@ -393,7 +394,7 @@ type OrchestratorResumeReason =
|
||||
function toConfirmationData(request: InstanceAiConfirmRequest): ConfirmationData {
|
||||
switch (request.kind) {
|
||||
case 'approval':
|
||||
return { approved: request.approved, userInput: request.userInput };
|
||||
return { approved: request.approved, userInput: request.userInput, scope: request.scope };
|
||||
case 'domainAccessApprove':
|
||||
return { approved: true, domainAccessAction: request.domainAccessAction };
|
||||
case 'domainAccessDeny':
|
||||
@@ -570,6 +571,7 @@ export class InstanceAiService {
|
||||
private readonly aiService: AiService,
|
||||
private readonly push: Push,
|
||||
private readonly threadRepo: InstanceAiThreadRepository,
|
||||
private readonly threadGrantRepo: InstanceAiThreadGrantRepository,
|
||||
private readonly pendingConfirmationRepo: InstanceAiPendingConfirmationRepository,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly dbSnapshotStorage: DbSnapshotStorage,
|
||||
@@ -872,6 +874,47 @@ export class InstanceAiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user's persisted "always allow" grants for a thread (keys like `executions:run`).
|
||||
* Persisted in `instance_ai_thread_grants` so they survive reload/navigation and are visible
|
||||
* across mains. Returns an empty set on any read error — a missing grant just re-asks, which
|
||||
* is safe.
|
||||
*/
|
||||
private async loadThreadSessionGrants(
|
||||
threadId: string,
|
||||
userId: string,
|
||||
): Promise<ReadonlySet<string>> {
|
||||
try {
|
||||
return await this.threadGrantRepo.findKeys(threadId, userId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to load Instance AI session grants', {
|
||||
threadId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a per-user, thread-level "always allow" grant. Idempotent across mains via the
|
||||
* composite PK. Best-effort — a failed write just means the user is re-asked next run.
|
||||
*/
|
||||
private async persistThreadSessionGrant(
|
||||
threadId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.threadGrantRepo.grant(threadId, userId, key);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to persist Instance AI session grant', {
|
||||
threadId,
|
||||
key,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the AI service proxy is enabled for credit counting. */
|
||||
isProxyEnabled(): boolean {
|
||||
return this.aiService.isProxyEnabled();
|
||||
@@ -2666,6 +2709,13 @@ export class InstanceAiService {
|
||||
}
|
||||
context.domainAccessTracker = domainTracker;
|
||||
context.runId = runId;
|
||||
|
||||
// Per-user, thread-level "always allow" grants are persisted in the DB so they survive
|
||||
// reload/navigation and are visible across mains. Load once per run; a tool resuming
|
||||
// from a `scope: 'session'` approval persists new grants via `grantSessionToolApproval`.
|
||||
context.sessionApprovedToolKeys = await this.loadThreadSessionGrants(threadId, user.id);
|
||||
context.grantSessionToolApproval = async (key: string) =>
|
||||
await this.persistThreadSessionGrant(threadId, user.id, key);
|
||||
if (this.isRunDebugEnabled()) {
|
||||
context.recordWorkflowCodeSnapshot = (snapshot) => {
|
||||
this.runDebugBuffer.ensure(runId, threadId);
|
||||
@@ -4690,6 +4740,7 @@ export class InstanceAiService {
|
||||
...(data.testTriggerNode ? { testTriggerNode: data.testTriggerNode } : {}),
|
||||
...(data.answers ? { answers: data.answers } : {}),
|
||||
...(data.resourceDecision ? { resourceDecision: data.resourceDecision } : {}),
|
||||
...(data.scope ? { scope: data.scope } : {}),
|
||||
};
|
||||
|
||||
const resumeTracing = await this.createOrchestratorResumeTraceContext({
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { InsertQueryBuilder } from '@n8n/typeorm';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { InstanceAiThreadGrant } from '../../entities/instance-ai-thread-grant.entity';
|
||||
import { InstanceAiThreadGrantRepository } from '../instance-ai-thread-grant.repository';
|
||||
|
||||
function buildRepo() {
|
||||
return Object.create(
|
||||
InstanceAiThreadGrantRepository.prototype,
|
||||
) as InstanceAiThreadGrantRepository;
|
||||
}
|
||||
|
||||
describe('InstanceAiThreadGrantRepository', () => {
|
||||
describe('grant', () => {
|
||||
it('inserts the grant and ignores duplicates', async () => {
|
||||
const repo = buildRepo();
|
||||
const qb = mock<InsertQueryBuilder<InstanceAiThreadGrant>>();
|
||||
qb.insert.mockReturnThis();
|
||||
qb.values.mockReturnThis();
|
||||
qb.orIgnore.mockReturnThis();
|
||||
repo.createQueryBuilder = jest.fn().mockReturnValue(qb);
|
||||
|
||||
await repo.grant('thread-1', 'user-1', 'executions:run');
|
||||
|
||||
expect(qb.values).toHaveBeenCalledWith({
|
||||
threadId: 'thread-1',
|
||||
userId: 'user-1',
|
||||
grantKey: 'executions:run',
|
||||
});
|
||||
expect(qb.orIgnore).toHaveBeenCalled();
|
||||
expect(qb.execute).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findKeys', () => {
|
||||
it('returns the grant keys for the thread/user as a set', async () => {
|
||||
const repo = buildRepo();
|
||||
repo.find = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ grantKey: 'executions:run' }, { grantKey: 'domain:example.com' }]);
|
||||
|
||||
const keys = await repo.findKeys('thread-1', 'user-1');
|
||||
|
||||
expect(repo.find).toHaveBeenCalledWith({
|
||||
where: { threadId: 'thread-1', userId: 'user-1' },
|
||||
select: ['grantKey'],
|
||||
});
|
||||
expect(keys).toEqual(new Set(['executions:run', 'domain:example.com']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,3 +8,4 @@ export { InstanceAiObservationRepository } from './instance-ai-observation.repos
|
||||
export { InstanceAiObservationCursorRepository } from './instance-ai-observation-cursor.repository';
|
||||
export { InstanceAiObservationLockRepository } from './instance-ai-observation-lock.repository';
|
||||
export { InstanceAiMcpRegistryConnectionRepository } from './instance-ai-mcp-registry-connection.repository';
|
||||
export { InstanceAiThreadGrantRepository } from './instance-ai-thread-grant.repository';
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { InstanceAiThreadGrant } from '../entities/instance-ai-thread-grant.entity';
|
||||
|
||||
@Service()
|
||||
export class InstanceAiThreadGrantRepository extends Repository<InstanceAiThreadGrant> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(InstanceAiThreadGrant, dataSource.manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an "always allow" grant. Idempotent across mains via the composite
|
||||
* PK — a concurrent duplicate is ignored rather than erroring.
|
||||
*/
|
||||
async grant(threadId: string, userId: string, grantKey: string): Promise<void> {
|
||||
await this.createQueryBuilder()
|
||||
.insert()
|
||||
.values({ threadId, userId, grantKey })
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** The grant keys this user holds in this thread. */
|
||||
async findKeys(threadId: string, userId: string): Promise<Set<string>> {
|
||||
const rows = await this.find({
|
||||
where: { threadId, userId },
|
||||
select: ['grantKey'],
|
||||
});
|
||||
return new Set(rows.map((row) => row.grantKey));
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -242,12 +242,17 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
|
||||
},
|
||||
{ action: 'run' },
|
||||
);
|
||||
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
|
||||
const addKeySpy = vi.spyOn(thread, 'addAlwaysAllowKey');
|
||||
|
||||
const { getByTestId } = renderComponent({ props: { kind: 'floating' } });
|
||||
await userEvent.click(getByTestId('instance-ai-panel-confirm-always-allow'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-always', {
|
||||
kind: 'approval',
|
||||
approved: true,
|
||||
scope: 'session',
|
||||
});
|
||||
expect(addKeySpy).toHaveBeenCalledWith('test-tool', { action: 'run' });
|
||||
expect(mockTelemetryTrack).toHaveBeenCalledWith(
|
||||
'User finished providing input',
|
||||
|
||||
+5
-1
@@ -256,7 +256,11 @@ async function handleAlwaysAllow(item: PendingConfirmationItem) {
|
||||
// failed POST would otherwise hide the card while the backend keeps
|
||||
// waiting, AND seed an auto-approve key the watcher would use to
|
||||
// silently approve later matching confirmations.
|
||||
const ok = await thread.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
||||
const ok = await thread.confirmAction(conf.requestId, {
|
||||
kind: 'approval',
|
||||
approved: true,
|
||||
scope: 'session',
|
||||
});
|
||||
if (!ok) return;
|
||||
thread.addAlwaysAllowKey(item.toolCall.toolName, item.toolCall.args ?? {});
|
||||
trackInputCompleted(
|
||||
|
||||
Reference in New Issue
Block a user