diff --git a/README.md b/README.md index 1c4bc79..c9f0913 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ This table is the source of truth for the current UI surface. | Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. | | Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. | | Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. | +| Cloud Explorer / Secrets | No | Yes | Placeholder | Azure Key Vault list/create/delete/inspect through the schema-driven explorer. | | Dedicated page / Secrets Manager | Yes | No | No | AWS-only page outside Cloud Explorer. | Visible placeholders in the current sidebar: @@ -178,9 +179,9 @@ Current gaps:
-Secrets Manager +Secrets -This is the only dedicated AWS page still outside Cloud Explorer. +AWS Secrets Manager remains a dedicated page outside Cloud Explorer. - List secrets. - Inspect metadata. @@ -189,10 +190,18 @@ This is the only dedicated AWS page still outside Cloud Explorer. - Update values. - Delete secrets, including force delete. +Azure Key Vault uses the shared Cloud Explorer: + +- List and filter secret metadata without exposing values. +- Create secrets with optional content type. +- Inspect normalized metadata. +- Soft-delete secrets. + Current gaps: -- Not migrated into the Cloud Explorer contract yet. -- No Azure or GCP secret adapter yet. +- AWS Secrets Manager is not migrated into the Cloud Explorer contract yet. +- Key Vault value retrieval, versioning, recovery, and purge workflows are not exposed yet. +- No GCP Secret Manager adapter yet.
diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts new file mode 100644 index 0000000..27ffdd0 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.test.ts @@ -0,0 +1,179 @@ +import {describe, expect, test} from 'bun:test' +import {AzureKeyVaultAdapter} from './AzureKeyVaultAdapter' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' + +interface RecordedCall { + path: string + init: RequestInit + options: AzureRuntimeFetchOptions +} + +describe('AzureKeyVaultAdapter', () => { + test('lists and filters Key Vault secrets without exposing values', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets?api-version=7.4': { + value: [secretRecord('api-token', 'v2')], + nextLink: 'https://devstoreaccount1.vault.azure.net/secrets?api-version=7.4&next=page-2', + }, + '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2': { + value: [secretRecord('database-password', 'v1', {env: 'test'})], + nextLink: null, + }, + }, calls)) + + await expect(adapter.list({search: 'database'})).resolves.toEqual([{ + id: 'database-password', + name: 'database-password', + cloud: 'azure', + service: 'secrets', + type: 'secret', + region: null, + createdAt: '2026-05-20T20:00:00.000Z', + status: 'enabled', + version: 'v1', + metadata: { + provider: 'azure', + secretsService: 'key-vault', + vaultAccount: 'devstoreaccount1', + contentType: 'text/plain', + updatedAt: '2026-05-20T20:00:01.000Z', + expiresAt: null, + notBefore: null, + recoveryLevel: 'Purgeable', + recoverableDays: 7, + tags: [{key: 'env', value: 'test'}], + }, + }]) + expect(calls.map((call) => call.path)).toEqual([ + '/devstoreaccount1-keyvault/secrets?api-version=7.4', + '/devstoreaccount1-keyvault/secrets?api-version=7.4&next=page-2', + ]) + expect(calls.every((call) => call.options.includeStorageApiVersion === false)).toBe(true) + }) + + test('gets a secret and omits its value from normalized metadata', async () => { + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/database-password?api-version=7.4': { + ...secretRecord('database-password', 'v1'), + value: 'super-secret', + }, + })) + + const resource = await adapter.get('database-password') + + expect(resource).toMatchObject({id: 'database-password', version: 'v1'}) + expect(resource?.metadata).not.toHaveProperty('value') + }) + + test('creates secrets through the Azure Key Vault data-plane contract', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': { + ...secretRecord('api-key', 'created-version'), + value: 'abc123', + contentType: 'application/json', + }, + }, calls)) + + const created = await adapter.create({values: { + secretName: 'api-key', + secretValue: 'abc123', + contentType: 'application/json', + }}) + + expect(created).toMatchObject({id: 'api-key', version: 'created-version'}) + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe('/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4') + expect(calls[0].init.method).toBe('PUT') + expect(calls[0].init.headers).toMatchObject({ + authorization: 'Bearer floci-ui', + 'content-type': 'application/json', + }) + expect(calls[0].options.includeStorageApiVersion).toBe(false) + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + value: 'abc123', + contentType: 'application/json', + }) + }) + + test('deletes secrets using soft-delete endpoint', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({ + '/devstoreaccount1-keyvault/secrets/api-key?api-version=7.4': {}, + }, calls)) + + await adapter.delete('api-key') + + expect(calls).toHaveLength(1) + expect(calls[0].init.method).toBe('DELETE') + expect(calls[0].init.headers).toMatchObject({authorization: 'Bearer floci-ui'}) + expect(calls[0].options.includeStorageApiVersion).toBe(false) + }) + + test('normalizes missing secrets to null and missing lists to empty', async () => { + const adapter = new AzureKeyVaultAdapter(testClient({})) + + await expect(adapter.get('missing')).resolves.toBeNull() + await expect(adapter.list()).resolves.toEqual([]) + }) + + test('validates required inputs before calling the runtime', async () => { + const calls: RecordedCall[] = [] + const adapter = new AzureKeyVaultAdapter(testClient({}, calls)) + + await expect(adapter.create({values: {secretName: 'not valid', secretValue: 'value'}})) + .rejects.toThrow('Use a valid Key Vault secret name') + await expect(adapter.create({values: {secretName: 'valid-name', secretValue: ''}})) + .rejects.toThrow('secretValue is required') + expect(calls).toHaveLength(0) + }) + + test('exposes Azure Key Vault CRUD capabilities in its schema', () => { + const schema = new AzureKeyVaultAdapter(testClient({})).schema() + + expect(schema.service).toBe('secrets') + expect(schema.actions).toEqual(['list', 'create', 'delete', 'inspect']) + expect(schema.fields.find((field) => field.name === 'secretValue')?.type).toBe('password') + const namePattern = schema.fields.find((field) => field.name === 'secretName')?.validation?.pattern + expect(() => new RegExp(namePattern ?? '', 'v')).not.toThrow() + }) +}) + +function secretRecord(name: string, version: string, tags: Record = {}) { + return { + id: `https://devstoreaccount1.vault.azure.net/secrets/${name}/${version}`, + attributes: { + enabled: true, + created: 1779307200, + updated: 1779307201, + exp: null, + nbf: null, + recoveryLevel: 'Purgeable', + recoverableDays: 7, + }, + contentType: 'text/plain', + tags, + } +} + +function testClient( + responses: Record, + calls: RecordedCall[] = [], +): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + calls.push({path, init, options}) + if (!(path in responses)) { + if (options.emptyOnNotFound) return null + throw new Error(`Unexpected Key Vault path: ${path}`) + } + return new Response(JSON.stringify(responses[path]), { + status: 200, + headers: {'content-type': 'application/json'}, + }) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts new file mode 100644 index 0000000..b9a6c00 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureKeyVaultAdapter.ts @@ -0,0 +1,215 @@ +import {azure, type AzureRuntimeClient} from '../azure' +import {azureSecretsSchema} from '../cloud-spi/secretsSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +const API_VERSION = '7.4' + +interface KeyVaultSecretAttributes { + enabled?: boolean + created?: number + updated?: number + exp?: number | null + nbf?: number | null + recoveryLevel?: string + recoverableDays?: number +} + +interface KeyVaultSecretRecord { + id?: string + value?: string + attributes?: KeyVaultSecretAttributes + contentType?: string + tags?: Record +} + +interface KeyVaultSecretListResponse { + value?: KeyVaultSecretRecord[] + nextLink?: string | null +} + +export class AzureKeyVaultAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'secrets' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureSecretsSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const records: KeyVaultSecretRecord[] = [] + let path: string | null = `/secrets?api-version=${API_VERSION}` + + while (path) { + const body: KeyVaultSecretListResponse | null = await this.keyVaultJson( + path, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + records.push(...(body?.value ?? [])) + path = body?.nextLink ? keyVaultNextPath(body.nextLink) : null + } + + return filterBySearch(records.map((record) => toSecretResource(record)), query.search) + } + + async get(id: string): Promise { + const body = await this.keyVaultJson( + `/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + + return body ? toSecretResource(body, id) : null + } + + async create(input: CreateResourceInput): Promise { + const secretName = stringValue(input.values.secretName ?? input.values.name) + const secretValue = stringValue(input.values.secretValue ?? input.values.value, false) + const contentType = stringValue(input.values.contentType) + + if (!secretName) throw new Error('secretName is required') + if (!/^[0-9A-Za-z-]{1,127}$/.test(secretName)) { + throw new Error('Use a valid Key Vault secret name: 1-127 letters, numbers, or hyphens.') + } + if (!secretValue) throw new Error('secretValue is required') + + const body = await this.keyVaultJson( + `/secrets/${encodeURIComponent(secretName)}?api-version=${API_VERSION}`, + { + method: 'PUT', + body: JSON.stringify({ + value: secretValue, + ...(contentType ? {contentType} : {}), + }), + }, + ) + + if (!body) throw new Error('Azure Key Vault create returned an empty response') + return toSecretResource(body, secretName) + } + + async delete(id: string): Promise { + await this.client.fetch( + this.keyVaultPath(`/secrets/${encodeURIComponent(id)}?api-version=${API_VERSION}`), + {method: 'DELETE', headers: keyVaultHeaders()}, + {emptyOnNotFound: true, includeStorageApiVersion: false}, + ) + } + + private async keyVaultJson( + path: string, + init: RequestInit, + options?: {emptyOnNotFound?: boolean}, + ): Promise { + const res = await this.client.fetch( + this.keyVaultPath(path), + { + ...init, + headers: { + ...keyVaultHeaders(), + ...(init.headers ?? {}), + }, + }, + {...options, includeStorageApiVersion: false}, + ) + + if (!res || res.status === 204) return null + return await res.json() as T + } + + private keyVaultPath(path: string): string { + return `/${encodeURIComponent(this.client.accountName)}-keyvault${path}` + } +} + +function keyVaultNextPath(nextLink: string): string { + try { + const url = new URL(nextLink) + return `${url.pathname}${url.search}` + } catch { + return nextLink.startsWith('/') ? nextLink : `/${nextLink}` + } +} + +function keyVaultHeaders(): Record { + return { + accept: 'application/json', + authorization: 'Bearer floci-ui', + 'content-type': 'application/json', + } +} + +function toSecretResource(record: KeyVaultSecretRecord, fallbackName = ''): CloudResource { + const parsed = parseSecretId(record.id) + const name = parsed.name || fallbackName + const attributes = record.attributes ?? {} + + return { + id: name, + name, + cloud: 'azure', + service: 'secrets', + type: 'secret', + region: null, + createdAt: epochDate(attributes.created), + status: attributes.enabled === false ? 'disabled' : 'enabled', + version: parsed.version, + metadata: { + provider: 'azure', + secretsService: 'key-vault', + vaultAccount: parsed.account, + contentType: record.contentType || null, + updatedAt: epochDate(attributes.updated), + expiresAt: epochDate(attributes.exp), + notBefore: epochDate(attributes.nbf), + recoveryLevel: attributes.recoveryLevel, + recoverableDays: attributes.recoverableDays, + tags: Object.entries(record.tags ?? {}).map(([key, value]) => ({key, value})), + }, + } +} + +function parseSecretId(id?: string): {account: string | null; name: string; version: string | null} { + if (!id) return {account: null, name: '', version: null} + try { + const url = new URL(id) + const parts = url.pathname.split('/').filter(Boolean) + return { + account: url.hostname.split('.')[0] || null, + name: decodeURIComponent(parts[1] ?? ''), + version: parts[2] ? decodeURIComponent(parts[2]) : null, + } + } catch { + const parts = id.split('/').filter(Boolean) + const secretsIndex = parts.lastIndexOf('secrets') + return { + account: null, + name: decodeURIComponent(parts[secretsIndex + 1] ?? ''), + version: parts[secretsIndex + 2] ? decodeURIComponent(parts[secretsIndex + 2]) : null, + } + } +} + +function epochDate(value?: number | null): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + return new Date(value * 1000).toISOString() +} + +function stringValue(value: unknown, trim = true): string { + if (typeof value !== 'string') return '' + return trim ? value.trim() : value +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} diff --git a/packages/api/src/azure.test.ts b/packages/api/src/azure.test.ts index 4cc9e9e..35db2cb 100644 --- a/packages/api/src/azure.test.ts +++ b/packages/api/src/azure.test.ts @@ -8,6 +8,22 @@ afterEach(() => { }) describe('AzureRestRuntimeClient', () => { + test('can omit the Blob Storage API version header', async () => { + let requestHeaders: HeadersInit | undefined + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + requestHeaders = init?.headers + return new Response('{}') + }) as unknown as typeof fetch + + const client = new AzureRestRuntimeClient('http://localhost:4577', 'devstoreaccount1') + + await client.fetch('/devstoreaccount1-keyvault/secrets', {method: 'GET'}, { + includeStorageApiVersion: false, + }) + + expect(new Headers(requestHeaders).has('x-ms-version')).toBe(false) + }) + test('adds endpoint context to network failures', async () => { globalThis.fetch = (async () => { throw new Error('connection refused') diff --git a/packages/api/src/azure.ts b/packages/api/src/azure.ts index b8ea2ba..8b57243 100644 --- a/packages/api/src/azure.ts +++ b/packages/api/src/azure.ts @@ -1,5 +1,6 @@ export interface AzureRuntimeFetchOptions { emptyOnNotFound?: boolean + includeStorageApiVersion?: boolean } export interface AzureRuntimeClient { @@ -20,7 +21,7 @@ export class AzureRestRuntimeClient implements AzureRuntimeClient { res = await globalThis.fetch(`${this.endpoint}${path}`, { ...init, headers: { - 'x-ms-version': '2021-12-02', + ...(options.includeStorageApiVersion === false ? {} : {'x-ms-version': '2021-12-02'}), ...(init.headers ?? {}), }, }) diff --git a/packages/api/src/cloud-spi/secretsSchema.ts b/packages/api/src/cloud-spi/secretsSchema.ts new file mode 100644 index 0000000..023d95f --- /dev/null +++ b/packages/api/src/cloud-spi/secretsSchema.ts @@ -0,0 +1,66 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const secretsFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const secretsColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Secret Name'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At'}, + {name: 'version', label: 'Version'}, +] + +export function azureSecretsSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'secrets', + displayName: 'Azure Key Vault', + fields: [ + { + name: 'secretName', + label: 'Secret Name', + type: 'text', + required: true, + description: 'Unique Key Vault secret name.', + validation: { + minLength: 1, + maxLength: 127, + pattern: '^(?:[0-9A-Za-z]|-)+$', + message: 'Use letters, numbers, or hyphens.', + }, + }, + { + name: 'secretValue', + label: 'Secret Value', + type: 'password', + required: true, + description: 'Value stored in the secret.', + span: true, + }, + { + name: 'contentType', + label: 'Content Type', + type: 'text', + required: false, + description: 'Optional content type, for example application/json.', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List secrets', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete secret', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: secretsFilters, + columns: secretsColumns, + } +} + +export function secretsSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'azure') return azureSecretsSchema() + return null +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index bf9da0d..3e5d64a 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' | 'secrets' export type CloudAvailability = 'available' | 'coming_soon' @@ -26,7 +26,7 @@ export interface CloudStatus { error: string | null } -export type FieldType = 'text' | 'select' +export type FieldType = 'text' | 'password' | 'select' export interface FieldSchema { name: string @@ -83,7 +83,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'secret' region: string | null createdAt: string | null status?: string | null diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index b9740c0..00443f5 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -10,6 +10,7 @@ import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' import {CloudProxyService} from './service/CloudProxyService' import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter' +import {AzureKeyVaultAdapter} from './adapter-azure/AzureKeyVaultAdapter' import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' @@ -38,6 +39,7 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), new AzureServerlessAdapter(), + new AzureKeyVaultAdapter(), ]) return new CloudProxyService(registry) diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 49296be..3c00a16 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -2,6 +2,7 @@ import {describe, expect, test} from 'bun:test' import {Hono} from 'hono' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' import {awsStorageSchema, azureStorageSchema} from '../cloud-spi/storageSchema' +import {azureSecretsSchema} from '../cloud-spi/secretsSchema' import type {CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {CloudProxyService} from '../service/CloudProxyService' @@ -116,6 +117,35 @@ describe('cloud schema routes', () => { expect(gcpBody.displayName).toBe('Cloud SQL') }) + test('returns Azure Key Vault schema without a registered adapter', async () => { + const res = await appWithRoutes().request('/api/clouds/azure/services/secrets/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.displayName).toBe('Azure Key Vault') + expect(body.fields.map((field: {name: string}) => field.name)).toEqual([ + 'secretName', + 'secretValue', + 'contentType', + ]) + }) + + test('marks Azure Key Vault available when its adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('azure', { + service: 'secrets', + schema: azureSecretsSchema, + })]) + const res = await app.request('/api/clouds/azure/services') + const body = await res.json() + const secrets = body.find((service: {service: string}) => service.service === 'secrets') + + expect(res.status).toBe(200) + expect(secrets).toMatchObject({ + displayName: 'Key Vault', + availability: 'available', + }) + }) + test('returns AWS cloud status', async () => { const res = await appWithRoutes().request('/api/clouds/aws/status') const body = await res.json() diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 93cb831..a2a64e3 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -261,7 +261,7 @@ function isCloudProvider(value: string): value is CloudProvider { } function isServiceType(value: string): value is CloudServiceType { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' || value === 'secrets' } async function withRuntime(c: Context, handler: () => Promise): Promise { diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 826bf12..e101ca2 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -20,6 +20,7 @@ import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {serverlessSchemaFor} from '../cloud-spi/serverlessSchema' import {k8sSchemaFor} from '../cloud-spi/eksSchema' import {databaseSchemaFor} from '../cloud-spi/databaseSchema' +import {secretsSchemaFor} from '../cloud-spi/secretsSchema' import {azureEndpoint} from '../azure' import {checkGcpRuntime, gcpEndpoint} from '../gcp' @@ -73,6 +74,12 @@ export class CloudProxyService { displayName: 'Networking', availability: this.registry.get(cloud, 'networking') ? 'available' : 'coming_soon', }) + services.push({ + cloud, + service: 'secrets', + displayName: cloud === 'azure' ? 'Key Vault' : 'Secrets', + availability: this.registry.get(cloud, 'secrets') ? 'available' : 'coming_soon', + }) return services } @@ -83,6 +90,7 @@ export class CloudProxyService { if (service === 'k8s') return k8sSchemaFor(cloud) if (service === 'database') return databaseSchemaFor(cloud) if (service === 'serverless') return serverlessSchemaFor(cloud) + if (service === 'secrets') return secretsSchemaFor(cloud) return null } diff --git a/packages/frontend/src/components/DynamicFormRenderer.tsx b/packages/frontend/src/components/DynamicFormRenderer.tsx index 0c718b6..fc6e17a 100644 --- a/packages/frontend/src/components/DynamicFormRenderer.tsx +++ b/packages/frontend/src/components/DynamicFormRenderer.tsx @@ -89,6 +89,7 @@ function FieldInput({field, value, invalid, onChange}: {field: FieldSchema; valu return ( type CloudSidebarService = keyof typeof CLOUD_SERVICE_ICONS @@ -52,6 +53,7 @@ const CLOUD_SERVICE_ITEMS: Array<{name: CloudSidebarService; label: string; rout {name: 'networking', label: 'Networking', route: 'networking'}, {name: 'secretsmanager', label: 'Secrets Manager', route: '/secretsmanager'}, {name: 'serverless', label: 'Serverless', route: 'serverless'}, + {name: 'secrets', label: 'Key Vault', route: 'secrets'}, {name: 'queue', label: 'Queue'}, {name: 'function', label: 'Function'}, ] @@ -71,6 +73,7 @@ function CloudServiceNav() { || (service.name === 'database' && (cloud === 'aws' || cloud === 'azure')) || ((service.name === 'k8s' || service.name === 'compute' || service.name === 'networking') && cloud === 'aws') || (service.name === 'serverless' && (cloud === 'aws' || cloud === 'azure')) + || (service.name === 'secrets' && cloud === 'azure') if (service.route && available) { const target = service.route.startsWith('/') ? service.route : `/cloud-explorer/${cloud}/${service.route}` return diff --git a/packages/frontend/src/pages/CloudExplorerPage.tsx b/packages/frontend/src/pages/CloudExplorerPage.tsx index 37efef0..a4c9927 100644 --- a/packages/frontend/src/pages/CloudExplorerPage.tsx +++ b/packages/frontend/src/pages/CloudExplorerPage.tsx @@ -102,7 +102,7 @@ function normalizeCloud(value?: string): CloudProvider | null { } function normalizeService(value?: string): CloudServiceType | null { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' ? value : null + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' || value === 'secrets' ? value : null } function ServiceInfoDialog({ @@ -256,6 +256,7 @@ function connectionValue(status?: CloudStatus, loading?: boolean): string { function serviceLabel(service: CloudServiceType): string { if (service === 'k8s') return 'k8s Engine' if (service === 'serverless') return 'Serverless' + if (service === 'secrets') return 'Secrets' return service.charAt(0).toUpperCase() + service.slice(1) } @@ -263,6 +264,7 @@ function limitationCopy(cloud: CloudProvider, service: CloudServiceType): string if (cloud === 'aws' && service === 'storage') return 'Advanced S3 workflows such as bulk actions, version browsing, and richer object lifecycle controls still live outside the normalized surface.' if (cloud === 'azure' && service === 'storage') return 'Blob Storage is wired through the normalized contract, but advanced metadata, tags, and access-policy workflows are still limited.' if (cloud === 'azure' && service === 'database') return 'Cosmos DB uses a richer panel below for databases, containers, items, and SQL queries; a fully normalized database model is still evolving.' + if (cloud === 'azure' && service === 'secrets') return 'Key Vault secret metadata and lifecycle operations are wired through the normalized contract; secret value versioning and recovery workflows are not exposed yet.' if (cloud === 'aws' && service === 'compute') return 'Compute workflows still rely on AWS-specific forms for dependent infrastructure choices such as VPC, subnet, and security group.' if (cloud === 'aws' && service === 'networking') return 'Networking uses AWS-specific operational panels because many actions require nested workflows instead of a flat generic form.' return 'This service is available through the current adapter, but the normalized contract is still expanding.' diff --git a/packages/frontend/src/types/cloud.ts b/packages/frontend/src/types/cloud.ts index 668a147..f6b1895 100644 --- a/packages/frontend/src/types/cloud.ts +++ b/packages/frontend/src/types/cloud.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' export type CloudAvailability = 'available' | 'coming_soon' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless' | 'secrets' export interface CloudDescriptor { id: CloudProvider diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index 71f464f..a69e1df 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -5,7 +5,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function'; + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function' | 'secret'; region: string | null createdAt: string | null status?: string | null diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index 06e4985..3efda8a 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -1,6 +1,6 @@ import type {CloudProvider, CloudServiceType} from './cloud' -export type FieldType = 'text' | 'select' +export type FieldType = 'text' | 'password' | 'select' export type ActionSchema = 'list' | 'create' | 'delete' | 'inspect' export type ResourceActionName = 'list' | 'create' | 'delete' | 'inspect' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy'