Ask for a personal storage connection from the vault
A storage step on the runner's own connection needs it before the execution starts. The gate now takes any requirement the server marks as paid from the vault (vaultBacked), with the key prefixes as a fallback, so the S3 or PostgreSQL connection shows up beside the LLM credentials. Adding one opens a form with the storage type's own fields, from /storage/types - endpoint, bucket, keys, or host, database, user - and saves them together as the JSON the server encrypts whole. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9639b2bfe8
commit
3abcecb944
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
/** One field of a person's own connection to a storage, as the vault form asks for it. */
|
||||
export type StorageCredentialField = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
/** Masked in the form. The whole connection is encrypted either way. */
|
||||
secret: boolean;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/** A kind of storage a flow can use: an S3-compatible object store, a PostgreSQL database. */
|
||||
export type StorageType = {
|
||||
name: string;
|
||||
description: string;
|
||||
/** What a read returns (FILES, TEXTS, JSON, KEYS), the default first. */
|
||||
viewShapes: string[];
|
||||
/** The kinds of value a write accepts (FILE, TEXT, JSON). */
|
||||
writableKinds: string[];
|
||||
credentialFields: StorageCredentialField[];
|
||||
};
|
||||
|
|
@ -100,6 +100,8 @@ export type TaskExecutionAuthorizationRequirement = {
|
|||
fieldName: string;
|
||||
description: string;
|
||||
requiredBySteps: string[];
|
||||
/** Paid with a saved credential's id - an LLM key, a person's own storage connection. */
|
||||
vaultBacked?: boolean;
|
||||
};
|
||||
|
||||
export type TaskExecutionContext = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { StorageType } from '@models/storage-type';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class StorageTypesCallServiceBase {
|
||||
abstract listTypes(): Observable<StorageType[]>;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { StorageType } from '@models/storage-type';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { StorageTypesCallServiceBase } from './storage-types-call.base';
|
||||
|
||||
export class StorageTypesCallServiceFake extends StorageTypesCallServiceBase {
|
||||
override listTypes(): Observable<StorageType[]> {
|
||||
return of([
|
||||
{
|
||||
name: 'S3',
|
||||
description: 'An S3-compatible object store.',
|
||||
viewShapes: ['FILES', 'TEXTS', 'JSON', 'KEYS'],
|
||||
writableKinds: ['FILE', 'TEXT', 'JSON'],
|
||||
credentialFields: [
|
||||
{ key: 'endpoint', label: 'Endpoint URL', description: 'e.g. https://minio.example.org', secret: false, required: true },
|
||||
{ key: 'bucket', label: 'Bucket', secret: false, required: true },
|
||||
{ key: 'accessKey', label: 'Access key', secret: false, required: true },
|
||||
{ key: 'secretKey', label: 'Secret key', secret: true, required: true }
|
||||
]
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { StorageCredentialField, StorageType } from '@models/storage-type';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { StorageTypesCallServiceBase } from './storage-types-call.base';
|
||||
|
||||
export class StorageTypesCallService extends StorageTypesCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
override listTypes(): Observable<StorageType[]> {
|
||||
return this.http.get<unknown>(`${environment.apiUrl}/storage/types`).pipe(
|
||||
map((raw) => (Array.isArray(raw) ? raw : [])
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
name: String(item['name'] ?? '').trim(),
|
||||
description: String(item['description'] ?? ''),
|
||||
viewShapes: strings(item['viewShapes']),
|
||||
writableKinds: strings(item['writableKinds']),
|
||||
credentialFields: (Array.isArray(item['credentialFields']) ? item['credentialFields'] : [])
|
||||
.filter((field): field is Record<string, unknown> => !!field && typeof field === 'object')
|
||||
.map((field): StorageCredentialField => ({
|
||||
key: String(field['key'] ?? '').trim(),
|
||||
label: String(field['label'] ?? field['key'] ?? ''),
|
||||
description: typeof field['description'] === 'string' ? field['description'] : null,
|
||||
secret: field['secret'] === true,
|
||||
required: field['required'] === true
|
||||
}))
|
||||
.filter((field) => field.key.length > 0)
|
||||
}))
|
||||
.filter((type) => type.name.length > 0))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { StorageType } from '@models/storage-type';
|
||||
import { Observable, shareReplay } from 'rxjs';
|
||||
import { StorageTypesCallServiceBase } from './storage-types-call.base';
|
||||
|
||||
/** The storage types the server has. They change only with a deploy, so they are fetched once. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StorageTypesService {
|
||||
private readonly call: StorageTypesCallServiceBase = new environment.storageTypesCallService();
|
||||
private types$?: Observable<StorageType[]>;
|
||||
|
||||
listTypes(): Observable<StorageType[]> {
|
||||
this.types$ ??= this.call.listTypes().pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
return this.types$;
|
||||
}
|
||||
}
|
||||
|
|
@ -199,6 +199,36 @@ describe('authorization gate', () => {
|
|||
expect(isExecutionStartable(provided, gate)).toBe(true);
|
||||
});
|
||||
|
||||
it('asks for a personal storage connection from the vault, not as a value to type in', () => {
|
||||
const storageRequirement = {
|
||||
key: 'StorageType::S3::credential',
|
||||
provider: 'S3',
|
||||
fieldName: 'credential',
|
||||
description: 'Select a saved S3 connection.',
|
||||
requiredBySteps: ['save-note'],
|
||||
vaultBacked: true
|
||||
};
|
||||
const gate = buildAuthorizationGate(execution({
|
||||
requiredAuthorizations: [storageRequirement, headerRequirement],
|
||||
missingAuthorizationKeys: [storageRequirement.key, headerRequirement.key]
|
||||
}), readyState);
|
||||
|
||||
expect(gate.vault.map((entry) => entry.provider)).toEqual(['S3']);
|
||||
expect(gate.runtime.map((requirement) => requirement.key)).toEqual([headerRequirement.key]);
|
||||
expect(gate.missingProviders).toEqual(['S3']);
|
||||
});
|
||||
|
||||
it('trusts the vault flag over the shape of the key', () => {
|
||||
const flagged = { ...headerRequirement, key: 'Custom::thing', vaultBacked: true };
|
||||
const gate = buildAuthorizationGate(execution({
|
||||
requiredAuthorizations: [flagged],
|
||||
missingAuthorizationKeys: [flagged.key]
|
||||
}), readyState);
|
||||
|
||||
expect(gate.vault.length).toBe(1);
|
||||
expect(gate.runtime).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a provider key on the vault path even when the catalog is unavailable', () => {
|
||||
const gate = buildAuthorizationGate(execution(), failedState);
|
||||
|
||||
|
|
|
|||
|
|
@ -588,10 +588,23 @@ export function authorizationProvider(requirement: TaskExecutionAuthorizationReq
|
|||
return parts.length > 1 ? parts[1].trim() : provider;
|
||||
}
|
||||
|
||||
/** Keys of the form `StorageType::<type>::credential`: a person's own storage connection. */
|
||||
export const STORAGE_AUTHORIZATION_KEY_PREFIX = 'storagetype::';
|
||||
|
||||
export function isLlmAuthorizationRequirement(requirement: TaskExecutionAuthorizationRequirement): boolean {
|
||||
return String(requirement.key ?? '').trim().toLowerCase().startsWith(LLM_AUTHORIZATION_KEY_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paid with the id of a saved credential rather than a value typed in. The server says so; the key
|
||||
* prefixes are for a server old enough not to.
|
||||
*/
|
||||
export function isVaultAuthorizationRequirement(requirement: TaskExecutionAuthorizationRequirement): boolean {
|
||||
if (requirement.vaultBacked === true) return true;
|
||||
const key = String(requirement.key ?? '').trim().toLowerCase();
|
||||
return key.startsWith(LLM_AUTHORIZATION_KEY_PREFIX) || key.startsWith(STORAGE_AUTHORIZATION_KEY_PREFIX);
|
||||
}
|
||||
|
||||
export function listAuthorizationRequirements(
|
||||
execution: TaskExecution | null | undefined
|
||||
): TaskExecutionAuthorizationRequirement[] {
|
||||
|
|
@ -622,7 +635,7 @@ export function buildAuthorizationGate(
|
|||
|
||||
// A provider key is never payable with a literal value, whatever the catalog says
|
||||
// or fails to say, so it can only ever go down the vault path.
|
||||
if (isLlmAuthorizationRequirement(requirement)) {
|
||||
if (isVaultAuthorizationRequirement(requirement)) {
|
||||
const provider = authorizationProvider(requirement);
|
||||
const entry: VaultAuthorizationEntry = {
|
||||
requirement,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ import { BlocksService } from '@services/blocks/blocks';
|
|||
import { LlmProviderService } from '@services/llm-provider/llm-provider';
|
||||
import { ExecutionVaultCredentialsService } from '@services/llm-provider/execution-vault-credentials';
|
||||
import { VaultService } from '@services/vault/vault';
|
||||
import { StorageTypesService } from '@services/storage/storage-types';
|
||||
import { StorageType } from '@models/storage-type';
|
||||
import { extractHttpErrorMessage } from '@services/shared/http-error.util';
|
||||
import {
|
||||
BiasRerunDialogService,
|
||||
|
|
@ -159,6 +161,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
private llmProviderService = inject(LlmProviderService);
|
||||
private executionVaultCredentials = inject(ExecutionVaultCredentialsService);
|
||||
private vaultService = inject(VaultService);
|
||||
private storageTypesService = inject(StorageTypesService);
|
||||
private biasRerunDialog = inject(BiasRerunDialogService);
|
||||
private biasCompareDialog = inject(BiasCompareDialogService);
|
||||
private biasComparisonViewState = inject(BiasComparisonViewStateService);
|
||||
|
|
@ -241,6 +244,8 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
readonly savingAuthorizations = signal<Record<string, boolean>>({});
|
||||
readonly authorizationErrors = signal<Record<string, string>>({});
|
||||
readonly llmProviderCapabilities = signal<LlmProviderCapability[]>([]);
|
||||
/** For a person's own storage connection: which fields its form asks for. */
|
||||
readonly storageTypes = signal<StorageType[]>([]);
|
||||
readonly llmProviderCapabilitiesLoading = signal(false);
|
||||
readonly llmProviderCapabilitiesError = signal<string | null>(null);
|
||||
readonly llmCredentialOptions = signal<Record<string, ExecutionVaultCredential[]>>({});
|
||||
|
|
@ -1099,6 +1104,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
*/
|
||||
async openVaultCredentialForm(provider: string) {
|
||||
this.credentialFormError.set(null);
|
||||
const storageType = this.storageTypeNamed(provider);
|
||||
if (storageType) {
|
||||
await this.openStorageConnectionForm(storageType);
|
||||
return;
|
||||
}
|
||||
|
||||
// The provider is fixed by the requirement being answered, so it is shown rather than chosen.
|
||||
const fields: NodeSettingField[] = [
|
||||
|
|
@ -1141,6 +1151,48 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
);
|
||||
}
|
||||
|
||||
private storageTypeNamed(provider: string): StorageType | undefined {
|
||||
const wanted = provider.trim().toLowerCase();
|
||||
return this.storageTypes().find((type) => type.name.trim().toLowerCase() === wanted);
|
||||
}
|
||||
|
||||
/**
|
||||
* A storage connection is several fields - an endpoint, a bucket, two keys - saved together as
|
||||
* the JSON of them, encrypted whole. The server works out the address it shows beside it.
|
||||
*/
|
||||
private async openStorageConnectionForm(type: StorageType) {
|
||||
const fieldKey = (key: string) => `connection_${key}`;
|
||||
const fields: NodeSettingField[] = [
|
||||
{ key: 'provider', label: 'Storage type', type: 'display' },
|
||||
{ key: 'label', label: 'Label', type: 'text', required: true, autofocus: true },
|
||||
{ key: 'description', label: 'Description (optional)', type: 'text' },
|
||||
...type.credentialFields.map((field): NodeSettingField => ({
|
||||
key: fieldKey(field.key),
|
||||
label: field.label,
|
||||
type: field.secret ? 'password' : 'text',
|
||||
required: field.required,
|
||||
tip: field.description ?? undefined
|
||||
}))
|
||||
];
|
||||
const initial: Record<string, string> = { provider: type.name, label: '', description: '' };
|
||||
type.credentialFields.forEach((field) => initial[fieldKey(field.key)] = '');
|
||||
const result = await this.settingsDialog.open({ title: `Add your ${type.name} connection`, fields, initial });
|
||||
if (!result) return;
|
||||
|
||||
const connection: Record<string, string> = {};
|
||||
for (const field of type.credentialFields) {
|
||||
const value = String(result[fieldKey(field.key)] ?? '').trim();
|
||||
if (value) connection[field.key] = value;
|
||||
}
|
||||
this.saveExecutionCredential(
|
||||
type.name,
|
||||
String(result['label'] ?? '').trim(),
|
||||
String(result['description'] ?? '').trim(),
|
||||
JSON.stringify(connection),
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private providerRequiresEndpoint(provider: string): boolean {
|
||||
const wanted = provider.trim().toLowerCase();
|
||||
return this.llmProviderCapabilities().find((capability) =>
|
||||
|
|
@ -1179,8 +1231,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
private loadStorageTypes() {
|
||||
this.storageTypesService.listTypes().pipe(take(1)).subscribe({
|
||||
next: (types) => this.storageTypes.set(types),
|
||||
// Without them an own storage connection falls back to the plain credential form, whose
|
||||
// save the server then refuses with the reason - worse, but not silent.
|
||||
error: () => this.storageTypes.set([])
|
||||
});
|
||||
}
|
||||
|
||||
private loadLlmProviderCapabilities() {
|
||||
if (!this.execution()?.id) return;
|
||||
this.loadStorageTypes();
|
||||
this.llmProviderCapabilitiesLoading.set(true);
|
||||
this.llmProviderCapabilitiesError.set(null);
|
||||
this.llmProviderService.listCapabilities().pipe(take(1)).subscribe({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { StorageTypesCallServiceFake } from "@services/storage/storage-types-call.fake";
|
||||
import { AdminCallFakeService } from "@services/admin/admin-call.fake";
|
||||
import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fake";
|
||||
import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake";
|
||||
|
|
@ -32,5 +33,6 @@ export const environment = {
|
|||
taskExecutionsCallService: TaskExecutionsCallServiceFake,
|
||||
llmProviderCallService: LlmProviderCallServiceFake,
|
||||
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallServiceFake,
|
||||
vaultCallService: VaultCallServiceFake
|
||||
vaultCallService: VaultCallServiceFake,
|
||||
storageTypesCallService: StorageTypesCallServiceFake
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { StorageTypesCallService } from "@services/storage/storage-types-call";
|
||||
import { AdminCallService } from "@services/admin/admin-call";
|
||||
import { AssistantCallService } from "@services/assistant/assistant-call";
|
||||
import { AuthorizationCallService } from "@services/authorization/authorization-call";
|
||||
|
|
@ -32,5 +33,6 @@ export const environment = {
|
|||
taskExecutionsCallService: TaskExecutionsCallService,
|
||||
llmProviderCallService: LlmProviderCallService,
|
||||
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService,
|
||||
vaultCallService: VaultCallService
|
||||
vaultCallService: VaultCallService,
|
||||
storageTypesCallService: StorageTypesCallService
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { StorageTypesCallService } from "@services/storage/storage-types-call";
|
||||
import { AdminCallService } from "@services/admin/admin-call";
|
||||
import { AssistantCallService } from "@services/assistant/assistant-call";
|
||||
import { AuthorizationCallService } from "@services/authorization/authorization-call";
|
||||
|
|
@ -32,5 +33,6 @@ export const environment = {
|
|||
taskExecutionsCallService: TaskExecutionsCallService,
|
||||
llmProviderCallService: LlmProviderCallService,
|
||||
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService,
|
||||
vaultCallService: VaultCallService
|
||||
vaultCallService: VaultCallService,
|
||||
storageTypesCallService: StorageTypesCallService
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue