diff --git a/apps/api/openapi/main.jsonnet b/apps/api/openapi/main.jsonnet index d8ba69eef..2f568a72d 100644 --- a/apps/api/openapi/main.jsonnet +++ b/apps/api/openapi/main.jsonnet @@ -50,7 +50,8 @@ local securitySchemes = { (import 'paths/release.jsonnet') + (import 'paths/job-agents.jsonnet') + (import 'paths/workflows.jsonnet') + - (import 'paths/variablesets.jsonnet'), + (import 'paths/variablesets.jsonnet') + + (import 'paths/secret-providers.jsonnet'), components: { parameters: {}, securitySchemes: securitySchemes, @@ -73,6 +74,7 @@ local securitySchemes = { (import 'schemas/job-agents.jsonnet') + (import 'schemas/verifications.jsonnet') + (import 'schemas/workflows.jsonnet') + - (import 'schemas/variablesets.jsonnet'), + (import 'schemas/variablesets.jsonnet') + + (import 'schemas/secret-providers.jsonnet'), }, } diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index 44ecb07ab..9444e85e2 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -21,6 +21,26 @@ ], "type": "string" }, + "AwsSecretsManagerConfig": { + "properties": { + "accessKeyId": { + "description": "Optional static AWS access key id. Omit to use the workspace-engine instance role.", + "type": "string" + }, + "region": { + "description": "AWS region.", + "type": "string" + }, + "secretAccessKey": { + "description": "Optional static AWS secret access key.", + "type": "string" + } + }, + "required": [ + "region" + ], + "type": "object" + }, "BooleanValue": { "type": "boolean" }, @@ -1056,6 +1076,34 @@ ], "type": "object" }, + "DopplerConfig": { + "properties": { + "serviceToken": { + "description": "Doppler service token (dp.st.<...>).", + "type": "string" + } + }, + "required": [ + "serviceToken" + ], + "type": "object" + }, + "EnvConfig": { + "properties": { + "allowedKeys": { + "description": "Explicit allowlist of environment variable names this provider may expose.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "allowedKeys" + ], + "type": "object" + }, "Environment": { "properties": { "createdAt": { @@ -2434,6 +2482,80 @@ ], "type": "object" }, + "SecretProvider": { + "description": "Secret provider metadata. The encrypted configuration is never returned.", + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/SecretProviderType" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "workspaceId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id", + "workspaceId", + "name", + "type", + "createdAt", + "updatedAt" + ], + "type": "object" + }, + "SecretProviderConfig": { + "description": "Provider-specific configuration. Shape depends on the provider type.", + "oneOf": [ + { + "$ref": "#/components/schemas/AwsSecretsManagerConfig" + }, + { + "$ref": "#/components/schemas/DopplerConfig" + }, + { + "$ref": "#/components/schemas/EnvConfig" + } + ] + }, + "SecretProviderRequestAccepted": { + "properties": { + "id": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "id", + "message" + ], + "type": "object" + }, + "SecretProviderType": { + "description": "Type of secret provider.", + "enum": [ + "aws_secrets_manager", + "doppler", + "env" + ], + "type": "string" + }, "SensitiveValue": { "properties": { "valueHash": { @@ -3063,6 +3185,26 @@ ], "type": "object" }, + "UpsertSecretProviderRequest": { + "properties": { + "config": { + "$ref": "#/components/schemas/SecretProviderConfig" + }, + "name": { + "description": "Workspace-unique name used to reference the provider from variable values.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/SecretProviderType" + } + }, + "required": [ + "name", + "type", + "config" + ], + "type": "object" + }, "UpsertSystemRequest": { "properties": { "description": { @@ -8882,6 +9024,227 @@ ] } }, + "/v1/workspaces/{workspaceId}/secret-providers": { + "get": { + "description": "Returns the metadata of every secret provider configured in the workspace. Encrypted configurations are never returned.", + "operationId": "listSecretProviders", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of items to return", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Number of items to skip", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SecretProvider" + }, + "type": "array" + }, + "limit": { + "description": "Maximum number of items returned", + "type": "integer" + }, + "offset": { + "description": "Number of items skipped", + "type": "integer" + }, + "total": { + "description": "Total number of items available", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "type": "object" + } + } + }, + "description": "Paginated list of items" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + } + }, + "summary": "List secret providers" + } + }, + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}": { + "delete": { + "description": "Variable values that reference this provider will fail to resolve until they are updated or the provider is recreated.", + "operationId": "requestSecretProviderDeletion", + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretProviderRequestAccepted" + } + } + }, + "description": "Secret provider deleted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "Delete a secret provider" + }, + "get": { + "operationId": "getSecretProvider", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretProvider" + } + } + }, + "description": "OK response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "Get a secret provider" + }, + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "ID of the secret provider", + "in": "path", + "name": "providerId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "put": { + "description": "Creates or updates a secret provider. The config is encrypted at rest before persistence.", + "operationId": "requestSecretProviderUpsert", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertSecretProviderRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretProviderRequestAccepted" + } + } + }, + "description": "Accepted response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "Upsert a secret provider" + } + }, "/v1/workspaces/{workspaceId}/systems": { "get": { "operationId": "listSystems", diff --git a/apps/api/openapi/paths/secret-providers.jsonnet b/apps/api/openapi/paths/secret-providers.jsonnet new file mode 100644 index 000000000..9ed448e45 --- /dev/null +++ b/apps/api/openapi/paths/secret-providers.jsonnet @@ -0,0 +1,54 @@ +local openapi = import '../lib/openapi.libsonnet'; + +{ + '/v1/workspaces/{workspaceId}/secret-providers': { + get: { + summary: 'List secret providers', + operationId: 'listSecretProviders', + description: 'Returns the metadata of every secret provider configured in the workspace. Encrypted configurations are never returned.', + parameters: [ + openapi.workspaceIdParam(), + openapi.limitParam(), + openapi.offsetParam(), + ], + responses: openapi.paginatedResponse(openapi.schemaRef('SecretProvider')) + + openapi.badRequestResponse(), + }, + }, + '/v1/workspaces/{workspaceId}/secret-providers/{providerId}': { + parameters: [ + openapi.workspaceIdParam(), + openapi.stringParam('providerId', 'ID of the secret provider'), + ], + get: { + summary: 'Get a secret provider', + operationId: 'getSecretProvider', + responses: openapi.okResponse(openapi.schemaRef('SecretProvider')) + + openapi.notFoundResponse(), + }, + put: { + summary: 'Upsert a secret provider', + operationId: 'requestSecretProviderUpsert', + description: 'Creates or updates a secret provider. The config is encrypted at rest before persistence.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: openapi.schemaRef('UpsertSecretProviderRequest'), + }, + }, + }, + responses: openapi.acceptedResponse(openapi.schemaRef('SecretProviderRequestAccepted')) + + openapi.notFoundResponse() + + openapi.badRequestResponse(), + }, + delete: { + summary: 'Delete a secret provider', + operationId: 'requestSecretProviderDeletion', + description: 'Variable values that reference this provider will fail to resolve until they are updated or the provider is recreated.', + responses: openapi.acceptedResponse(openapi.schemaRef('SecretProviderRequestAccepted'), 'Secret provider deleted') + + openapi.notFoundResponse() + + openapi.badRequestResponse(), + }, + }, +} diff --git a/apps/api/openapi/schemas/secret-providers.jsonnet b/apps/api/openapi/schemas/secret-providers.jsonnet new file mode 100644 index 000000000..e73eaa527 --- /dev/null +++ b/apps/api/openapi/schemas/secret-providers.jsonnet @@ -0,0 +1,80 @@ +{ + SecretProviderType: { + type: 'string', + enum: ['aws_secrets_manager', 'doppler', 'env'], + description: 'Type of secret provider.', + }, + + AwsSecretsManagerConfig: { + type: 'object', + required: ['region'], + properties: { + region: { type: 'string', description: 'AWS region.' }, + accessKeyId: { type: 'string', description: 'Optional static AWS access key id. Omit to use the workspace-engine instance role.' }, + secretAccessKey: { type: 'string', description: 'Optional static AWS secret access key.' }, + }, + }, + + DopplerConfig: { + type: 'object', + required: ['serviceToken'], + properties: { + serviceToken: { type: 'string', description: 'Doppler service token (dp.st.<...>).' }, + }, + }, + + EnvConfig: { + type: 'object', + required: ['allowedKeys'], + properties: { + allowedKeys: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + description: 'Explicit allowlist of environment variable names this provider may expose.', + }, + }, + }, + + SecretProviderConfig: { + oneOf: [ + { '$ref': '#/components/schemas/AwsSecretsManagerConfig' }, + { '$ref': '#/components/schemas/DopplerConfig' }, + { '$ref': '#/components/schemas/EnvConfig' }, + ], + description: 'Provider-specific configuration. Shape depends on the provider type.', + }, + + UpsertSecretProviderRequest: { + type: 'object', + required: ['name', 'type', 'config'], + properties: { + name: { type: 'string', description: 'Workspace-unique name used to reference the provider from variable values.' }, + type: { '$ref': '#/components/schemas/SecretProviderType' }, + config: { '$ref': '#/components/schemas/SecretProviderConfig' }, + }, + }, + + SecretProvider: { + type: 'object', + required: ['id', 'workspaceId', 'name', 'type', 'createdAt', 'updatedAt'], + properties: { + id: { type: 'string', format: 'uuid' }, + workspaceId: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + type: { '$ref': '#/components/schemas/SecretProviderType' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + description: 'Secret provider metadata. The encrypted configuration is never returned.', + }, + + SecretProviderRequestAccepted: { + type: 'object', + required: ['id', 'message'], + properties: { + id: { type: 'string' }, + message: { type: 'string' }, + }, + }, +} diff --git a/apps/api/package.json b/apps/api/package.json index f5728b677..dd9474464 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "@ctrlplane/auth": "workspace:*", "@ctrlplane/db": "workspace:*", "@ctrlplane/logger": "workspace:*", + "@ctrlplane/secrets": "workspace:*", "@ctrlplane/trpc": "workspace:*", "@ctrlplane/validators": "workspace:*", "@ctrlplane/workspace-engine-sdk": "workspace:*", diff --git a/apps/api/src/routes/v1/workspaces/index.ts b/apps/api/src/routes/v1/workspaces/index.ts index 440d6889a..45989a09a 100644 --- a/apps/api/src/routes/v1/workspaces/index.ts +++ b/apps/api/src/routes/v1/workspaces/index.ts @@ -24,6 +24,7 @@ import { releaseTargetsRouter } from "./release-targets.js"; import { releaseRouter } from "./releases.js"; import { resourceProvidersRouter } from "./resource-providers.js"; import { resourceRouter } from "./resources.js"; +import { secretProvidersRouter } from "./secret-providers.js"; import { systemRouter } from "./systems.js"; import { variableSetsRouter } from "./variable-sets.js"; import { workflowsRouter } from "./workflows.js"; @@ -57,4 +58,5 @@ export const createWorkspacesRouter = (): Router => .use("/:workspaceId/releases", releaseRouter) .use("/:workspaceId/job-agents", jobAgentsRouter) .use("/:workspaceId/workflows", workflowsRouter) - .use("/:workspaceId/variable-sets", variableSetsRouter); + .use("/:workspaceId/variable-sets", variableSetsRouter) + .use("/:workspaceId/secret-providers", secretProvidersRouter); diff --git a/apps/api/src/routes/v1/workspaces/secret-providers.ts b/apps/api/src/routes/v1/workspaces/secret-providers.ts new file mode 100644 index 000000000..5ffeaa359 --- /dev/null +++ b/apps/api/src/routes/v1/workspaces/secret-providers.ts @@ -0,0 +1,172 @@ +import type { AsyncTypedHandler } from "@/types/api.js"; +import { ApiError, asyncHandler } from "@/types/api.js"; +import { Router } from "express"; +import { z } from "zod"; + +import { and, count, eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { variablesAES256 } from "@ctrlplane/secrets"; + +const awsSecretsManagerConfig = z.object({ + region: z.string().min(1), + accessKeyId: z.string().min(1).optional(), + secretAccessKey: z.string().min(1).optional(), +}); + +const dopplerConfig = z.object({ + serviceToken: z.string().startsWith("dp.st."), +}); + +const envConfig = z.object({ + allowedKeys: z + .array(z.string().regex(/^[A-Z_][A-Z0-9_]*$/)) + .min(1), +}); + +const providerBody = z.discriminatedUnion("type", [ + z.object({ + name: z.string().min(1), + type: z.literal("aws_secrets_manager"), + config: awsSecretsManagerConfig, + }), + z.object({ + name: z.string().min(1), + type: z.literal("doppler"), + config: dopplerConfig, + }), + z.object({ + name: z.string().min(1), + type: z.literal("env"), + config: envConfig, + }), +]); + +const formatSecretProvider = ( + row: typeof schema.secretProvider.$inferSelect, +) => ({ + id: row.id, + workspaceId: row.workspaceId, + name: row.name, + type: row.type, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), +}); + +const listSecretProviders: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/secret-providers", + "get" +> = async (req, res) => { + const { workspaceId } = req.params; + const limit = req.query.limit ?? 50; + const offset = req.query.offset ?? 0; + + const [countResult] = await db + .select({ total: count() }) + .from(schema.secretProvider) + .where(eq(schema.secretProvider.workspaceId, workspaceId)); + + const total = countResult?.total ?? 0; + + const items = await db + .select() + .from(schema.secretProvider) + .where(eq(schema.secretProvider.workspaceId, workspaceId)) + .limit(limit) + .offset(offset); + + res + .status(200) + .json({ items: items.map(formatSecretProvider), total, limit, offset }); +}; + +const getSecretProvider: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + "get" +> = async (req, res) => { + const { workspaceId, providerId } = req.params; + + const row = await db.query.secretProvider.findFirst({ + where: and( + eq(schema.secretProvider.id, providerId), + eq(schema.secretProvider.workspaceId, workspaceId), + ), + }); + + if (row == null) throw new ApiError("Secret provider not found", 404); + + res.status(200).json(formatSecretProvider(row)); +}; + +const upsertSecretProvider: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + "put" +> = async (req, res) => { + const { workspaceId, providerId } = req.params; + + const parsed = providerBody.safeParse(req.body); + if (!parsed.success) + throw new ApiError( + `Invalid secret provider body: ${parsed.error.message}`, + 400, + ); + + const { name, type, config } = parsed.data; + const encryptedConfig = Buffer.from( + variablesAES256().encrypt(JSON.stringify(config)), + "utf8", + ); + + await db + .insert(schema.secretProvider) + .values({ + id: providerId, + workspaceId, + name, + type, + config: encryptedConfig, + }) + .onConflictDoUpdate({ + target: schema.secretProvider.id, + set: { + name, + type, + config: encryptedConfig, + updatedAt: new Date(), + }, + }); + + res.status(202).json({ + id: providerId, + message: "Secret provider upsert requested", + }); +}; + +const deleteSecretProvider: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + "delete" +> = async (req, res) => { + const { workspaceId, providerId } = req.params; + + const [deleted] = await db + .delete(schema.secretProvider) + .where( + and( + eq(schema.secretProvider.id, providerId), + eq(schema.secretProvider.workspaceId, workspaceId), + ), + ) + .returning(); + + if (deleted == null) throw new ApiError("Secret provider not found", 404); + + res + .status(202) + .json({ id: providerId, message: "Secret provider deleted" }); +}; + +export const secretProvidersRouter = Router({ mergeParams: true }) + .get("/", asyncHandler(listSecretProviders)) + .get("/:providerId", asyncHandler(getSecretProvider)) + .put("/:providerId", asyncHandler(upsertSecretProvider)) + .delete("/:providerId", asyncHandler(deleteSecretProvider)); diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index 6e0fc5abe..6bc1e5824 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -936,6 +936,56 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/secret-providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List secret providers + * @description Returns the metadata of every secret provider configured in the workspace. Encrypted configurations are never returned. + */ + get: operations["listSecretProviders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + /** Get a secret provider */ + get: operations["getSecretProvider"]; + /** + * Upsert a secret provider + * @description Creates or updates a secret provider. The config is encrypted at rest before persistence. + */ + put: operations["requestSecretProviderUpsert"]; + post?: never; + /** + * Delete a secret provider + * @description Variable values that reference this provider will fail to resolve until they are updated or the provider is recreated. + */ + delete: operations["requestSecretProviderDeletion"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/systems": { parameters: { query?: never; @@ -1185,6 +1235,14 @@ export interface components { }; /** @enum {string} */ ApprovalStatus: "approved" | "rejected"; + AwsSecretsManagerConfig: { + /** @description Optional static AWS access key id. Omit to use the workspace-engine instance role. */ + accessKeyId?: string; + /** @description AWS region. */ + region: string; + /** @description Optional static AWS secret access key. */ + secretAccessKey?: string; + }; BooleanValue: boolean; CreateDeploymentPlanRequest: { /** @description Arbitrary key-value metadata for the plan (e.g. GitHub PR links, CI run URLs) */ @@ -1555,6 +1613,14 @@ export interface components { workflowJob?: components["schemas"]["WorkflowJob"]; workflowRun?: components["schemas"]["WorkflowRun"]; }; + DopplerConfig: { + /** @description Doppler service token (dp.st.<...>). */ + serviceToken: string; + }; + EnvConfig: { + /** @description Explicit allowlist of environment variable names this provider may expose. */ + allowedKeys: string[]; + }; Environment: { /** Format: date-time */ createdAt: string; @@ -2040,6 +2106,30 @@ export interface components { /** @description Job statuses that count toward the retry limit. If null or empty, defaults to ["failure", "invalidIntegration", "invalidJobAgent"] for maxRetries > 0, or ["failure", "invalidIntegration", "invalidJobAgent", "successful"] for maxRetries = 0. Cancelled and skipped jobs never count by default (allows redeployment after cancellation). Example: ["failure", "cancelled"] will only count failed/cancelled jobs. */ retryOnStatuses?: components["schemas"]["JobStatus"][]; }; + /** @description Secret provider metadata. The encrypted configuration is never returned. */ + SecretProvider: { + /** Format: date-time */ + createdAt: string; + /** Format: uuid */ + id: string; + name: string; + type: components["schemas"]["SecretProviderType"]; + /** Format: date-time */ + updatedAt: string; + /** Format: uuid */ + workspaceId: string; + }; + /** @description Provider-specific configuration. Shape depends on the provider type. */ + SecretProviderConfig: components["schemas"]["AwsSecretsManagerConfig"] | components["schemas"]["DopplerConfig"] | components["schemas"]["EnvConfig"]; + SecretProviderRequestAccepted: { + id: string; + message: string; + }; + /** + * @description Type of secret provider. + * @enum {string} + */ + SecretProviderType: "aws_secrets_manager" | "doppler" | "env"; SensitiveValue: { valueHash: string; }; @@ -2267,6 +2357,12 @@ export interface components { }; version: string; }; + UpsertSecretProviderRequest: { + config: components["schemas"]["SecretProviderConfig"]; + /** @description Workspace-unique name used to reference the provider from variable values. */ + name: string; + type: components["schemas"]["SecretProviderType"]; + }; UpsertSystemRequest: { description?: string; metadata?: { @@ -5823,6 +5919,175 @@ export interface operations { }; }; }; + listSecretProviders: { + parameters: { + query?: { + /** @description Maximum number of items to return */ + limit?: number; + /** @description Number of items to skip */ + offset?: number; + }; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Paginated list of items */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["SecretProvider"][]; + /** @description Maximum number of items returned */ + limit: number; + /** @description Number of items skipped */ + offset: number; + /** @description Total number of items available */ + total: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getSecretProvider: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecretProvider"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + requestSecretProviderUpsert: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertSecretProviderRequest"]; + }; + }; + responses: { + /** @description Accepted response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecretProviderRequestAccepted"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + requestSecretProviderDeletion: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Secret provider deleted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecretProviderRequestAccepted"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; listSystems: { parameters: { query?: { diff --git a/apps/workspace-engine/go.mod b/apps/workspace-engine/go.mod index 3914d8a96..b2f596f77 100644 --- a/apps/workspace-engine/go.mod +++ b/apps/workspace-engine/go.mod @@ -7,6 +7,10 @@ require ( github.com/argoproj/argo-cd/v3 v3.3.4 github.com/argoproj/argo-workflows/v4 v4.0.3 github.com/avast/retry-go v2.7.0+incompatible + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 github.com/charmbracelet/log v0.4.2 github.com/confluentinc/confluent-kafka-go/v2 v2.13.3 github.com/dgraph-io/ristretto/v2 v2.3.0 @@ -29,6 +33,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/teambition/rrule-go v1.8.2 + github.com/tidwall/gjson v1.19.0 go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 go.opentelemetry.io/otel v1.43.0 @@ -130,6 +135,17 @@ require ( github.com/argoproj/gitops-engine v0.7.1-0.20250908182407-97ad5b59a627 // indirect github.com/argoproj/pkg v0.13.7-0.20250123033407-65f2d4777bfd // indirect github.com/argoproj/pkg/v2 v2.0.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -280,6 +296,8 @@ require ( github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect github.com/testcontainers/testcontainers-go/modules/compose v0.41.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/upper/db/v4 v4.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fastjson v1.6.7 // indirect diff --git a/apps/workspace-engine/go.sum b/apps/workspace-engine/go.sum index ff91c6536..38b80179e 100644 --- a/apps/workspace-engine/go.sum +++ b/apps/workspace-engine/go.sum @@ -78,6 +78,36 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/avast/retry-go v2.7.0+incompatible h1:XaGnzl7gESAideSjr+I8Hki/JBi+Yb9baHlMRPeSC84= github.com/avast/retry-go v2.7.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 h1:JUGKqUnJHbXpS8uyuICP/zpQ+vXUIXW2zTEqjMLCqrY= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7/go.mod h1:l/cqI7ujYqBuTR6Ll13d9/gG/uUdlVzJ1UDltEEBTOo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -905,6 +935,12 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go/modules/compose v0.41.0 h1:6ttsQ6IilJYMoTFI2gu9l7KmKlnlY9XGkP0wtgh4rF4= github.com/testcontainers/testcontainers-go/modules/compose v0.41.0/go.mod h1:6PfaNLXsylvZE5CID8QMZ4fWjLHORvqm1xcGBncdzAY= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 h1:QB54BJwA6x8QU9nHY3xJSZR2kX9bgpZekRKGkLTmEXA= github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375/go.mod h1:xRroudyp5iVtxKqZCrA6n2TLFRBf8bmnjr1UD4x+z7g= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= diff --git a/apps/workspace-engine/main.go b/apps/workspace-engine/main.go index 03009295b..b74d513a3 100644 --- a/apps/workspace-engine/main.go +++ b/apps/workspace-engine/main.go @@ -11,6 +11,8 @@ import ( "github.com/google/uuid" "workspace-engine/pkg/config" "workspace-engine/pkg/db" + "workspace-engine/pkg/secrets" + "workspace-engine/pkg/secrets/providers" "workspace-engine/svc" "workspace-engine/svc/claimcleanup" "workspace-engine/svc/controllers/deploymentplan" @@ -49,21 +51,23 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + secretResolver := newSecretResolver(ctx) + allServices := []svc.Service{ pprof.New(pprof.DefaultAddr(config.Global.PprofPort)), httpsvc.New(config.Global, db.GetPool(ctx)), claimcleanup.New(db.GetPool(ctx), 30*time.Second), - deploymentplan.New(WorkerID, db.GetPool(ctx)), + deploymentplan.New(WorkerID, db.GetPool(ctx), secretResolver), deploymentplanresult.New(WorkerID, db.GetPool(ctx)), deploymentresourceselectoreval.New(WorkerID, db.GetPool(ctx)), environmentresourceselectoreval.New(WorkerID, db.GetPool(ctx)), - forcedeploy.New(WorkerID, db.GetPool(ctx)), + forcedeploy.New(WorkerID, db.GetPool(ctx), secretResolver), jobdispatch.New(WorkerID, db.GetPool(ctx)), - jobeligibility.New(WorkerID, db.GetPool(ctx)), + jobeligibility.New(WorkerID, db.GetPool(ctx), secretResolver), jobverificationmetric.New(WorkerID, db.GetPool(ctx)), relationshipeval.New(WorkerID, db.GetPool(ctx)), - desiredrelease.New(WorkerID, db.GetPool(ctx)), + desiredrelease.New(WorkerID, db.GetPool(ctx), secretResolver), policyeval.New(WorkerID, db.GetPool(ctx)), } @@ -94,3 +98,28 @@ func main() { slog.Info("Workspace engine shut down") } + +// newSecretResolver wires the components needed to resolve secret_ref +// variable values. If VARIABLES_AES_256_KEY is unset the resolver is nil and +// any secret_ref encountered during reconciliation will block release +// dispatch with a clear error. +func newSecretResolver(ctx context.Context) *secrets.Resolver { + keyHex := config.Global.VariablesAes256Key + if keyHex == "" { + slog.Warn( + "VARIABLES_AES_256_KEY is unset; secret_ref variable values will fail to resolve", + ) + return nil + } + store, err := secrets.NewPostgresStoreFromKey(db.GetQueries(ctx), keyHex) + if err != nil { + slog.Error("Failed to construct secret store", "error", err) + os.Exit(1) + } + return secrets.NewResolver( + store, + providers.NewDefaultRegistry(), + secrets.NewCache(config.Global.SecretsCacheTTL), + secrets.NewProviderCache(config.Global.SecretsProviderCacheTTL), + ) +} diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index e5e6fe963..a9d3118b4 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -495,6 +495,13 @@ "jobAgentConfig": { "$ref": "#/components/schemas/JobAgentConfig" }, + "jobAgentVariables": { + "additionalProperties": { + "$ref": "#/components/schemas/LiteralValue" + }, + "description": "Variables scoped to the dispatching job agent. Resolved at dispatch time and referenced from agent-config templates as {{ .jobAgentVariables. }}.", + "type": "object" + }, "release": { "$ref": "#/components/schemas/Release" }, @@ -2507,6 +2514,34 @@ ], "type": "object" }, + "SecretReferenceValue": { + "properties": { + "secretKey": { + "description": "Secret key within the provider", + "type": "string" + }, + "secretPath": { + "description": "Optional provider-specific path components", + "items": { + "type": "string" + }, + "type": "array" + }, + "secretProvider": { + "description": "Workspace-unique secret_provider.name", + "type": "string" + }, + "secretVersion": { + "description": "Optional provider-specific version pin. For AWS Secrets Manager this maps to VersionId (uuid form) or VersionStage (AWSCURRENT/AWSPREVIOUS). For Doppler this maps to accept_secret_version. Empty means latest.", + "type": "string" + } + }, + "required": [ + "secretProvider", + "secretKey" + ], + "type": "object" + }, "SensitiveValue": { "properties": { "valueHash": { @@ -2730,6 +2765,9 @@ }, { "$ref": "#/components/schemas/SensitiveValue" + }, + { + "$ref": "#/components/schemas/SecretReferenceValue" } ] }, diff --git a/apps/workspace-engine/oapi/spec/schemas/core.jsonnet b/apps/workspace-engine/oapi/spec/schemas/core.jsonnet index 510c7f388..198bac179 100644 --- a/apps/workspace-engine/oapi/spec/schemas/core.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/core.jsonnet @@ -74,11 +74,40 @@ local openapi = import '../lib/openapi.libsonnet'; }, }, + // SecretReferenceValue identifies a secret stored in an external provider. + // Resolution is performed at release time by the secrets resolver and the + // returned value flows through release.Variables as a LiteralValue. The + // plaintext is never persisted on the resolved Value. + SecretReferenceValue: { + type: 'object', + required: ['secretProvider', 'secretKey'], + properties: { + secretProvider: { + type: 'string', + description: 'Workspace-unique secret_provider.name', + }, + secretKey: { + type: 'string', + description: 'Secret key within the provider', + }, + secretPath: { + type: 'array', + items: { type: 'string' }, + description: 'Optional provider-specific path components', + }, + secretVersion: { + type: 'string', + description: 'Optional provider-specific version pin. For AWS Secrets Manager this maps to VersionId (uuid form) or VersionStage (AWSCURRENT/AWSPREVIOUS). For Doppler this maps to accept_secret_version. Empty means latest.', + }, + }, + }, + Value: { oneOf: [ openapi.schemaRef('LiteralValue'), openapi.schemaRef('ReferenceValue'), openapi.schemaRef('SensitiveValue'), + openapi.schemaRef('SecretReferenceValue'), ], }, diff --git a/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet b/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet index 667b1fe1b..5870773c9 100644 --- a/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet @@ -66,6 +66,11 @@ local JobPropertyKeys = std.objectFields(Job.properties); type: 'object', additionalProperties: openapi.schemaRef('LiteralValue'), }, + jobAgentVariables: { + type: 'object', + additionalProperties: openapi.schemaRef('LiteralValue'), + description: 'Variables scoped to the dispatching job agent. Resolved at dispatch time and referenced from agent-config templates as {{ .jobAgentVariables. }}.', + }, }, }, diff --git a/apps/workspace-engine/pkg/config/env.go b/apps/workspace-engine/pkg/config/env.go index d40480829..f0ddbe1dd 100644 --- a/apps/workspace-engine/pkg/config/env.go +++ b/apps/workspace-engine/pkg/config/env.go @@ -5,6 +5,7 @@ import ( "runtime" "strconv" "strings" + "time" "github.com/kelseyhightower/envconfig" ) @@ -53,6 +54,19 @@ type Config struct { // Whether to enable dry run for workflow jobs. DryRunEnabled bool `default:"false" envconfig:"DRY_RUN_ENABLED"` + + // Symmetric key used to encrypt/decrypt secret_provider.config rows. + // Must match @ctrlplane/secrets in the TypeScript layer (64 hex chars). + VariablesAes256Key string `default:"" envconfig:"VARIABLES_AES_256_KEY"` + + // TTL for the secrets resolver value cache. + SecretsCacheTTL time.Duration `default:"5m" envconfig:"SECRETS_CACHE_TTL"` + + // TTL for the secrets resolver provider-instance cache (constructed + // Provider objects, e.g. AWS SDK clients). A longer TTL is appropriate + // here than the value cache because provider configs change rarely + // while individual secret values may be rotated more often. + SecretsProviderCacheTTL time.Duration `default:"30m" envconfig:"SECRETS_PROVIDER_CACHE_TTL"` } // GetMaxConcurrency returns the max concurrency for a given service kind. diff --git a/apps/workspace-engine/pkg/crypto/aes256cbc.go b/apps/workspace-engine/pkg/crypto/aes256cbc.go new file mode 100644 index 000000000..94b573511 --- /dev/null +++ b/apps/workspace-engine/pkg/crypto/aes256cbc.go @@ -0,0 +1,127 @@ +// Package crypto provides AES-256-CBC encryption matching the format produced +// by the TypeScript @ctrlplane/secrets package. Both sides use a 32-byte key +// (encoded as 64 hex characters) and emit ciphertext in the form +// ":" with PKCS#7 padding. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +const ( + keyHexLength = 64 + keyByteLen = 32 + ivByteLen = 16 +) + +// AES256CBC encrypts and decrypts strings using AES-256-CBC. The key must be +// supplied as a 64-character hex string (32 bytes decoded). +type AES256CBC struct { + key []byte +} + +// New constructs an AES256CBC from a 64-character hex key. +func New(keyHex string) (*AES256CBC, error) { + if len(keyHex) != keyHexLength { + return nil, fmt.Errorf( + "aes256cbc: key must be %d hex characters, got %d", + keyHexLength, + len(keyHex), + ) + } + key, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("aes256cbc: invalid hex key: %w", err) + } + if len(key) != keyByteLen { + return nil, fmt.Errorf( + "aes256cbc: decoded key must be %d bytes, got %d", + keyByteLen, + len(key), + ) + } + return &AES256CBC{key: key}, nil +} + +// Encrypt returns the ciphertext for plaintext in the format used by +// @ctrlplane/secrets: ":". +func (a *AES256CBC) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(a.key) + if err != nil { + return "", fmt.Errorf("aes256cbc: cipher init: %w", err) + } + iv := make([]byte, ivByteLen) + if _, err := rand.Read(iv); err != nil { + return "", fmt.Errorf("aes256cbc: iv generation: %w", err) + } + padded := pkcs7Pad([]byte(plaintext), block.BlockSize()) + encrypted := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(encrypted, padded) + return hex.EncodeToString(iv) + ":" + hex.EncodeToString(encrypted), nil +} + +// Decrypt reverses Encrypt. It accepts ciphertexts produced by either the Go +// implementation or the TypeScript @ctrlplane/secrets package. +func (a *AES256CBC) Decrypt(ciphertext string) (string, error) { + ivHex, encHex, ok := strings.Cut(ciphertext, ":") + if !ok { + return "", errors.New("aes256cbc: invalid encrypted data") + } + iv, err := hex.DecodeString(ivHex) + if err != nil { + return "", fmt.Errorf("aes256cbc: invalid iv: %w", err) + } + if len(iv) != ivByteLen { + return "", fmt.Errorf("aes256cbc: iv must be %d bytes, got %d", ivByteLen, len(iv)) + } + enc, err := hex.DecodeString(encHex) + if err != nil { + return "", fmt.Errorf("aes256cbc: invalid ciphertext: %w", err) + } + block, err := aes.NewCipher(a.key) + if err != nil { + return "", fmt.Errorf("aes256cbc: cipher init: %w", err) + } + if len(enc) == 0 || len(enc)%block.BlockSize() != 0 { + return "", errors.New("aes256cbc: ciphertext length is not a multiple of block size") + } + decrypted := make([]byte, len(enc)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(decrypted, enc) + unpadded, err := pkcs7Unpad(decrypted, block.BlockSize()) + if err != nil { + return "", err + } + return string(unpadded), nil +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + padLen := blockSize - len(data)%blockSize + padded := make([]byte, len(data)+padLen) + copy(padded, data) + for i := len(data); i < len(padded); i++ { + padded[i] = byte(padLen) + } + return padded +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, errors.New("aes256cbc: padded data is not a multiple of block size") + } + padLen := int(data[len(data)-1]) + if padLen == 0 || padLen > blockSize { + return nil, errors.New("aes256cbc: invalid padding length") + } + for i := len(data) - padLen; i < len(data); i++ { + if int(data[i]) != padLen { + return nil, errors.New("aes256cbc: invalid padding bytes") + } + } + return data[:len(data)-padLen], nil +} diff --git a/apps/workspace-engine/pkg/crypto/aes256cbc_test.go b/apps/workspace-engine/pkg/crypto/aes256cbc_test.go new file mode 100644 index 000000000..4c94b0ce0 --- /dev/null +++ b/apps/workspace-engine/pkg/crypto/aes256cbc_test.go @@ -0,0 +1,137 @@ +package crypto + +import ( + "strings" + "testing" +) + +const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +// tsFixtures are ciphertexts produced by the TypeScript @ctrlplane/secrets +// package using testKey. Any drift from byte-level interop will break these. +var tsFixtures = []struct { + plaintext string + ciphertext string +}{ + { + plaintext: "hello", + ciphertext: "c72e33c634ce0cb81e16e2dea3ae8ece:b8d32739794be620178e9b0c8b314969", + }, + { + plaintext: "", + ciphertext: "a47023812378a4bf2e3986f931cd7a6d:3ad36d456286bc05ad86a354b0cb6822", + }, + { + plaintext: "a longer plaintext with spaces and punctuation, including 0123456789!", + ciphertext: "bbd1fb174926cf4d5ee3c71a9020af37:748ddb2465eee4bd20829a37c974086b2ed5b68ec7f55d53beb7a91b1e42025aa4a58e35af5a303d7e5e7ec5ade14ad6708da09189c7c0150d515f9eb9d2d602c735fc7d8ecb3fd36a6f894f6174f12f", + }, + { + plaintext: `{"serviceToken":"dp.st.abcdef","region":"us-east-1"}`, + ciphertext: "4eddf95c6ed56c14b0a34c66468ffefb:fab54199d89ffc29df9c4ab62160538a4d2f8c4decfcdce8065a0a6334109562982f87e8d364c9b43eb8c54b45e98732d6853cce381f1b1a9b21cd8f1490f3e8", + }, +} + +func TestDecryptInteropWithTypeScript(t *testing.T) { + svc, err := New(testKey) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, f := range tsFixtures { + got, err := svc.Decrypt(f.ciphertext) + if err != nil { + t.Fatalf("Decrypt(%q): %v", f.ciphertext, err) + } + if got != f.plaintext { + t.Fatalf("Decrypt(%q): want %q, got %q", f.ciphertext, f.plaintext, got) + } + } +} + +func TestRoundTrip(t *testing.T) { + svc, err := New(testKey) + if err != nil { + t.Fatalf("New: %v", err) + } + cases := []string{ + "", + "x", + "hello", + strings.Repeat("a", 16), + strings.Repeat("b", 17), + strings.Repeat("c", 31), + strings.Repeat("d", 32), + `{"key":"value","nested":{"a":1}}`, + } + for _, c := range cases { + ct, err := svc.Encrypt(c) + if err != nil { + t.Fatalf("Encrypt(%q): %v", c, err) + } + if !strings.Contains(ct, ":") { + t.Fatalf("Encrypt(%q): ciphertext missing iv separator: %s", c, ct) + } + pt, err := svc.Decrypt(ct) + if err != nil { + t.Fatalf("Decrypt(%q): %v", ct, err) + } + if pt != c { + t.Fatalf("round trip: want %q, got %q", c, pt) + } + } +} + +func TestEncryptUniqueIV(t *testing.T) { + svc, err := New(testKey) + if err != nil { + t.Fatalf("New: %v", err) + } + ct1, err := svc.Encrypt("same plaintext") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + ct2, err := svc.Encrypt("same plaintext") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if ct1 == ct2 { + t.Fatal( + "two encryptions of the same plaintext produced identical ciphertext (IV not random)", + ) + } +} + +func TestNewRejectsBadKey(t *testing.T) { + cases := []string{ + "", + "too short", + strings.Repeat("z", 64), // not hex + strings.Repeat("a", 63), + strings.Repeat("a", 65), + } + for _, c := range cases { + if _, err := New(c); err == nil { + t.Fatalf("New(%q) expected error", c) + } + } +} + +func TestDecryptRejectsMalformed(t *testing.T) { + svc, err := New(testKey) + if err != nil { + t.Fatalf("New: %v", err) + } + cases := []string{ + "", + "no-separator", + "deadbeef", + ":onlysepatstart", + "abc:", + "zz:zz", + "00112233445566778899aabbccddeeff:zz", + } + for _, c := range cases { + if _, err := svc.Decrypt(c); err == nil { + t.Fatalf("Decrypt(%q) expected error", c) + } + } +} diff --git a/apps/workspace-engine/pkg/db/convert.go b/apps/workspace-engine/pkg/db/convert.go index e566bba14..4ae175f91 100644 --- a/apps/workspace-engine/pkg/db/convert.go +++ b/apps/workspace-engine/pkg/db/convert.go @@ -302,6 +302,7 @@ type VariableValueAggRow struct { SecretProvider *string `json:"secretProvider"` SecretKey *string `json:"secretKey"` SecretPath []string `json:"secretPath"` + SecretVersion *string `json:"secretVersion"` } func flattenVariableValue(r VariableValueAggRow) (oapi.Value, error) { @@ -324,7 +325,21 @@ func flattenVariableValue(r VariableValueAggRow) (oapi.Value, error) { return v, err } case "secret_ref": - return v, fmt.Errorf("secret_ref variable values are not yet supported") + sr := oapi.SecretReferenceValue{ + SecretProvider: derefString(r.SecretProvider), + SecretKey: derefString(r.SecretKey), + } + if len(r.SecretPath) > 0 { + path := append([]string(nil), r.SecretPath...) + sr.SecretPath = &path + } + if r.SecretVersion != nil && *r.SecretVersion != "" { + version := *r.SecretVersion + sr.SecretVersion = &version + } + if err := v.FromSecretReferenceValue(sr); err != nil { + return v, err + } default: return v, fmt.Errorf("unknown variable_value kind: %q", r.Kind) } @@ -352,6 +367,23 @@ func ToOapiDeploymentVariable( return v } +// ToOapiJobAgentVariable maps a job_agent-scoped variable row to the same +// oapi.DeploymentVariable shape used by deployment variables, so the +// variableresolver can run a single resolution pipeline regardless of scope. +// DeploymentId is left empty since the variable does not belong to one. +func ToOapiJobAgentVariable( + row ListVariablesWithValuesByJobAgentIDRow, +) oapi.DeploymentVariable { + v := oapi.DeploymentVariable{ + Id: row.ID.String(), + Key: row.Key, + } + if row.Description.Valid { + v.Description = &row.Description.String + } + return v +} + func ToOapiDeploymentVariableValueFromAgg( r VariableValueAggRow, ) (oapi.DeploymentVariableValue, error) { diff --git a/apps/workspace-engine/pkg/db/convert_test.go b/apps/workspace-engine/pkg/db/convert_test.go index 5412c9b84..b70e687d6 100644 --- a/apps/workspace-engine/pkg/db/convert_test.go +++ b/apps/workspace-engine/pkg/db/convert_test.go @@ -262,9 +262,10 @@ func TestFlattenVariableValue_Ref(t *testing.T) { assert.Equal(t, []string{"host"}, rv.Path) } -func TestFlattenVariableValue_SecretRefUnsupported(t *testing.T) { +func TestFlattenVariableValue_SecretRef(t *testing.T) { provider := "vault" key := "kv/data/prod/db" + path := []string{"backend", "production"} agg := VariableValueAggRow{ ID: uuid.New(), VariableID: uuid.New(), @@ -272,9 +273,16 @@ func TestFlattenVariableValue_SecretRefUnsupported(t *testing.T) { Kind: "secret_ref", SecretProvider: &provider, SecretKey: &key, + SecretPath: path, } - _, err := flattenVariableValue(agg) - require.Error(t, err) + v, err := flattenVariableValue(agg) + require.NoError(t, err) + srv, err := v.AsSecretReferenceValue() + require.NoError(t, err) + assert.Equal(t, provider, srv.SecretProvider) + assert.Equal(t, key, srv.SecretKey) + require.NotNil(t, srv.SecretPath) + assert.Equal(t, path, *srv.SecretPath) } func TestToOapiDeploymentVariableValueFromAgg_CELSelector(t *testing.T) { diff --git a/apps/workspace-engine/pkg/db/models.go b/apps/workspace-engine/pkg/db/models.go index 35991c538..9b0fe9cb2 100644 --- a/apps/workspace-engine/pkg/db/models.go +++ b/apps/workspace-engine/pkg/db/models.go @@ -283,6 +283,49 @@ func (ns NullJobVerificationTriggerOn) Value() (driver.Value, error) { return string(ns.JobVerificationTriggerOn), nil } +type SecretProviderType string + +const ( + SecretProviderTypeAwsSecretsManager SecretProviderType = "aws_secrets_manager" + SecretProviderTypeDoppler SecretProviderType = "doppler" + SecretProviderTypeEnv SecretProviderType = "env" +) + +func (e *SecretProviderType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = SecretProviderType(s) + case string: + *e = SecretProviderType(s) + default: + return fmt.Errorf("unsupported scan type for SecretProviderType: %T", src) + } + return nil +} + +type NullSecretProviderType struct { + SecretProviderType SecretProviderType + Valid bool // Valid is true if SecretProviderType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullSecretProviderType) Scan(value interface{}) error { + if value == nil { + ns.SecretProviderType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.SecretProviderType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullSecretProviderType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.SecretProviderType), nil +} + type VariableScope string const ( @@ -761,6 +804,16 @@ type ResourceProvider struct { Metadata map[string]string } +type SecretProvider struct { + ID uuid.UUID + WorkspaceID uuid.UUID + Name string + Type SecretProviderType + Config []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type System struct { ID uuid.UUID Name string @@ -834,6 +887,7 @@ type VariableValue struct { SecretProvider pgtype.Text SecretKey pgtype.Text SecretPath []string + SecretVersion pgtype.Text CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz } diff --git a/apps/workspace-engine/pkg/db/queries/schema.sql b/apps/workspace-engine/pkg/db/queries/schema.sql index df5190d0a..25c943716 100644 --- a/apps/workspace-engine/pkg/db/queries/schema.sql +++ b/apps/workspace-engine/pkg/db/queries/schema.sql @@ -519,6 +519,7 @@ CREATE TABLE variable_value ( secret_provider TEXT, secret_key TEXT, secret_path TEXT[], + secret_version TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -548,4 +549,17 @@ CREATE TABLE deployment_plan_target_result_validation ( violations JSONB NOT NULL DEFAULT '[]'::jsonb, evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (result_id, rule_id) -); \ No newline at end of file +); + +CREATE TYPE secret_provider_type AS ENUM ('aws_secrets_manager', 'doppler', 'env'); + +CREATE TABLE secret_provider ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type secret_provider_type NOT NULL, + config BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (workspace_id, name) +); diff --git a/apps/workspace-engine/pkg/db/queries/secret_providers.sql b/apps/workspace-engine/pkg/db/queries/secret_providers.sql new file mode 100644 index 000000000..801cdd7bd --- /dev/null +++ b/apps/workspace-engine/pkg/db/queries/secret_providers.sql @@ -0,0 +1,10 @@ +-- name: GetSecretProviderByName :one +SELECT id, workspace_id, name, type, config, created_at, updated_at +FROM secret_provider +WHERE workspace_id = $1 AND name = $2; + +-- name: ListSecretProvidersByWorkspaceID :many +SELECT id, workspace_id, name, type, config, created_at, updated_at +FROM secret_provider +WHERE workspace_id = $1 +ORDER BY name; diff --git a/apps/workspace-engine/pkg/db/queries/variables.sql b/apps/workspace-engine/pkg/db/queries/variables.sql index 3d26b412b..077be2da8 100644 --- a/apps/workspace-engine/pkg/db/queries/variables.sql +++ b/apps/workspace-engine/pkg/db/queries/variables.sql @@ -22,7 +22,8 @@ SELECT 'refPath', vv.ref_path, 'secretProvider', vv.secret_provider, 'secretKey', vv.secret_key, - 'secretPath', vv.secret_path + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version ) ORDER BY vv.priority DESC, vv.id ASC ) @@ -58,7 +59,8 @@ SELECT 'refPath', vv.ref_path, 'secretProvider', vv.secret_provider, 'secretKey', vv.secret_key, - 'secretPath', vv.secret_path + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version ) ORDER BY vv.priority DESC, vv.id ASC ) @@ -91,7 +93,8 @@ SELECT 'refPath', vv.ref_path, 'secretProvider', vv.secret_provider, 'secretKey', vv.secret_key, - 'secretPath', vv.secret_path + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version ) ORDER BY vv.priority DESC, vv.id ASC ) @@ -103,3 +106,40 @@ SELECT FROM variable v INNER JOIN resource r ON r.id = v.resource_id WHERE v.scope = 'resource' AND r.workspace_id = $1 AND r.deleted_at IS NULL; + +-- name: ListVariablesWithValuesByJobAgentID :many +SELECT + v.id, + v.scope, + v.deployment_id, + v.resource_id, + v.job_agent_id, + v.key, + v.is_sensitive, + v.description, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', vv.id, + 'variableId', vv.variable_id, + 'resourceSelector', vv.resource_selector, + 'priority', vv.priority, + 'kind', vv.kind, + 'literalValue', vv.literal_value, + 'refKey', vv.ref_key, + 'refPath', vv.ref_path, + 'secretProvider', vv.secret_provider, + 'secretKey', vv.secret_key, + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version + ) + ORDER BY vv.priority DESC, vv.id ASC + ) + FROM variable_value vv + WHERE vv.variable_id = v.id + ), + '[]'::json + ) AS values +FROM variable v +WHERE v.scope = 'job_agent' AND v.job_agent_id = $1; diff --git a/apps/workspace-engine/pkg/db/secret_providers.sql.go b/apps/workspace-engine/pkg/db/secret_providers.sql.go new file mode 100644 index 000000000..b60927a39 --- /dev/null +++ b/apps/workspace-engine/pkg/db/secret_providers.sql.go @@ -0,0 +1,73 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: secret_providers.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const getSecretProviderByName = `-- name: GetSecretProviderByName :one +SELECT id, workspace_id, name, type, config, created_at, updated_at +FROM secret_provider +WHERE workspace_id = $1 AND name = $2 +` + +type GetSecretProviderByNameParams struct { + WorkspaceID uuid.UUID + Name string +} + +func (q *Queries) GetSecretProviderByName(ctx context.Context, arg GetSecretProviderByNameParams) (SecretProvider, error) { + row := q.db.QueryRow(ctx, getSecretProviderByName, arg.WorkspaceID, arg.Name) + var i SecretProvider + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Type, + &i.Config, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listSecretProvidersByWorkspaceID = `-- name: ListSecretProvidersByWorkspaceID :many +SELECT id, workspace_id, name, type, config, created_at, updated_at +FROM secret_provider +WHERE workspace_id = $1 +ORDER BY name +` + +func (q *Queries) ListSecretProvidersByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]SecretProvider, error) { + rows, err := q.db.Query(ctx, listSecretProvidersByWorkspaceID, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SecretProvider + for rows.Next() { + var i SecretProvider + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Type, + &i.Config, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/apps/workspace-engine/pkg/db/sqlc.yaml b/apps/workspace-engine/pkg/db/sqlc.yaml index 56670b9fd..036f4aa7b 100644 --- a/apps/workspace-engine/pkg/db/sqlc.yaml +++ b/apps/workspace-engine/pkg/db/sqlc.yaml @@ -33,6 +33,7 @@ sql: - queries/release_targets.sql - queries/variable_sets.sql - queries/plan_validation.sql + - queries/secret_providers.sql database: uri: "postgresql://ctrlplane:ctrlplane@127.0.0.1:5432/ctrlplane?sslmode=disable" gen: diff --git a/apps/workspace-engine/pkg/db/variables.sql.go b/apps/workspace-engine/pkg/db/variables.sql.go index 0d170f890..474a6219d 100644 --- a/apps/workspace-engine/pkg/db/variables.sql.go +++ b/apps/workspace-engine/pkg/db/variables.sql.go @@ -33,7 +33,8 @@ SELECT 'refPath', vv.ref_path, 'secretProvider', vv.secret_provider, 'secretKey', vv.secret_key, - 'secretPath', vv.secret_path + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version ) ORDER BY vv.priority DESC, vv.id ASC ) @@ -107,7 +108,8 @@ SELECT 'refPath', vv.ref_path, 'secretProvider', vv.secret_provider, 'secretKey', vv.secret_key, - 'secretPath', vv.secret_path + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version ) ORDER BY vv.priority DESC, vv.id ASC ) @@ -162,6 +164,86 @@ func (q *Queries) ListVariablesWithValuesByDeploymentID(ctx context.Context, dep return items, nil } +const listVariablesWithValuesByJobAgentID = `-- name: ListVariablesWithValuesByJobAgentID :many +SELECT + v.id, + v.scope, + v.deployment_id, + v.resource_id, + v.job_agent_id, + v.key, + v.is_sensitive, + v.description, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', vv.id, + 'variableId', vv.variable_id, + 'resourceSelector', vv.resource_selector, + 'priority', vv.priority, + 'kind', vv.kind, + 'literalValue', vv.literal_value, + 'refKey', vv.ref_key, + 'refPath', vv.ref_path, + 'secretProvider', vv.secret_provider, + 'secretKey', vv.secret_key, + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version + ) + ORDER BY vv.priority DESC, vv.id ASC + ) + FROM variable_value vv + WHERE vv.variable_id = v.id + ), + '[]'::json + ) AS values +FROM variable v +WHERE v.scope = 'job_agent' AND v.job_agent_id = $1 +` + +type ListVariablesWithValuesByJobAgentIDRow struct { + ID uuid.UUID + Scope VariableScope + DeploymentID uuid.UUID + ResourceID uuid.UUID + JobAgentID uuid.UUID + Key string + IsSensitive bool + Description pgtype.Text + Values []byte +} + +func (q *Queries) ListVariablesWithValuesByJobAgentID(ctx context.Context, jobAgentID uuid.UUID) ([]ListVariablesWithValuesByJobAgentIDRow, error) { + rows, err := q.db.Query(ctx, listVariablesWithValuesByJobAgentID, jobAgentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListVariablesWithValuesByJobAgentIDRow + for rows.Next() { + var i ListVariablesWithValuesByJobAgentIDRow + if err := rows.Scan( + &i.ID, + &i.Scope, + &i.DeploymentID, + &i.ResourceID, + &i.JobAgentID, + &i.Key, + &i.IsSensitive, + &i.Description, + &i.Values, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listVariablesWithValuesByResourceID = `-- name: ListVariablesWithValuesByResourceID :many SELECT v.id, @@ -186,7 +268,8 @@ SELECT 'refPath', vv.ref_path, 'secretProvider', vv.secret_provider, 'secretKey', vv.secret_key, - 'secretPath', vv.secret_path + 'secretPath', vv.secret_path, + 'secretVersion', vv.secret_version ) ORDER BY vv.priority DESC, vv.id ASC ) diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index a6be17e8b..bde7708ea 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -400,16 +400,19 @@ type DispatchContext struct { Environment *Environment `json:"environment,omitempty"` // Inputs Resolved input values for the workflow run. - Inputs *map[string]interface{} `json:"inputs,omitempty"` - JobAgent JobAgent `json:"jobAgent"` - JobAgentConfig JobAgentConfig `json:"jobAgentConfig"` - Release *Release `json:"release,omitempty"` - Resource *Resource `json:"resource,omitempty"` - Variables *map[string]LiteralValue `json:"variables,omitempty"` - Version *DeploymentVersion `json:"version,omitempty"` - Workflow *Workflow `json:"workflow,omitempty"` - WorkflowJob *WorkflowJob `json:"workflowJob,omitempty"` - WorkflowRun *WorkflowRun `json:"workflowRun,omitempty"` + Inputs *map[string]interface{} `json:"inputs,omitempty"` + JobAgent JobAgent `json:"jobAgent"` + JobAgentConfig JobAgentConfig `json:"jobAgentConfig"` + + // JobAgentVariables Variables scoped to the dispatching job agent. Resolved at dispatch time and referenced from agent-config templates as {{ .jobAgentVariables. }}. + JobAgentVariables *map[string]LiteralValue `json:"jobAgentVariables,omitempty"` + Release *Release `json:"release,omitempty"` + Resource *Resource `json:"resource,omitempty"` + Variables *map[string]LiteralValue `json:"variables,omitempty"` + Version *DeploymentVersion `json:"version,omitempty"` + Workflow *Workflow `json:"workflow,omitempty"` + WorkflowJob *WorkflowJob `json:"workflowJob,omitempty"` + WorkflowRun *WorkflowRun `json:"workflowRun,omitempty"` } // EntityRelation defines model for EntityRelation. @@ -1156,6 +1159,21 @@ type RuleEvaluation struct { // RuleEvaluationActionType Type of action required type RuleEvaluationActionType string +// SecretReferenceValue defines model for SecretReferenceValue. +type SecretReferenceValue struct { + // SecretKey Secret key within the provider + SecretKey string `json:"secretKey"` + + // SecretPath Optional provider-specific path components + SecretPath *[]string `json:"secretPath,omitempty"` + + // SecretProvider Workspace-unique secret_provider.name + SecretProvider string `json:"secretProvider"` + + // SecretVersion Optional provider-specific version pin. For AWS Secrets Manager this maps to VersionId (uuid form) or VersionStage (AWSCURRENT/AWSPREVIOUS). For Doppler this maps to accept_secret_version. Empty means latest. + SecretVersion *string `json:"secretVersion,omitempty"` +} + // SensitiveValue defines model for SensitiveValue. type SensitiveValue struct { ValueHash string `json:"valueHash"` @@ -2336,6 +2354,32 @@ func (t *Value) MergeSensitiveValue(v SensitiveValue) error { return err } +// AsSecretReferenceValue returns the union data inside the Value as a SecretReferenceValue +func (t Value) AsSecretReferenceValue() (SecretReferenceValue, error) { + var body SecretReferenceValue + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromSecretReferenceValue overwrites any union data inside the Value as the provided SecretReferenceValue +func (t *Value) FromSecretReferenceValue(v SecretReferenceValue) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeSecretReferenceValue performs a merge with any union data inside the Value, using the provided SecretReferenceValue +func (t *Value) MergeSecretReferenceValue(v SecretReferenceValue) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + func (t Value) MarshalJSON() ([]byte, error) { b, err := t.union.MarshalJSON() return b, err diff --git a/apps/workspace-engine/pkg/oapi/oapi.go b/apps/workspace-engine/pkg/oapi/oapi.go index ad406d8a1..f718fa5df 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.go +++ b/apps/workspace-engine/pkg/oapi/oapi.go @@ -94,6 +94,14 @@ func (j *Job) IsInTerminalState() bool { } func (v *Value) GetType() (string, error) { + // Try SecretReferenceValue first — its required field set is the most + // specific so a positive match leaves no ambiguity. + if srv, err := v.AsSecretReferenceValue(); err == nil { + if srv.SecretProvider != "" && srv.SecretKey != "" { + return "secret_ref", nil + } + } + // Try ReferenceValue - check that required fields are present if rv, err := v.AsReferenceValue(); err == nil { if rv.Reference != "" && rv.Path != nil { diff --git a/apps/workspace-engine/pkg/secrets/awssm/provider.go b/apps/workspace-engine/pkg/secrets/awssm/provider.go new file mode 100644 index 000000000..71d0a4fcb --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/awssm/provider.go @@ -0,0 +1,159 @@ +// Package awssm implements a secrets.Provider backed by AWS Secrets Manager. +// +// SecretReference shape: +// +// Provider: secret_provider.name in the workspace +// Path: secret name or ARN (e.g. "prod/db" or +// "arn:aws:secretsmanager:us-east-1:123:secret:prod/db-AbCdEf") +// Key: optional. If empty, the full SecretString is returned. If set, +// the SecretString is treated as JSON and the named field is +// extracted via gjson (dotted paths supported). +package awssm + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/tidwall/gjson" + "workspace-engine/pkg/secrets" +) + +const Type = "aws_secrets_manager" + +// Config is the decrypted config payload for an aws_secrets_manager row. +// AccessKeyID + SecretAccessKey are both optional, but if one is set the +// other must be too. When both are absent the SDK's default credential chain +// is used (IRSA / instance role / shared config / env). +type Config struct { + Region string `json:"region"` + AccessKeyID string `json:"accessKeyId,omitempty"` + SecretAccessKey string `json:"secretAccessKey,omitempty"` +} + +func (c Config) validate() error { + if c.Region == "" { + return fmt.Errorf("awssm provider: region is required") + } + if (c.AccessKeyID == "") != (c.SecretAccessKey == "") { + return fmt.Errorf( + "awssm provider: accessKeyId and secretAccessKey must both be set or both omitted", + ) + } + return nil +} + +// secretsClient is the subset of secretsmanager.Client the provider uses. +// Tests substitute a fake implementation; production uses the real SDK client. +type secretsClient interface { + GetSecretValue( + ctx context.Context, + params *secretsmanager.GetSecretValueInput, + optFns ...func(*secretsmanager.Options), + ) (*secretsmanager.GetSecretValueOutput, error) +} + +type Provider struct { + client secretsClient +} + +// Factory matches secrets.ProviderFactory. +func Factory(raw json.RawMessage) (secrets.Provider, error) { + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("awssm provider: parse config: %w", err) + } + if err := cfg.validate(); err != nil { + return nil, err + } + awsCfg, err := buildAWSConfig(context.Background(), cfg) + if err != nil { + return nil, err + } + return &Provider{client: secretsmanager.NewFromConfig(awsCfg)}, nil +} + +func buildAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { + loadOpts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(cfg.Region)} + if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { + loadOpts = append( + loadOpts, + awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + ), + ) + } + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOpts...) + if err != nil { + return aws.Config{}, fmt.Errorf("awssm provider: load AWS config: %w", err) + } + return awsCfg, nil +} + +func (*Provider) Type() string { return Type } + +// isVersionStage returns true if version names a Secrets Manager +// VersionStage label rather than a VersionId. AWS-defined stages are +// AWSCURRENT, AWSPREVIOUS, and AWSPENDING; user-defined stages can be any +// label up to 64 characters but cannot start with "AWS" unless they are +// the AWS-defined ones above. We treat any value that starts with "AWS" +// (case-insensitive) as a stage; UUIDs and other arbitrary identifiers +// fall through to VersionId. Callers can also opt-in with a sentinel +// prefix "stage:" (e.g. "stage:my-custom-stage") for user-defined stages. +func isVersionStage(version string) bool { + const stagePrefix = "stage:" + if strings.HasPrefix(version, stagePrefix) { + return true + } + return strings.HasPrefix(strings.ToUpper(version), "AWS") +} + +func (p *Provider) Resolve(ctx context.Context, ref secrets.SecretReference) (string, error) { + if ref.Path == "" { + return "", fmt.Errorf( + "awssm provider: SecretReference.Path is required (secret name or ARN)", + ) + } + + input := &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(ref.Path), + } + if ref.Version != "" { + // AWS Secrets Manager distinguishes VersionId (a UUID identifying a + // specific historical version) from VersionStage (a label like + // AWSCURRENT or AWSPREVIOUS). All-uppercase labels starting with + // "AWS" are treated as stages; everything else as a version id. + if isVersionStage(ref.Version) { + input.VersionStage = aws.String(strings.TrimPrefix(ref.Version, "stage:")) + } else { + input.VersionId = aws.String(ref.Version) + } + } + + out, err := p.client.GetSecretValue(ctx, input) + if err != nil { + return "", fmt.Errorf("awssm provider: GetSecretValue %s: %w", ref.Path, err) + } + if out.SecretString == nil { + return "", fmt.Errorf("awssm provider: secret %s has no SecretString payload", ref.Path) + } + + if ref.Key == "" { + return *out.SecretString, nil + } + + r := gjson.Get(*out.SecretString, ref.Key) + if !r.Exists() { + return "", fmt.Errorf( + "awssm provider: secret %s has no JSON field %q", + ref.Path, + ref.Key, + ) + } + return r.String(), nil +} diff --git a/apps/workspace-engine/pkg/secrets/awssm/provider_test.go b/apps/workspace-engine/pkg/secrets/awssm/provider_test.go new file mode 100644 index 000000000..42472d5c3 --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/awssm/provider_test.go @@ -0,0 +1,254 @@ +package awssm + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "workspace-engine/pkg/secrets" +) + +type fakeClient struct { + in *secretsmanager.GetSecretValueInput + out *secretsmanager.GetSecretValueOutput + err error +} + +func (f *fakeClient) GetSecretValue( + _ context.Context, + in *secretsmanager.GetSecretValueInput, + _ ...func(*secretsmanager.Options), +) (*secretsmanager.GetSecretValueOutput, error) { + f.in = in + if f.err != nil { + return nil, f.err + } + return f.out, nil +} + +func mustMarshal(t *testing.T, cfg Config) json.RawMessage { + t.Helper() + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + return raw +} + +func TestResolveReturnsRawSecretStringWhenKeyEmpty(t *testing.T) { + fc := &fakeClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String("raw-value"), + }, + } + p := &Provider{client: fc} + + got, err := p.Resolve(context.Background(), secrets.SecretReference{Path: "prod/db"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "raw-value" { + t.Fatalf("got %q want raw-value", got) + } + if fc.in.SecretId == nil || *fc.in.SecretId != "prod/db" { + t.Fatalf("unexpected SecretId %v", fc.in.SecretId) + } +} + +func TestResolveVersionId(t *testing.T) { + fc := &fakeClient{ + out: &secretsmanager.GetSecretValueOutput{SecretString: aws.String("v")}, + } + p := &Provider{client: fc} + + _, err := p.Resolve(context.Background(), secrets.SecretReference{ + Path: "prod/db", + Version: "ab12cd34-ef56-7890-abcd-ef1234567890", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if fc.in.VersionId == nil || *fc.in.VersionId != "ab12cd34-ef56-7890-abcd-ef1234567890" { + t.Fatalf("expected VersionId to be set, got %+v", fc.in.VersionId) + } + if fc.in.VersionStage != nil { + t.Fatalf("VersionStage must not be set for a UUID version, got %q", *fc.in.VersionStage) + } +} + +func TestResolveVersionStageAWSCURRENT(t *testing.T) { + fc := &fakeClient{ + out: &secretsmanager.GetSecretValueOutput{SecretString: aws.String("v")}, + } + p := &Provider{client: fc} + + _, err := p.Resolve(context.Background(), secrets.SecretReference{ + Path: "prod/db", + Version: "AWSCURRENT", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if fc.in.VersionStage == nil || *fc.in.VersionStage != "AWSCURRENT" { + t.Fatalf("expected VersionStage=AWSCURRENT, got %+v", fc.in.VersionStage) + } + if fc.in.VersionId != nil { + t.Fatalf("VersionId must not be set for a stage, got %q", *fc.in.VersionId) + } +} + +func TestResolveVersionUserStageWithPrefix(t *testing.T) { + fc := &fakeClient{ + out: &secretsmanager.GetSecretValueOutput{SecretString: aws.String("v")}, + } + p := &Provider{client: fc} + + _, err := p.Resolve(context.Background(), secrets.SecretReference{ + Path: "prod/db", + Version: "stage:custom-pin", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if fc.in.VersionStage == nil || *fc.in.VersionStage != "custom-pin" { + t.Fatalf("expected VersionStage=custom-pin, got %+v", fc.in.VersionStage) + } +} + +func TestResolveNoVersionPassthrough(t *testing.T) { + fc := &fakeClient{ + out: &secretsmanager.GetSecretValueOutput{SecretString: aws.String("v")}, + } + p := &Provider{client: fc} + + _, err := p.Resolve(context.Background(), secrets.SecretReference{Path: "prod/db"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if fc.in.VersionId != nil { + t.Fatalf("VersionId must be nil when no version specified") + } + if fc.in.VersionStage != nil { + t.Fatalf("VersionStage must be nil when no version specified") + } +} + +func TestResolveExtractsJSONFieldByKey(t *testing.T) { + fc := &fakeClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String( + `{"username":"app","password":"hunter2","nested":{"k":"v"}}`, + ), + }, + } + p := &Provider{client: fc} + + got, err := p.Resolve(context.Background(), secrets.SecretReference{ + Path: "prod/db", + Key: "password", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "hunter2" { + t.Fatalf("got %q want hunter2", got) + } + + got, err = p.Resolve(context.Background(), secrets.SecretReference{ + Path: "prod/db", + Key: "nested.k", + }) + if err != nil { + t.Fatalf("Resolve nested: %v", err) + } + if got != "v" { + t.Fatalf("nested got %q want v", got) + } +} + +func TestResolveMissingJSONField(t *testing.T) { + fc := &fakeClient{ + out: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`{"username":"app"}`), + }, + } + p := &Provider{client: fc} + + _, err := p.Resolve(context.Background(), secrets.SecretReference{ + Path: "prod/db", + Key: "password", + }) + if err == nil { + t.Fatal("expected error for missing JSON field") + } +} + +func TestResolveEmptyPathRejected(t *testing.T) { + p := &Provider{client: &fakeClient{}} + _, err := p.Resolve(context.Background(), secrets.SecretReference{Key: "K"}) + if err == nil { + t.Fatal("expected error for empty Path") + } +} + +func TestResolveNoSecretStringPayload(t *testing.T) { + fc := &fakeClient{out: &secretsmanager.GetSecretValueOutput{}} + p := &Provider{client: fc} + _, err := p.Resolve(context.Background(), secrets.SecretReference{Path: "prod/db"}) + if err == nil { + t.Fatal("expected error when SecretString is nil") + } +} + +func TestResolveUpstreamErrorPropagates(t *testing.T) { + fc := &fakeClient{err: errors.New("AccessDenied")} + p := &Provider{client: fc} + _, err := p.Resolve(context.Background(), secrets.SecretReference{Path: "prod/db"}) + if err == nil { + t.Fatal("expected error to propagate") + } +} + +func TestFactoryRejectsBadConfigs(t *testing.T) { + cases := []struct { + name string + raw json.RawMessage + }{ + {"not json", json.RawMessage(`not-json`)}, + {"missing region", mustMarshal(t, Config{})}, + { + "partial creds (key only)", + mustMarshal(t, Config{Region: "us-east-1", AccessKeyID: "AKIA..."}), + }, + { + "partial creds (secret only)", + mustMarshal(t, Config{Region: "us-east-1", SecretAccessKey: "secret"}), + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := Factory(c.raw); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestFactoryAcceptsRegionOnly(t *testing.T) { + if _, err := Factory(mustMarshal(t, Config{Region: "us-east-1"})); err != nil { + t.Fatalf("Factory: %v", err) + } +} + +func TestFactoryAcceptsStaticCreds(t *testing.T) { + if _, err := Factory(mustMarshal(t, Config{ + Region: "us-east-1", + AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + })); err != nil { + t.Fatalf("Factory: %v", err) + } +} diff --git a/apps/workspace-engine/pkg/secrets/cache.go b/apps/workspace-engine/pkg/secrets/cache.go new file mode 100644 index 000000000..2f1ec2aee --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/cache.go @@ -0,0 +1,114 @@ +package secrets + +import ( + "sync" + "time" + + "github.com/google/uuid" +) + +// cacheKey identifies a single resolved secret value. Path is normalized to +// "" when absent so that ":::" and "::/:" +// don't collide. +type cacheKey struct { + WorkspaceID uuid.UUID + Provider string + Path string + Key string + Version string +} + +type cacheEntry struct { + value string + expiresAt time.Time +} + +// Cache is a goroutine-safe TTL cache for resolved secret values. Entries +// expire passively on read; explicit invalidation is provided for provider +// updates received over LISTEN/NOTIFY. +type Cache struct { + ttl time.Duration + now func() time.Time + mu sync.RWMutex + entries map[cacheKey]cacheEntry +} + +// NewCache constructs an empty cache. ttl of zero disables caching (every Get +// returns a miss). +func NewCache(ttl time.Duration) *Cache { + return &Cache{ + ttl: ttl, + now: time.Now, + entries: make(map[cacheKey]cacheEntry), + } +} + +func keyFor(workspaceID uuid.UUID, ref SecretReference) cacheKey { + return cacheKey{ + WorkspaceID: workspaceID, + Provider: ref.Provider, + Path: ref.Path, + Key: ref.Key, + Version: ref.Version, + } +} + +// Get returns the cached value if present and unexpired. The boolean return +// distinguishes a cache hit from a miss. +func (c *Cache) Get(workspaceID uuid.UUID, ref SecretReference) (string, bool) { + if c.ttl <= 0 { + return "", false + } + c.mu.RLock() + entry, ok := c.entries[keyFor(workspaceID, ref)] + c.mu.RUnlock() + if !ok { + return "", false + } + if c.now().After(entry.expiresAt) { + return "", false + } + return entry.value, true +} + +// Set stores a resolved value. The TTL is taken from the cache; per-entry +// TTLs are not supported. +func (c *Cache) Set(workspaceID uuid.UUID, ref SecretReference, value string) { + if c.ttl <= 0 { + return + } + c.mu.Lock() + c.entries[keyFor(workspaceID, ref)] = cacheEntry{ + value: value, + expiresAt: c.now().Add(c.ttl), + } + c.mu.Unlock() +} + +// InvalidateProvider drops every entry that resolves through the named +// provider in the given workspace. Called by the LISTEN/NOTIFY consumer when +// the TS api updates a secret_provider row. +func (c *Cache) InvalidateProvider(workspaceID uuid.UUID, providerName string) { + c.mu.Lock() + defer c.mu.Unlock() + for k := range c.entries { + if k.WorkspaceID == workspaceID && k.Provider == providerName { + delete(c.entries, k) + } + } +} + +// InvalidateAll empties the cache. Intended for tests and admin operations. +func (c *Cache) InvalidateAll() { + c.mu.Lock() + c.entries = make(map[cacheKey]cacheEntry) + c.mu.Unlock() +} + +// Size returns the number of entries currently cached. Expired entries that +// have not yet been observed by Get are counted. +func (c *Cache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.entries) +} diff --git a/apps/workspace-engine/pkg/secrets/cache_test.go b/apps/workspace-engine/pkg/secrets/cache_test.go new file mode 100644 index 000000000..28a21784f --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/cache_test.go @@ -0,0 +1,127 @@ +package secrets + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestCacheHitMiss(t *testing.T) { + c := NewCache(time.Minute) + ws := uuid.New() + ref := SecretReference{Provider: "doppler-prod", Path: "backend/prod", Key: "TOKEN"} + + if _, ok := c.Get(ws, ref); ok { + t.Fatal("expected miss on empty cache") + } + + c.Set(ws, ref, "secret-value") + + v, ok := c.Get(ws, ref) + if !ok || v != "secret-value" { + t.Fatalf("expected hit with value=%q, got ok=%v value=%q", "secret-value", ok, v) + } +} + +func TestCacheExpiry(t *testing.T) { + c := NewCache(time.Minute) + now := time.Unix(1_700_000_000, 0) + c.now = func() time.Time { return now } + + ws := uuid.New() + ref := SecretReference{Provider: "p", Key: "K"} + + c.Set(ws, ref, "v1") + + if _, ok := c.Get(ws, ref); !ok { + t.Fatal("expected hit immediately after Set") + } + + now = now.Add(time.Minute + time.Second) + if _, ok := c.Get(ws, ref); ok { + t.Fatal("expected miss after TTL expiry") + } +} + +func TestCacheDisabledWhenTTLZero(t *testing.T) { + c := NewCache(0) + ws := uuid.New() + ref := SecretReference{Provider: "p", Key: "K"} + + c.Set(ws, ref, "v1") + if _, ok := c.Get(ws, ref); ok { + t.Fatal("zero TTL must disable caching") + } + if c.Size() != 0 { + t.Fatalf("zero TTL must not store entries, got %d", c.Size()) + } +} + +func TestCacheInvalidateProvider(t *testing.T) { + c := NewCache(time.Minute) + wsA := uuid.New() + wsB := uuid.New() + + c.Set(wsA, SecretReference{Provider: "doppler-prod", Key: "K1"}, "a") + c.Set(wsA, SecretReference{Provider: "doppler-prod", Key: "K2"}, "b") + c.Set(wsA, SecretReference{Provider: "aws-prod", Key: "K3"}, "c") + c.Set(wsB, SecretReference{Provider: "doppler-prod", Key: "K1"}, "d") + + c.InvalidateProvider(wsA, "doppler-prod") + + if _, ok := c.Get(wsA, SecretReference{Provider: "doppler-prod", Key: "K1"}); ok { + t.Fatal("expected wsA/doppler-prod/K1 evicted") + } + if _, ok := c.Get(wsA, SecretReference{Provider: "doppler-prod", Key: "K2"}); ok { + t.Fatal("expected wsA/doppler-prod/K2 evicted") + } + if _, ok := c.Get(wsA, SecretReference{Provider: "aws-prod", Key: "K3"}); !ok { + t.Fatal("expected wsA/aws-prod/K3 retained") + } + if _, ok := c.Get(wsB, SecretReference{Provider: "doppler-prod", Key: "K1"}); !ok { + t.Fatal("expected wsB/doppler-prod/K1 retained (different workspace)") + } +} + +func TestCacheKeysDistinguishVersions(t *testing.T) { + c := NewCache(time.Minute) + ws := uuid.New() + + c.Set(ws, SecretReference{Provider: "p", Path: "x", Key: "K", Version: ""}, "latest") + c.Set(ws, SecretReference{Provider: "p", Path: "x", Key: "K", Version: "v1"}, "pinned-v1") + c.Set(ws, SecretReference{Provider: "p", Path: "x", Key: "K", Version: "v2"}, "pinned-v2") + + if v, _ := c.Get(ws, SecretReference{Provider: "p", Path: "x", Key: "K"}); v != "latest" { + t.Fatalf("latest: got %q want latest", v) + } + if v, _ := c.Get( + ws, + SecretReference{Provider: "p", Path: "x", Key: "K", Version: "v1"}, + ); v != "pinned-v1" { + t.Fatalf("v1: got %q want pinned-v1", v) + } + if v, _ := c.Get( + ws, + SecretReference{Provider: "p", Path: "x", Key: "K", Version: "v2"}, + ); v != "pinned-v2" { + t.Fatalf("v2: got %q want pinned-v2", v) + } +} + +func TestCacheKeysDistinguishPaths(t *testing.T) { + c := NewCache(time.Minute) + ws := uuid.New() + + c.Set(ws, SecretReference{Provider: "p", Path: "a", Key: "K"}, "value-a") + c.Set(ws, SecretReference{Provider: "p", Path: "b", Key: "K"}, "value-b") + + v, _ := c.Get(ws, SecretReference{Provider: "p", Path: "a", Key: "K"}) + if v != "value-a" { + t.Fatalf("path a: want value-a, got %q", v) + } + v, _ = c.Get(ws, SecretReference{Provider: "p", Path: "b", Key: "K"}) + if v != "value-b" { + t.Fatalf("path b: want value-b, got %q", v) + } +} diff --git a/apps/workspace-engine/pkg/secrets/doppler/provider.go b/apps/workspace-engine/pkg/secrets/doppler/provider.go new file mode 100644 index 000000000..f53561182 --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/doppler/provider.go @@ -0,0 +1,145 @@ +// Package doppler implements a secrets.Provider backed by the Doppler v3 API. +// +// SecretReference shape for Doppler: +// +// Provider: secret_provider.name in the workspace +// Path: "/" (e.g. "backend/production") +// Key: Doppler secret name within the config (e.g. "ARGOCD_TOKEN") +// +// The provider talks to https://api.doppler.com/v3/configs/config/secret with +// a service-token Bearer header. +package doppler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "workspace-engine/pkg/secrets" +) + +const ( + Type = "doppler" + defaultBaseURL = "https://api.doppler.com" + defaultTimeout = 10 * time.Second + tokenPrefix = "dp.st." +) + +// Config is the decrypted config payload for a doppler provider row. +type Config struct { + ServiceToken string `json:"serviceToken"` +} + +func (c Config) validate() error { + if c.ServiceToken == "" { + return fmt.Errorf("doppler provider: serviceToken is required") + } + if !strings.HasPrefix(c.ServiceToken, tokenPrefix) { + return fmt.Errorf("doppler provider: serviceToken must start with %q", tokenPrefix) + } + return nil +} + +type Provider struct { + serviceToken string + baseURL string + client *http.Client +} + +// Factory matches secrets.ProviderFactory. +func Factory(raw json.RawMessage) (secrets.Provider, error) { + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("doppler provider: parse config: %w", err) + } + if err := cfg.validate(); err != nil { + return nil, err + } + return &Provider{ + serviceToken: cfg.ServiceToken, + baseURL: defaultBaseURL, + client: &http.Client{Timeout: defaultTimeout}, + }, nil +} + +func (*Provider) Type() string { return Type } + +func (p *Provider) Resolve(ctx context.Context, ref secrets.SecretReference) (string, error) { + project, config, err := parsePath(ref.Path) + if err != nil { + return "", err + } + + u, err := url.Parse(p.baseURL + "/v3/configs/config/secret") + if err != nil { + return "", fmt.Errorf("doppler provider: bad baseURL: %w", err) + } + q := u.Query() + q.Set("project", project) + q.Set("config", config) + q.Set("name", ref.Key) + if ref.Version != "" { + q.Set("accept_secret_version", ref.Version) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("doppler provider: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+p.serviceToken) + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("doppler provider: HTTP call: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf( + "doppler provider: secret %s/%s/%s lookup returned %d", + project, + config, + ref.Key, + resp.StatusCode, + ) + } + + var payload struct { + Value struct { + Computed string `json:"computed"` + Raw string `json:"raw"` + } `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("doppler provider: decode response: %w", err) + } + if payload.Value.Computed != "" { + return payload.Value.Computed, nil + } + if payload.Value.Raw != "" { + return payload.Value.Raw, nil + } + return "", fmt.Errorf( + "doppler provider: secret %s/%s/%s has empty value", + project, + config, + ref.Key, + ) +} + +func parsePath(path string) (project, config string, err error) { + parts := strings.SplitN(path, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf( + "doppler provider: path must be \"/\", got %q", + path, + ) + } + return parts[0], parts[1], nil +} diff --git a/apps/workspace-engine/pkg/secrets/doppler/provider_test.go b/apps/workspace-engine/pkg/secrets/doppler/provider_test.go new file mode 100644 index 000000000..14b76a9ad --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/doppler/provider_test.go @@ -0,0 +1,199 @@ +package doppler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "workspace-engine/pkg/secrets" +) + +func newTestProvider(t *testing.T, srv *httptest.Server) *Provider { + t.Helper() + raw, _ := json.Marshal(Config{ServiceToken: "dp.st.test1234567890"}) + p, err := Factory(raw) + if err != nil { + t.Fatalf("Factory: %v", err) + } + prov := p.(*Provider) + prov.baseURL = srv.URL + prov.client = srv.Client() + prov.client.Timeout = 2 * time.Second + return prov +} + +func TestResolveHappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Path; got != "/v3/configs/config/secret" { + t.Errorf("path %q want /v3/configs/config/secret", got) + } + q := r.URL.Query() + if q.Get("project") != "backend" || q.Get("config") != "production" || + q.Get("name") != "ARGOCD_TOKEN" { + t.Errorf("unexpected query %v", q) + } + if got := r.Header.Get("Authorization"); got != "Bearer dp.st.test1234567890" { + t.Errorf("auth header %q", got) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte(`{"value":{"computed":"resolved-token","raw":"resolved-token"}}`), + ) + })) + defer srv.Close() + + p := newTestProvider(t, srv) + got, err := p.Resolve(context.Background(), secrets.SecretReference{ + Path: "backend/production", + Key: "ARGOCD_TOKEN", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "resolved-token" { + t.Fatalf("got %q want resolved-token", got) + } +} + +func TestResolveNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + p := newTestProvider(t, srv) + _, err := p.Resolve( + context.Background(), + secrets.SecretReference{Path: "p/c", Key: "K"}, + ) + if err == nil { + t.Fatal("expected error on 404") + } +} + +func TestResolveEmptyValue(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"value":{"computed":"","raw":""}}`)) + })) + defer srv.Close() + + p := newTestProvider(t, srv) + _, err := p.Resolve( + context.Background(), + secrets.SecretReference{Path: "p/c", Key: "K"}, + ) + if err == nil { + t.Fatal("expected error on empty value") + } +} + +func TestResolveVersionPin(t *testing.T) { + var captured string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.URL.Query().Get("accept_secret_version") + _, _ = w.Write([]byte(`{"value":{"computed":"x","raw":"x"}}`)) + })) + defer srv.Close() + + p := newTestProvider(t, srv) + _, err := p.Resolve(context.Background(), secrets.SecretReference{ + Path: "backend/prod", + Key: "TOKEN", + Version: "42", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if captured != "42" { + t.Fatalf("expected accept_secret_version=42, got %q", captured) + } +} + +func TestResolveNoVersionOmitsQuery(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("accept_secret_version") { + t.Errorf("accept_secret_version must not be present, got %v", r.URL.Query()) + } + _, _ = w.Write([]byte(`{"value":{"computed":"x","raw":"x"}}`)) + })) + defer srv.Close() + + p := newTestProvider(t, srv) + if _, err := p.Resolve( + context.Background(), + secrets.SecretReference{Path: "p/c", Key: "K"}, + ); err != nil { + t.Fatalf("Resolve: %v", err) + } +} + +func TestResolveFallsBackToRaw(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"value":{"computed":"","raw":"raw-val"}}`)) + })) + defer srv.Close() + + p := newTestProvider(t, srv) + got, err := p.Resolve( + context.Background(), + secrets.SecretReference{Path: "p/c", Key: "K"}, + ) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "raw-val" { + t.Fatalf("got %q want raw-val", got) + } +} + +func TestParsePath(t *testing.T) { + cases := []struct { + in string + ok bool + proj string + cfg string + }{ + {"backend/production", true, "backend", "production"}, + {"a/b", true, "a", "b"}, + {"single", false, "", ""}, + {"", false, "", ""}, + {"/missing-project", false, "", ""}, + {"missing-config/", false, "", ""}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + p, cfg, err := parsePath(c.in) + if c.ok && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !c.ok && err == nil { + t.Fatal("expected error") + } + if c.ok && (p != c.proj || cfg != c.cfg) { + t.Fatalf("got (%q,%q) want (%q,%q)", p, cfg, c.proj, c.cfg) + } + }) + } +} + +func TestFactoryRejectsBadConfigs(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"not json", `not-json`}, + {"missing", `{}`}, + {"empty", `{"serviceToken":""}`}, + {"bad prefix", `{"serviceToken":"not-a-doppler-token"}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := Factory([]byte(c.raw)); err == nil { + t.Fatal("expected error") + } + }) + } +} diff --git a/apps/workspace-engine/pkg/secrets/env/provider.go b/apps/workspace-engine/pkg/secrets/env/provider.go new file mode 100644 index 000000000..24d792d9e --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/env/provider.go @@ -0,0 +1,66 @@ +// Package env implements a secrets.Provider that reads from the +// workspace-engine process environment. Every workspace using this provider +// must list the permitted env var names explicitly in AllowedKeys to prevent +// a tenant from reading arbitrary process state. +package env + +import ( + "context" + "encoding/json" + "fmt" + "os" + "slices" + + "workspace-engine/pkg/secrets" +) + +const Type = "env" + +// Config is the decrypted config payload for an env provider row. +type Config struct { + AllowedKeys []string `json:"allowedKeys"` +} + +func (c Config) validate() error { + if len(c.AllowedKeys) == 0 { + return fmt.Errorf("env provider: allowedKeys is empty") + } + if slices.Contains(c.AllowedKeys, "") { + return fmt.Errorf("env provider: allowedKeys entries must be non-empty strings") + } + return nil +} + +type Provider struct { + allowed map[string]struct{} + lookup func(string) (string, bool) +} + +// Factory matches secrets.ProviderFactory. +func Factory(raw json.RawMessage) (secrets.Provider, error) { + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("env provider: parse config: %w", err) + } + if err := cfg.validate(); err != nil { + return nil, err + } + allowed := make(map[string]struct{}, len(cfg.AllowedKeys)) + for _, k := range cfg.AllowedKeys { + allowed[k] = struct{}{} + } + return &Provider{allowed: allowed, lookup: os.LookupEnv}, nil +} + +func (*Provider) Type() string { return Type } + +func (p *Provider) Resolve(_ context.Context, ref secrets.SecretReference) (string, error) { + if _, ok := p.allowed[ref.Key]; !ok { + return "", fmt.Errorf("env provider: key %q not in allowlist", ref.Key) + } + v, ok := p.lookup(ref.Key) + if !ok { + return "", fmt.Errorf("env provider: env var %q is not set", ref.Key) + } + return v, nil +} diff --git a/apps/workspace-engine/pkg/secrets/env/provider_test.go b/apps/workspace-engine/pkg/secrets/env/provider_test.go new file mode 100644 index 000000000..e3a3bc53f --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/env/provider_test.go @@ -0,0 +1,81 @@ +package env + +import ( + "context" + "encoding/json" + "testing" + + "workspace-engine/pkg/secrets" +) + +func newTestProvider(t *testing.T, cfg Config, envVars map[string]string) *Provider { + t.Helper() + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + p, err := Factory(raw) + if err != nil { + t.Fatalf("Factory: %v", err) + } + prov := p.(*Provider) + prov.lookup = func(k string) (string, bool) { + v, ok := envVars[k] + return v, ok + } + return prov +} + +func TestResolveHappyPath(t *testing.T) { + p := newTestProvider(t, + Config{AllowedKeys: []string{"FOO", "BAR"}}, + map[string]string{"FOO": "value-foo"}, + ) + got, err := p.Resolve(context.Background(), secrets.SecretReference{Key: "FOO"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "value-foo" { + t.Fatalf("got %q want value-foo", got) + } +} + +func TestResolveRejectsNotInAllowlist(t *testing.T) { + p := newTestProvider(t, + Config{AllowedKeys: []string{"FOO"}}, + map[string]string{"FOO": "x", "BAR": "y"}, + ) + if _, err := p.Resolve(context.Background(), secrets.SecretReference{Key: "BAR"}); err == nil { + t.Fatal("expected allowlist rejection") + } +} + +func TestResolveMissingEnvVar(t *testing.T) { + p := newTestProvider(t, + Config{AllowedKeys: []string{"FOO"}}, + map[string]string{}, + ) + if _, err := p.Resolve(context.Background(), secrets.SecretReference{Key: "FOO"}); err == nil { + t.Fatal("expected error for unset env var") + } +} + +func TestFactoryRejectsBadConfigs(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"not json", `not-json`}, + {"missing", `{}`}, + {"wrong type", `{"allowedKeys":"FOO"}`}, + {"empty list", `{"allowedKeys":[]}`}, + {"empty string entry", `{"allowedKeys":[""]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := Factory([]byte(c.raw)); err == nil { + t.Fatal("expected error") + } + }) + } +} diff --git a/apps/workspace-engine/pkg/secrets/provider_cache.go b/apps/workspace-engine/pkg/secrets/provider_cache.go new file mode 100644 index 000000000..dac79a9ba --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/provider_cache.go @@ -0,0 +1,97 @@ +package secrets + +import ( + "sync" + "time" + + "github.com/google/uuid" +) + +// providerCacheKey identifies a constructed Provider instance scoped to a +// workspace + secret_provider row name. +type providerCacheKey struct { + WorkspaceID uuid.UUID + Name string +} + +type providerCacheEntry struct { + provider Provider + expiresAt time.Time +} + +// ProviderCache memoizes constructed Provider instances per +// (workspaceID, providerName) so that hot release fan-outs do not pay for +// repeated config decryption + factory construction (AWS LoadDefaultConfig, +// HTTP client rebuilds, etc.). +type ProviderCache struct { + ttl time.Duration + now func() time.Time + mu sync.RWMutex + entries map[providerCacheKey]providerCacheEntry +} + +// NewProviderCache constructs a cache. A non-positive TTL disables caching. +func NewProviderCache(ttl time.Duration) *ProviderCache { + return &ProviderCache{ + ttl: ttl, + now: time.Now, + entries: make(map[providerCacheKey]providerCacheEntry), + } +} + +func providerKeyFor(workspaceID uuid.UUID, providerName string) providerCacheKey { + return providerCacheKey{WorkspaceID: workspaceID, Name: providerName} +} + +// Get returns the cached Provider if present and unexpired. +func (c *ProviderCache) Get(workspaceID uuid.UUID, providerName string) (Provider, bool) { + if c.ttl <= 0 { + return nil, false + } + c.mu.RLock() + entry, ok := c.entries[providerKeyFor(workspaceID, providerName)] + c.mu.RUnlock() + if !ok { + return nil, false + } + if c.now().After(entry.expiresAt) { + return nil, false + } + return entry.provider, true +} + +// Set stores the constructed Provider with the cache TTL applied. +func (c *ProviderCache) Set(workspaceID uuid.UUID, providerName string, p Provider) { + if c.ttl <= 0 { + return + } + c.mu.Lock() + c.entries[providerKeyFor(workspaceID, providerName)] = providerCacheEntry{ + provider: p, + expiresAt: c.now().Add(c.ttl), + } + c.mu.Unlock() +} + +// Invalidate drops the Provider for the named provider in the given +// workspace. Wired into LISTEN/NOTIFY so an upstream config change forces +// reconstruction on the next resolve. +func (c *ProviderCache) Invalidate(workspaceID uuid.UUID, providerName string) { + c.mu.Lock() + delete(c.entries, providerKeyFor(workspaceID, providerName)) + c.mu.Unlock() +} + +// InvalidateAll drops every cached Provider. +func (c *ProviderCache) InvalidateAll() { + c.mu.Lock() + c.entries = make(map[providerCacheKey]providerCacheEntry) + c.mu.Unlock() +} + +// Size returns the number of entries currently cached. +func (c *ProviderCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.entries) +} diff --git a/apps/workspace-engine/pkg/secrets/provider_cache_test.go b/apps/workspace-engine/pkg/secrets/provider_cache_test.go new file mode 100644 index 000000000..d0bc0af1f --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/provider_cache_test.go @@ -0,0 +1,103 @@ +package secrets + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" +) + +type stubProviderInst struct { + typ string +} + +func (s *stubProviderInst) Type() string { return s.typ } +func (s *stubProviderInst) Resolve(_ context.Context, _ SecretReference) (string, error) { + return "", nil +} + +func TestProviderCacheHitMiss(t *testing.T) { + c := NewProviderCache(time.Minute) + ws := uuid.New() + + if _, ok := c.Get(ws, "doppler-prod"); ok { + t.Fatal("expected miss on empty cache") + } + + want := &stubProviderInst{typ: "doppler"} + c.Set(ws, "doppler-prod", want) + + got, ok := c.Get(ws, "doppler-prod") + if !ok { + t.Fatal("expected hit after Set") + } + if got != want { + t.Fatalf("got %p want %p", got, want) + } +} + +func TestProviderCacheExpiry(t *testing.T) { + c := NewProviderCache(time.Minute) + now := time.Unix(1_700_000_000, 0) + c.now = func() time.Time { return now } + ws := uuid.New() + + c.Set(ws, "doppler-prod", &stubProviderInst{typ: "doppler"}) + if _, ok := c.Get(ws, "doppler-prod"); !ok { + t.Fatal("expected hit immediately after Set") + } + + now = now.Add(time.Minute + time.Second) + if _, ok := c.Get(ws, "doppler-prod"); ok { + t.Fatal("expected miss after TTL expiry") + } +} + +func TestProviderCacheDisabledWhenTTLZero(t *testing.T) { + c := NewProviderCache(0) + ws := uuid.New() + + c.Set(ws, "doppler-prod", &stubProviderInst{typ: "doppler"}) + if _, ok := c.Get(ws, "doppler-prod"); ok { + t.Fatal("zero TTL must disable caching") + } + if c.Size() != 0 { + t.Fatalf("zero TTL must not store entries, got %d", c.Size()) + } +} + +func TestProviderCacheInvalidate(t *testing.T) { + c := NewProviderCache(time.Minute) + wsA := uuid.New() + wsB := uuid.New() + + c.Set(wsA, "doppler-prod", &stubProviderInst{typ: "doppler"}) + c.Set(wsA, "aws-prod", &stubProviderInst{typ: "aws_secrets_manager"}) + c.Set(wsB, "doppler-prod", &stubProviderInst{typ: "doppler"}) + + c.Invalidate(wsA, "doppler-prod") + + if _, ok := c.Get(wsA, "doppler-prod"); ok { + t.Fatal("expected wsA/doppler-prod evicted") + } + if _, ok := c.Get(wsA, "aws-prod"); !ok { + t.Fatal("expected wsA/aws-prod retained") + } + if _, ok := c.Get(wsB, "doppler-prod"); !ok { + t.Fatal("expected wsB/doppler-prod retained (different workspace)") + } +} + +func TestProviderCacheInvalidateAll(t *testing.T) { + c := NewProviderCache(time.Minute) + ws := uuid.New() + c.Set(ws, "doppler-prod", &stubProviderInst{typ: "doppler"}) + c.Set(ws, "aws-prod", &stubProviderInst{typ: "aws_secrets_manager"}) + + c.InvalidateAll() + + if c.Size() != 0 { + t.Fatalf("InvalidateAll: size %d, want 0", c.Size()) + } +} diff --git a/apps/workspace-engine/pkg/secrets/providers/providers.go b/apps/workspace-engine/pkg/secrets/providers/providers.go new file mode 100644 index 000000000..bf41dd7be --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/providers/providers.go @@ -0,0 +1,27 @@ +// Package providers wires the built-in secret provider implementations into +// a secrets.Registry. main.go calls RegisterAll once during startup; new +// provider types are added here. +package providers + +import ( + "workspace-engine/pkg/secrets" + "workspace-engine/pkg/secrets/awssm" + "workspace-engine/pkg/secrets/doppler" + "workspace-engine/pkg/secrets/env" +) + +// RegisterAll registers every provider implementation shipped with +// workspace-engine. Callers may add additional registrations afterward. +func RegisterAll(r *secrets.Registry) { + r.Register(awssm.Type, awssm.Factory) + r.Register(doppler.Type, doppler.Factory) + r.Register(env.Type, env.Factory) +} + +// NewDefaultRegistry constructs a Registry pre-populated with the built-in +// providers. Convenience for tests and main.go. +func NewDefaultRegistry() *secrets.Registry { + r := secrets.NewRegistry() + RegisterAll(r) + return r +} diff --git a/apps/workspace-engine/pkg/secrets/providers/providers_test.go b/apps/workspace-engine/pkg/secrets/providers/providers_test.go new file mode 100644 index 000000000..620a2ee96 --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/providers/providers_test.go @@ -0,0 +1,21 @@ +package providers + +import ( + "sort" + "testing" +) + +func TestRegisterAllRegistersExpectedTypes(t *testing.T) { + r := NewDefaultRegistry() + got := r.Types() + sort.Strings(got) + want := []string{"aws_secrets_manager", "doppler", "env"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i, w := range want { + if got[i] != w { + t.Fatalf("got %v, want %v", got, want) + } + } +} diff --git a/apps/workspace-engine/pkg/secrets/registry.go b/apps/workspace-engine/pkg/secrets/registry.go new file mode 100644 index 000000000..7b1b5faab --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/registry.go @@ -0,0 +1,41 @@ +package secrets + +import "fmt" + +// Registry is a string-keyed lookup of ProviderFactory by secret_provider.type. +// Provider packages register themselves at init time, mirroring the +// jobagents/registry.go pattern. +type Registry struct { + factories map[string]ProviderFactory +} + +func NewRegistry() *Registry { + return &Registry{factories: make(map[string]ProviderFactory)} +} + +// Register attaches a factory under the given provider type. A second call +// with the same type overwrites the prior registration; callers should treat +// re-registration as a programming error and avoid it. +func (r *Registry) Register(providerType string, factory ProviderFactory) { + r.factories[providerType] = factory +} + +// Build constructs a Provider from a decrypted ProviderConfig. Returns an +// error if no factory is registered for the config's type. +func (r *Registry) Build(cfg *ProviderConfig) (Provider, error) { + factory, ok := r.factories[cfg.Type] + if !ok { + return nil, fmt.Errorf("secrets: no provider factory registered for type %q", cfg.Type) + } + return factory(cfg.Config) +} + +// Types returns the registered provider types in undefined order. Primarily +// useful for diagnostics and startup logging. +func (r *Registry) Types() []string { + types := make([]string, 0, len(r.factories)) + for t := range r.factories { + types = append(types, t) + } + return types +} diff --git a/apps/workspace-engine/pkg/secrets/registry_test.go b/apps/workspace-engine/pkg/secrets/registry_test.go new file mode 100644 index 000000000..a28e23f1d --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/registry_test.go @@ -0,0 +1,59 @@ +package secrets + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +type stubProvider struct{ name string } + +func (s *stubProvider) Type() string { return s.name } + +func (s *stubProvider) Resolve(_ context.Context, _ SecretReference) (string, error) { + return "", errors.New("not implemented") +} + +func TestRegistryBuildAndTypes(t *testing.T) { + r := NewRegistry() + r.Register("doppler", func(_ json.RawMessage) (Provider, error) { + return &stubProvider{name: "doppler"}, nil + }) + r.Register("aws_secrets_manager", func(_ json.RawMessage) (Provider, error) { + return &stubProvider{name: "aws_secrets_manager"}, nil + }) + + types := r.Types() + if len(types) != 2 { + t.Fatalf("expected 2 registered types, got %d (%v)", len(types), types) + } + + p, err := r.Build(&ProviderConfig{Type: "doppler", Config: json.RawMessage(`{}`)}) + if err != nil { + t.Fatalf("Build doppler: %v", err) + } + if p.Type() != "doppler" { + t.Fatalf("got type %q, want doppler", p.Type()) + } +} + +func TestRegistryUnknownTypeFails(t *testing.T) { + r := NewRegistry() + _, err := r.Build(&ProviderConfig{Type: "vault", Config: json.RawMessage(`{}`)}) + if err == nil { + t.Fatal("expected error for unregistered type") + } +} + +func TestRegistryFactoryErrorPropagates(t *testing.T) { + r := NewRegistry() + wantErr := errors.New("bad config") + r.Register("doppler", func(_ json.RawMessage) (Provider, error) { + return nil, wantErr + }) + _, err := r.Build(&ProviderConfig{Type: "doppler", Config: json.RawMessage(`{}`)}) + if !errors.Is(err, wantErr) { + t.Fatalf("expected wrapped wantErr, got %v", err) + } +} diff --git a/apps/workspace-engine/pkg/secrets/resolver.go b/apps/workspace-engine/pkg/secrets/resolver.go new file mode 100644 index 000000000..d5b8cd94c --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/resolver.go @@ -0,0 +1,214 @@ +package secrets + +import ( + "context" + "fmt" + "log/slog" + + "github.com/google/uuid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +var tracer = otel.Tracer("workspace-engine/pkg/secrets") + +// Resolver glues the ProviderConfigStore (lookup + decrypt), the Registry +// (factory dispatch), the value cache (TTL'd resolved plaintexts), and the +// provider cache (TTL'd constructed Provider instances). One Resolver is +// constructed at startup and shared by all reconciliation goroutines. +type Resolver struct { + store ProviderConfigStore + registry *Registry + cache *Cache + providerCache *ProviderCache +} + +// NewResolver builds a Resolver. A nil cache or providerCache disables that +// layer of caching while leaving the rest of the lookup chain intact. +func NewResolver( + store ProviderConfigStore, + registry *Registry, + cache *Cache, + providerCache *ProviderCache, +) *Resolver { + return &Resolver{ + store: store, + registry: registry, + cache: cache, + providerCache: providerCache, + } +} + +// Resolve fetches the secret value identified by ref. Lookups proceed: +// +// 1. value cache (if configured) +// 2. provider-instance cache (skips store + factory on hit) +// 3. ProviderConfigStore.Get to load + decrypt the provider config +// 4. Registry.Build to construct a Provider from the config +// 5. Provider.Resolve to hit the upstream secret store +// +// Any error in steps 3-5 propagates; release dispatch is expected to block. +// +// Observability: every call opens a "secrets.Resolve" span with non-sensitive +// reference metadata (provider/path/key — never the plaintext) and emits a +// structured slog record on each terminal outcome. Cache hits are recorded as +// span events so traces show which layer absorbed the call. +func (r *Resolver) Resolve( + ctx context.Context, + workspaceID uuid.UUID, + ref SecretReference, +) (string, error) { + ctx, span := tracer.Start(ctx, "secrets.Resolve") + defer span.End() + span.SetAttributes( + attribute.String("workspace.id", workspaceID.String()), + attribute.String("secret.provider", ref.Provider), + attribute.String("secret.path", ref.Path), + attribute.String("secret.key", ref.Key), + ) + + if ref.Provider == "" { + err := fmt.Errorf("secrets: empty provider name in reference") + r.recordFailure(ctx, span, workspaceID, ref, "", "validation", err) + return "", err + } + // Per-provider Key semantics are validated by the Provider impl: awssm + // treats an empty Key as "return the raw SecretString", while Doppler + // and env require a non-empty Key. + + if r.cache != nil { + if v, ok := r.cache.Get(workspaceID, ref); ok { + span.AddEvent("value_cache.hit") + span.SetAttributes( + attribute.Bool("secret.value_cache_hit", true), + attribute.Int("secret.value.length", len(v)), + ) + slog.DebugContext(ctx, "secret resolved (value cache hit)", + "workspace_id", workspaceID.String(), + "provider", ref.Provider, + "path", ref.Path, + "key", ref.Key, + ) + return v, nil + } + } + + provider, providerType, providerCacheHit, err := r.lookupProvider( + ctx, + workspaceID, + ref.Provider, + ) + if err != nil { + r.recordFailure(ctx, span, workspaceID, ref, "", "provider_lookup", err) + return "", err + } + span.SetAttributes( + attribute.String("secret.provider_type", providerType), + attribute.Bool("secret.provider_cache_hit", providerCacheHit), + ) + if providerCacheHit { + span.AddEvent("provider_cache.hit") + } + + value, err := provider.Resolve(ctx, ref) + if err != nil { + wrapped := fmt.Errorf( + "secrets: provider %q (%s) resolve: %w", + ref.Provider, + providerType, + err, + ) + r.recordFailure(ctx, span, workspaceID, ref, providerType, "upstream", wrapped) + return "", wrapped + } + + if r.cache != nil { + r.cache.Set(workspaceID, ref, value) + } + + span.SetAttributes(attribute.Int("secret.value.length", len(value))) + span.SetStatus(codes.Ok, "") + slog.InfoContext(ctx, "secret resolved", + "workspace_id", workspaceID.String(), + "provider", ref.Provider, + "provider_type", providerType, + "path", ref.Path, + "key", ref.Key, + "provider_cache_hit", providerCacheHit, + "value_length", len(value), + ) + return value, nil +} + +// recordFailure attaches the error to the span and emits a structured +// warning log. The plaintext is never recorded; only the reference metadata +// and a coarse error class. +func (r *Resolver) recordFailure( + ctx context.Context, + span trace.Span, + workspaceID uuid.UUID, + ref SecretReference, + providerType string, + errorClass string, + err error, +) { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + span.SetAttributes(attribute.String("secret.error_class", errorClass)) + slog.WarnContext(ctx, "secret resolve failed", + "workspace_id", workspaceID.String(), + "provider", ref.Provider, + "provider_type", providerType, + "path", ref.Path, + "key", ref.Key, + "error_class", errorClass, + "error", err.Error(), + ) +} + +// lookupProvider returns the Provider for the named secret_provider row in +// the workspace. The provider-instance cache is checked first; on a miss the +// config is loaded + decrypted via the store and constructed via the +// registry, then memoized. The boolean indicates whether the result came +// from the cache. +func (r *Resolver) lookupProvider( + ctx context.Context, + workspaceID uuid.UUID, + providerName string, +) (Provider, string, bool, error) { + if r.providerCache != nil { + if p, ok := r.providerCache.Get(workspaceID, providerName); ok { + return p, p.Type(), true, nil + } + } + + cfg, err := r.store.Get(ctx, workspaceID, providerName) + if err != nil { + return nil, "", false, err + } + + provider, err := r.registry.Build(cfg) + if err != nil { + return nil, "", false, err + } + + if r.providerCache != nil { + r.providerCache.Set(workspaceID, providerName, provider) + } + return provider, cfg.Type, false, nil +} + +// InvalidateProvider drops cached resolved values and the cached Provider +// instance for the named provider. Wire to the LISTEN/NOTIFY consumer on +// the `secret_provider_invalidate` channel so an api-side update flushes +// every workspace-engine pod. +func (r *Resolver) InvalidateProvider(workspaceID uuid.UUID, providerName string) { + if r.cache != nil { + r.cache.InvalidateProvider(workspaceID, providerName) + } + if r.providerCache != nil { + r.providerCache.Invalidate(workspaceID, providerName) + } +} diff --git a/apps/workspace-engine/pkg/secrets/resolver_test.go b/apps/workspace-engine/pkg/secrets/resolver_test.go new file mode 100644 index 000000000..3c4a9b44f --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/resolver_test.go @@ -0,0 +1,370 @@ +package secrets + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" +) + +type mockStore struct { + configs map[string]*ProviderConfig + getErr error + getCalls atomic.Int32 + lastLookup string +} + +func (m *mockStore) Get( + _ context.Context, + _ uuid.UUID, + providerName string, +) (*ProviderConfig, error) { + m.getCalls.Add(1) + m.lastLookup = providerName + if m.getErr != nil { + return nil, m.getErr + } + cfg, ok := m.configs[providerName] + if !ok { + return nil, errors.New("not found") + } + return cfg, nil +} + +func (m *mockStore) List( + _ context.Context, + _ uuid.UUID, +) ([]*ProviderConfig, error) { + out := make([]*ProviderConfig, 0, len(m.configs)) + for _, cfg := range m.configs { + out = append(out, cfg) + } + return out, nil +} + +type mockProvider struct { + t string + resolveErr error + resolveVal string + resolveRefs []SecretReference +} + +func (p *mockProvider) Type() string { return p.t } + +func (p *mockProvider) Resolve(_ context.Context, ref SecretReference) (string, error) { + p.resolveRefs = append(p.resolveRefs, ref) + if p.resolveErr != nil { + return "", p.resolveErr + } + return p.resolveVal, nil +} + +func newResolver(t *testing.T, store ProviderConfigStore, provider Provider) *Resolver { + t.Helper() + reg := NewRegistry() + reg.Register( + provider.Type(), + func(_ json.RawMessage) (Provider, error) { return provider, nil }, + ) + return NewResolver(store, reg, NewCache(time.Minute), NewProviderCache(time.Minute)) +} + +func TestResolverHappyPath(t *testing.T) { + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "doppler-prod": { + WorkspaceID: ws, + Name: "doppler-prod", + Type: "doppler", + Config: json.RawMessage(`{"serviceToken":"dp.st.test"}`), + }, + }, + } + provider := &mockProvider{t: "doppler", resolveVal: "abc123"} + r := newResolver(t, store, provider) + + got, err := r.Resolve(context.Background(), ws, SecretReference{ + Provider: "doppler-prod", + Path: "backend/prod", + Key: "TOKEN", + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "abc123" { + t.Fatalf("expected abc123, got %q", got) + } + if len(provider.resolveRefs) != 1 { + t.Fatalf("provider.Resolve called %d times, want 1", len(provider.resolveRefs)) + } +} + +func TestResolverCacheHitsSkipStore(t *testing.T) { + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "doppler-prod": { + WorkspaceID: ws, + Name: "doppler-prod", + Type: "doppler", + Config: json.RawMessage(`{}`), + }, + }, + } + provider := &mockProvider{t: "doppler", resolveVal: "abc123"} + r := newResolver(t, store, provider) + ref := SecretReference{Provider: "doppler-prod", Key: "TOKEN"} + + if _, err := r.Resolve(context.Background(), ws, ref); err != nil { + t.Fatalf("first Resolve: %v", err) + } + if _, err := r.Resolve(context.Background(), ws, ref); err != nil { + t.Fatalf("second Resolve: %v", err) + } + + if got := store.getCalls.Load(); got != 1 { + t.Fatalf("store.Get called %d times, want 1 (second should be cache hit)", got) + } + if got := len(provider.resolveRefs); got != 1 { + t.Fatalf("provider.Resolve called %d times, want 1", got) + } +} + +func TestResolverInvalidationForcesRefetch(t *testing.T) { + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "doppler-prod": { + WorkspaceID: ws, + Name: "doppler-prod", + Type: "doppler", + Config: json.RawMessage(`{}`), + }, + }, + } + provider := &mockProvider{t: "doppler", resolveVal: "abc"} + r := newResolver(t, store, provider) + ref := SecretReference{Provider: "doppler-prod", Key: "TOKEN"} + + if _, err := r.Resolve(context.Background(), ws, ref); err != nil { + t.Fatalf("Resolve: %v", err) + } + r.InvalidateProvider(ws, "doppler-prod") + if _, err := r.Resolve(context.Background(), ws, ref); err != nil { + t.Fatalf("Resolve: %v", err) + } + + if got := store.getCalls.Load(); got != 2 { + t.Fatalf("store.Get called %d times, want 2 after invalidation", got) + } +} + +func TestResolverProviderInstanceCachedAcrossRefs(t *testing.T) { + // Two distinct SecretReferences sharing a provider should construct the + // Provider once. With only the value cache (and no provider cache), + // every distinct ref would re-decrypt and re-build. + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "aws-prod": { + WorkspaceID: ws, + Name: "aws-prod", + Type: "aws_secrets_manager", + Config: json.RawMessage(`{}`), + }, + }, + } + provider := &mockProvider{t: "aws_secrets_manager", resolveVal: "x"} + factoryCalls := 0 + reg := NewRegistry() + reg.Register("aws_secrets_manager", func(_ json.RawMessage) (Provider, error) { + factoryCalls++ + return provider, nil + }) + r := NewResolver(store, reg, NewCache(time.Minute), NewProviderCache(time.Minute)) + + refA := SecretReference{Provider: "aws-prod", Path: "prod/db", Key: "password"} + refB := SecretReference{Provider: "aws-prod", Path: "prod/db", Key: "username"} + if _, err := r.Resolve(context.Background(), ws, refA); err != nil { + t.Fatalf("Resolve A: %v", err) + } + if _, err := r.Resolve(context.Background(), ws, refB); err != nil { + t.Fatalf("Resolve B: %v", err) + } + + if got := store.getCalls.Load(); got != 1 { + t.Fatalf("store.Get called %d times, want 1 (second ref should reuse cached provider)", got) + } + if factoryCalls != 1 { + t.Fatalf("factory called %d times, want 1", factoryCalls) + } + if got := len(provider.resolveRefs); got != 2 { + t.Fatalf("provider.Resolve called %d times, want 2 (one per distinct value ref)", got) + } +} + +func TestResolverInvalidationDropsProviderInstance(t *testing.T) { + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "aws-prod": { + WorkspaceID: ws, + Name: "aws-prod", + Type: "aws_secrets_manager", + Config: json.RawMessage(`{}`), + }, + }, + } + provider := &mockProvider{t: "aws_secrets_manager", resolveVal: "x"} + factoryCalls := 0 + reg := NewRegistry() + reg.Register("aws_secrets_manager", func(_ json.RawMessage) (Provider, error) { + factoryCalls++ + return provider, nil + }) + r := NewResolver(store, reg, NewCache(time.Minute), NewProviderCache(time.Minute)) + + ref := SecretReference{Provider: "aws-prod", Path: "prod/db", Key: "password"} + if _, err := r.Resolve(context.Background(), ws, ref); err != nil { + t.Fatalf("Resolve: %v", err) + } + r.InvalidateProvider(ws, "aws-prod") + if _, err := r.Resolve(context.Background(), ws, ref); err != nil { + t.Fatalf("Resolve after invalidation: %v", err) + } + + if got := store.getCalls.Load(); got != 2 { + t.Fatalf( + "store.Get called %d times, want 2 (invalidation forces re-decrypt)", + got, + ) + } + if factoryCalls != 2 { + t.Fatalf("factory called %d times, want 2 after invalidation", factoryCalls) + } +} + +func TestResolverProviderCacheDisabledWhenNil(t *testing.T) { + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "aws-prod": { + WorkspaceID: ws, + Name: "aws-prod", + Type: "aws_secrets_manager", + Config: json.RawMessage(`{}`), + }, + }, + } + provider := &mockProvider{t: "aws_secrets_manager", resolveVal: "x"} + factoryCalls := 0 + reg := NewRegistry() + reg.Register("aws_secrets_manager", func(_ json.RawMessage) (Provider, error) { + factoryCalls++ + return provider, nil + }) + // Disable both caches: every Resolve must hit store + factory. + r := NewResolver(store, reg, nil, nil) + + refA := SecretReference{Provider: "aws-prod", Path: "prod/db", Key: "password"} + refB := SecretReference{Provider: "aws-prod", Path: "prod/db", Key: "username"} + if _, err := r.Resolve(context.Background(), ws, refA); err != nil { + t.Fatalf("Resolve A: %v", err) + } + if _, err := r.Resolve(context.Background(), ws, refB); err != nil { + t.Fatalf("Resolve B: %v", err) + } + + if got := store.getCalls.Load(); got != 2 { + t.Fatalf("store.Get called %d times, want 2 with caches off", got) + } + if factoryCalls != 2 { + t.Fatalf("factory called %d times, want 2 with caches off", factoryCalls) + } +} + +func TestResolverRejectsEmptyProvider(t *testing.T) { + // Empty Provider is always invalid — there's nothing to look up. Empty + // Key is provider-specific (awssm treats it as "return raw") and is + // validated inside the Provider impl, not at the Resolver level. + store := &mockStore{} + provider := &mockProvider{t: "doppler"} + r := newResolver(t, store, provider) + + if _, err := r.Resolve( + context.Background(), + uuid.New(), + SecretReference{Provider: "", Key: "K"}, + ); err == nil { + t.Fatal("expected error for empty Provider") + } + if got := store.getCalls.Load(); got != 0 { + t.Fatalf("store should not be hit for invalid refs, got %d calls", got) + } +} + +func TestResolverNoFactoryRegistered(t *testing.T) { + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "unknown": { + WorkspaceID: ws, + Name: "unknown", + Type: "vault", + Config: json.RawMessage(`{}`), + }, + }, + } + r := NewResolver(store, NewRegistry(), NewCache(time.Minute), NewProviderCache(time.Minute)) + + _, err := r.Resolve(context.Background(), ws, SecretReference{Provider: "unknown", Key: "K"}) + if err == nil { + t.Fatal("expected error for unregistered provider type") + } +} + +func TestResolverProviderErrorPropagates(t *testing.T) { + ws := uuid.New() + store := &mockStore{ + configs: map[string]*ProviderConfig{ + "doppler-prod": { + WorkspaceID: ws, + Name: "doppler-prod", + Type: "doppler", + Config: json.RawMessage(`{}`), + }, + }, + } + provider := &mockProvider{t: "doppler", resolveErr: errors.New("upstream 500")} + r := newResolver(t, store, provider) + + _, err := r.Resolve( + context.Background(), + ws, + SecretReference{Provider: "doppler-prod", Key: "K"}, + ) + if err == nil { + t.Fatal("expected error from provider to propagate") + } +} + +func TestResolverStoreErrorPropagates(t *testing.T) { + ws := uuid.New() + store := &mockStore{getErr: errors.New("db is sad")} + provider := &mockProvider{t: "doppler"} + r := newResolver(t, store, provider) + + _, err := r.Resolve( + context.Background(), + ws, + SecretReference{Provider: "doppler-prod", Key: "K"}, + ) + if err == nil { + t.Fatal("expected error from store to propagate") + } +} diff --git a/apps/workspace-engine/pkg/secrets/store.go b/apps/workspace-engine/pkg/secrets/store.go new file mode 100644 index 000000000..a907bc710 --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/store.go @@ -0,0 +1,95 @@ +package secrets + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "workspace-engine/pkg/crypto" + "workspace-engine/pkg/db" +) + +// Decryptor decrypts the bytea config payload stored on secret_provider rows. +// Matched 1:1 with the AES-256-CBC implementation used by @ctrlplane/secrets +// on the TypeScript side. +type Decryptor interface { + Decrypt(ciphertext string) (string, error) +} + +// PostgresStore loads secret_provider rows via sqlc and decrypts their +// configs in memory. +type PostgresStore struct { + queries *db.Queries + decryptor Decryptor +} + +// NewPostgresStore constructs a store using a sqlc Queries handle and a +// crypto.AES256CBC built from the workspace-engine's VARIABLES_AES_256_KEY. +func NewPostgresStore(queries *db.Queries, decryptor Decryptor) *PostgresStore { + return &PostgresStore{queries: queries, decryptor: decryptor} +} + +// NewPostgresStoreFromKey is a convenience constructor for callers that have a +// hex key rather than a Decryptor instance. +func NewPostgresStoreFromKey(queries *db.Queries, keyHex string) (*PostgresStore, error) { + dec, err := crypto.New(keyHex) + if err != nil { + return nil, fmt.Errorf("secrets: bad decryption key: %w", err) + } + return NewPostgresStore(queries, dec), nil +} + +func (s *PostgresStore) Get( + ctx context.Context, + workspaceID uuid.UUID, + providerName string, +) (*ProviderConfig, error) { + row, err := s.queries.GetSecretProviderByName(ctx, db.GetSecretProviderByNameParams{ + WorkspaceID: workspaceID, + Name: providerName, + }) + if err != nil { + return nil, fmt.Errorf( + "secrets: load provider %q for workspace %s: %w", + providerName, + workspaceID, + err, + ) + } + return s.toProviderConfig(row) +} + +func (s *PostgresStore) List( + ctx context.Context, + workspaceID uuid.UUID, +) ([]*ProviderConfig, error) { + rows, err := s.queries.ListSecretProvidersByWorkspaceID(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("secrets: list providers for workspace %s: %w", workspaceID, err) + } + out := make([]*ProviderConfig, 0, len(rows)) + for _, row := range rows { + cfg, err := s.toProviderConfig(row) + if err != nil { + return nil, err + } + out = append(out, cfg) + } + return out, nil +} + +func (s *PostgresStore) toProviderConfig(row db.SecretProvider) (*ProviderConfig, error) { + // Ciphertext is the TS-encoded string (":") stored + // as bytea. Bytea -> []byte -> string with no transformation. + plaintext, err := s.decryptor.Decrypt(string(row.Config)) + if err != nil { + return nil, fmt.Errorf("secrets: decrypt config for %q: %w", row.Name, err) + } + return &ProviderConfig{ + ID: row.ID, + WorkspaceID: row.WorkspaceID, + Name: row.Name, + Type: string(row.Type), + Config: []byte(plaintext), + }, nil +} diff --git a/apps/workspace-engine/pkg/secrets/types.go b/apps/workspace-engine/pkg/secrets/types.go new file mode 100644 index 000000000..e2438800e --- /dev/null +++ b/apps/workspace-engine/pkg/secrets/types.go @@ -0,0 +1,61 @@ +// Package secrets resolves variable_value rows of kind = secret_ref by +// looking up the workspace's secret_provider entity, decrypting its +// configuration, and dispatching to a provider implementation (Doppler, AWS +// Secrets Manager, env, ...). The Resolver is constructed once at startup and +// injected into the variableresolver. +package secrets + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" +) + +// SecretReference identifies a single secret value within a workspace. +type SecretReference struct { + // Provider is the workspace-unique name of the secret_provider row. + Provider string + // Path is provider-specific; may be empty. + Path string + // Key identifies the secret within Path. Some providers ignore Path and + // use Key alone (e.g. env). + Key string + // Version optionally pins to a specific provider-side version. Empty + // means "latest" — awssm reads AWSCURRENT, Doppler the latest published + // version. When set: awssm uses VersionId (uuid form) or VersionStage + // (AWSCURRENT/AWSPREVIOUS), Doppler uses accept_secret_version. + Version string +} + +// Provider resolves a SecretReference against an external secret store. +// Implementations are constructed by a ProviderFactory from a decrypted +// ProviderConfig and are safe to reuse across resolutions for the lifetime of +// a single ProviderConfig row (TTL cache governs reuse). +type Provider interface { + // Type matches secret_provider.type. Used for registry lookups. + Type() string + // Resolve fetches the secret value. Returning a non-nil error blocks the + // downstream release dispatch. + Resolve(ctx context.Context, ref SecretReference) (string, error) +} + +// ProviderConfig is the decrypted view of a secret_provider row. Config is +// the raw decrypted JSON payload; each provider's factory unmarshals it into +// a typed struct that lives next to the provider implementation. +type ProviderConfig struct { + ID uuid.UUID + WorkspaceID uuid.UUID + Name string + Type string + Config json.RawMessage +} + +// ProviderFactory constructs a Provider from the decrypted config payload. +type ProviderFactory func(cfg json.RawMessage) (Provider, error) + +// ProviderConfigStore loads and decrypts secret_provider rows. +type ProviderConfigStore interface { + Get(ctx context.Context, workspaceID uuid.UUID, providerName string) (*ProviderConfig, error) + List(ctx context.Context, workspaceID uuid.UUID) ([]*ProviderConfig, error) +} diff --git a/apps/workspace-engine/pkg/workspace/jobs/config_template.go b/apps/workspace-engine/pkg/workspace/jobs/config_template.go new file mode 100644 index 000000000..47adac5c6 --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/jobs/config_template.go @@ -0,0 +1,100 @@ +package jobs + +import ( + "bytes" + "fmt" + "strings" + + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/templatefuncs" +) + +// renderJobAgentConfig walks the job agent config and renders every string +// value that contains a Go template directive (`{{`) against the dispatch +// context. Maps and slices are recursed into. Non-string scalars (numbers, +// booleans, nil) pass through unchanged. The original config is left +// untouched; a new map is returned so the job agent receives a config with +// secrets already resolved. +// +// Strings that do not contain `{{` are returned verbatim — this avoids +// surprising changes for configs that legitimately include double braces in +// non-template content, and lets template render failures surface only when +// the operator intentionally used template syntax. +func renderJobAgentConfig( + cfg oapi.JobAgentConfig, + dispatchCtx *oapi.DispatchContext, +) (oapi.JobAgentConfig, error) { + if len(cfg) == 0 { + return cfg, nil + } + data := dispatchCtx.Map() + // oapi.JobAgentConfig is a named map type; the type switch in + // renderValue matches map[string]any literally, so convert to the + // unnamed form before recursing. + asMap := map[string]any(cfg) + rendered, err := renderValue(asMap, data, "") + if err != nil { + return nil, err + } + out, ok := rendered.(map[string]any) + if !ok { + return nil, fmt.Errorf("job agent config: rendered value is not a map") + } + return oapi.JobAgentConfig(out), nil +} + +// renderValue walks any JSON-shaped value (string / number / bool / nil / +// []any / map[string]any) and renders string leaves containing `{{`. The +// path argument is used to label template parse / execute errors. +func renderValue(v any, data map[string]any, path string) (any, error) { + switch t := v.(type) { + case string: + if !strings.Contains(t, "{{") { + return t, nil + } + return renderString(t, data, path) + case map[string]any: + out := make(map[string]any, len(t)) + for k, child := range t { + childPath := joinPath(path, k) + rendered, err := renderValue(child, data, childPath) + if err != nil { + return nil, err + } + out[k] = rendered + } + return out, nil + case []any: + out := make([]any, len(t)) + for i, child := range t { + childPath := fmt.Sprintf("%s[%d]", path, i) + rendered, err := renderValue(child, data, childPath) + if err != nil { + return nil, err + } + out[i] = rendered + } + return out, nil + default: + return v, nil + } +} + +func renderString(tmpl string, data map[string]any, path string) (string, error) { + t, err := templatefuncs.Parse("jobAgentConfig:"+path, tmpl) + if err != nil { + return "", fmt.Errorf("job agent config %q: parse template: %w", path, err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("job agent config %q: execute template: %w", path, err) + } + return buf.String(), nil +} + +func joinPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} diff --git a/apps/workspace-engine/pkg/workspace/jobs/config_template_test.go b/apps/workspace-engine/pkg/workspace/jobs/config_template_test.go new file mode 100644 index 000000000..4a85dd09f --- /dev/null +++ b/apps/workspace-engine/pkg/workspace/jobs/config_template_test.go @@ -0,0 +1,168 @@ +package jobs + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "workspace-engine/pkg/oapi" +) + +func dispatchCtxWithJobAgentVars(vars map[string]string) *oapi.DispatchContext { + dc := &oapi.DispatchContext{} + if vars != nil { + m := make(map[string]oapi.LiteralValue, len(vars)) + for k, v := range vars { + m[k] = *oapi.NewLiteralValue(v) + } + dc.JobAgentVariables = &m + } + return dc +} + +func TestRenderJobAgentConfig_RendersStringTemplates(t *testing.T) { + dc := dispatchCtxWithJobAgentVars(map[string]string{ + "argo_token": "supersecret123", + }) + cfg := oapi.JobAgentConfig{ + "serverUrl": "https://argocd.example.com", + "apiKey": "{{ .jobAgentVariables.argo_token }}", + } + + out, err := renderJobAgentConfig(cfg, dc) + require.NoError(t, err) + assert.Equal(t, "https://argocd.example.com", out["serverUrl"]) + assert.Equal(t, "supersecret123", out["apiKey"]) +} + +func TestRenderJobAgentConfig_LeavesPlainStringsAlone(t *testing.T) { + dc := dispatchCtxWithJobAgentVars(nil) + cfg := oapi.JobAgentConfig{ + "serverUrl": "https://argocd.example.com", + "template": "no braces here", + } + + out, err := renderJobAgentConfig(cfg, dc) + require.NoError(t, err) + assert.Equal(t, "https://argocd.example.com", out["serverUrl"]) + assert.Equal(t, "no braces here", out["template"]) +} + +func TestRenderJobAgentConfig_RecursesIntoNestedMaps(t *testing.T) { + dc := dispatchCtxWithJobAgentVars(map[string]string{ + "region": "us-east-1", + "token": "abc123", + }) + cfg := oapi.JobAgentConfig{ + "aws": map[string]any{ + "region": "{{ .jobAgentVariables.region }}", + "credentials": map[string]any{"token": "{{ .jobAgentVariables.token }}"}, + }, + } + + out, err := renderJobAgentConfig(cfg, dc) + require.NoError(t, err) + aws := out["aws"].(map[string]any) + assert.Equal(t, "us-east-1", aws["region"]) + creds := aws["credentials"].(map[string]any) + assert.Equal(t, "abc123", creds["token"]) +} + +func TestRenderJobAgentConfig_RecursesIntoArrays(t *testing.T) { + dc := dispatchCtxWithJobAgentVars(map[string]string{ + "a": "first", + "b": "second", + }) + cfg := oapi.JobAgentConfig{ + "hosts": []any{ + "{{ .jobAgentVariables.a }}", + "{{ .jobAgentVariables.b }}", + "literal", + }, + } + + out, err := renderJobAgentConfig(cfg, dc) + require.NoError(t, err) + hosts := out["hosts"].([]any) + assert.Equal(t, []any{"first", "second", "literal"}, hosts) +} + +func TestRenderJobAgentConfig_NonStringScalarsPassThrough(t *testing.T) { + dc := dispatchCtxWithJobAgentVars(nil) + cfg := oapi.JobAgentConfig{ + "timeoutSeconds": 30, + "insecure": false, + "nullField": nil, + } + + out, err := renderJobAgentConfig(cfg, dc) + require.NoError(t, err) + assert.Equal(t, 30, out["timeoutSeconds"]) + assert.Equal(t, false, out["insecure"]) + assert.Nil(t, out["nullField"]) +} + +func TestRenderJobAgentConfig_MissingKeyErrors(t *testing.T) { + // templatefuncs.New applies Option("missingkey=zero"): the missing + // top-level "jobAgentVariables" key renders to zero (nil interface), + // and traversing into it (.unknown) raises a nil-pointer error. The + // operator gets a clear template error instead of an empty string + // silently propagating into the agent's config. + dc := dispatchCtxWithJobAgentVars(nil) + cfg := oapi.JobAgentConfig{ + "apiKey": "{{ .jobAgentVariables.unknown }}", + } + + _, err := renderJobAgentConfig(cfg, dc) + require.Error(t, err) + assert.Contains(t, err.Error(), "apiKey") +} + +func TestRenderJobAgentConfig_MissingLeafKey(t *testing.T) { + // When jobAgentVariables exists but the leaf key does not, missingkey=zero + // returns nil for map[string]any element type, which Go's text/template + // prints as "". Operators get a visible marker rather than a + // silent empty string, which is the safer default for security config. + dc := dispatchCtxWithJobAgentVars(map[string]string{"present": "x"}) + cfg := oapi.JobAgentConfig{ + "apiKey": "{{ .jobAgentVariables.absent }}", + } + + out, err := renderJobAgentConfig(cfg, dc) + require.NoError(t, err) + assert.Equal(t, "", out["apiKey"]) +} + +func TestRenderJobAgentConfig_ParseError(t *testing.T) { + dc := dispatchCtxWithJobAgentVars(nil) + cfg := oapi.JobAgentConfig{ + "apiKey": "{{ .unterminated", + } + + _, err := renderJobAgentConfig(cfg, dc) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse template") + assert.Contains(t, err.Error(), "apiKey") +} + +func TestRenderJobAgentConfig_EmptyConfigUnchanged(t *testing.T) { + dc := dispatchCtxWithJobAgentVars(nil) + out, err := renderJobAgentConfig(oapi.JobAgentConfig{}, dc) + require.NoError(t, err) + assert.Empty(t, out) +} + +func TestRenderJobAgentConfig_DispatchContextFieldsAccessible(t *testing.T) { + // Confirm fields other than jobAgentVariables (release, resource etc.) + // are reachable when present on the DispatchContext. + dc := &oapi.DispatchContext{ + Resource: &oapi.Resource{Id: "res-1", Name: "srv-a"}, + } + cfg := oapi.JobAgentConfig{ + "appName": "argo-{{ .resource.name }}", + } + + out, err := renderJobAgentConfig(cfg, dc) + require.NoError(t, err) + assert.Equal(t, "argo-srv-a", out["appName"]) +} diff --git a/apps/workspace-engine/pkg/workspace/jobs/factory.go b/apps/workspace-engine/pkg/workspace/jobs/factory.go index 35a780906..95f64ae0f 100644 --- a/apps/workspace-engine/pkg/workspace/jobs/factory.go +++ b/apps/workspace-engine/pkg/workspace/jobs/factory.go @@ -4,13 +4,16 @@ package jobs import ( "context" "fmt" + "log/slog" "time" "github.com/google/uuid" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" oteltrace "go.opentelemetry.io/otel/trace" + "workspace-engine/pkg/db" "workspace-engine/pkg/oapi" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" ) var tracer = otel.Tracer("workspace/releasemanager/jobs") @@ -21,15 +24,27 @@ type Getters interface { GetResource(ctx context.Context, resourceID uuid.UUID) (*oapi.Resource, error) } -// Factory creates jobs for releases. +// Factory creates jobs for releases. When secretResolver is non-nil and +// the dispatching job agent has job_agent-scoped variables, those are +// resolved at BuildDispatchContext time and surfaced under +// DispatchContext.JobAgentVariables for template interpolation. type Factory struct { - getters Getters + getters Getters + secretResolver variableresolver.SecretResolver } func NewFactoryFromGetters(getters Getters) *Factory { - return &Factory{ - getters: getters, - } + return &Factory{getters: getters} +} + +// NewFactoryWithSecrets constructs a Factory that resolves job-agent scoped +// secret_ref variables for every dispatch. Callers without a secret +// resolver should use NewFactoryFromGetters. +func NewFactoryWithSecrets( + getters Getters, + secretResolver variableresolver.SecretResolver, +) *Factory { + return &Factory{getters: getters, secretResolver: secretResolver} } // BuildDispatchContext builds a dispatch context for a release, fetching @@ -70,11 +85,68 @@ func (f *Factory) BuildDispatchContext( if jobAgent != nil { dc.JobAgent = *jobAgent dc.JobAgentConfig = jobAgent.Config + if err := f.populateJobAgentVariables(ctx, dc, jobAgent); err != nil { + return nil, err + } + // Template-render any `{{ ... }}` strings in the agent config + // against the dispatch context, so agent configs can reference + // resolved jobAgentVariables (e.g. apiKey: "{{ .jobAgentVariables.argo_token }}"). + rendered, err := renderJobAgentConfig(dc.JobAgentConfig, dc) + if err != nil { + return nil, fmt.Errorf("render job agent config: %w", err) + } + dc.JobAgentConfig = rendered } return dc, nil } +// populateJobAgentVariables resolves variables scoped to the dispatching +// job agent and writes them onto the DispatchContext. A nil secretResolver +// (NewFactoryFromGetters caller) short-circuits to a no-op; only callers +// that wired NewFactoryWithSecrets pay for the lookup. +func (f *Factory) populateJobAgentVariables( + ctx context.Context, + dc *oapi.DispatchContext, + jobAgent *oapi.JobAgent, +) error { + if f.secretResolver == nil { + return nil + } + jobAgentID, err := uuid.Parse(jobAgent.Id) + if err != nil { + return fmt.Errorf("parse job agent id: %w", err) + } + workspaceID, err := uuid.Parse(jobAgent.WorkspaceId) + if err != nil { + return fmt.Errorf("parse job agent workspace id: %w", err) + } + + getter := variableresolver.NewPostgresGetter(db.GetQueries(ctx)) + resolved, sensitiveKeys, err := variableresolver.ResolveForJobAgent( + ctx, + getter, + f.secretResolver, + workspaceID, + jobAgentID, + ) + if err != nil { + return fmt.Errorf("resolve job agent variables for %s: %w", jobAgentID, err) + } + if len(resolved) == 0 { + return nil + } + dc.JobAgentVariables = &resolved + if len(sensitiveKeys) > 0 { + slog.InfoContext(ctx, "job agent variables resolved", + "job_agent_id", jobAgentID.String(), + "resolved_count", len(resolved), + "sensitive_count", len(sensitiveKeys), + ) + } + return nil +} + // CreateJobForRelease creates a job for a given release (PURE FUNCTION, NO WRITES). // The job uses the resolved settings already present on the selected job agent. func (f *Factory) CreateJobForRelease( diff --git a/apps/workspace-engine/svc/controllers/deploymentplan/controller.go b/apps/workspace-engine/svc/controllers/deploymentplan/controller.go index 104dc5b83..102ac7c85 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplan/controller.go +++ b/apps/workspace-engine/svc/controllers/deploymentplan/controller.go @@ -20,6 +20,7 @@ import ( "workspace-engine/pkg/reconcile" "workspace-engine/pkg/reconcile/events" "workspace-engine/pkg/reconcile/postgres" + "workspace-engine/pkg/secrets" "workspace-engine/pkg/selector" "workspace-engine/svc" "workspace-engine/svc/controllers/desiredrelease/variableresolver" @@ -191,13 +192,16 @@ func (c *Controller) processTarget( Deployment: deployment, Environment: env, } - variables, err := c.varResolver.Resolve( + variables, sensitiveKeys, err := c.varResolver.Resolve( ctx, scope, plan.DeploymentID.String(), target.ResourceID.String(), ) if err != nil { return fmt.Errorf("resolve variables: %w", err) } + if sensitiveKeys == nil { + sensitiveKeys = []string{} + } release := &oapi.Release{ CreatedAt: plan.CreatedAt.Time.Format(time.RFC3339), @@ -209,7 +213,7 @@ func (c *Controller) processTarget( }, Variables: variables, Version: *version, - EncryptedVariables: []string{}, + EncryptedVariables: sensitiveKeys, } for i := range matchedAgents { @@ -252,7 +256,11 @@ func (c *Controller) processTarget( return nil } -func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { +func New( + workerID string, + pgxPool *pgxpool.Pool, + secretResolver *secrets.Resolver, +) svc.Service { if pgxPool == nil { slog.Error("Failed to get pgx pool") os.Exit(1) @@ -282,7 +290,7 @@ func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { controller := &Controller{ getter: &PostgresGetter{}, setter: &PostgresSetter{queue: enqueueQueue}, - varResolver: NewPostgresVarResolver(variableresolver.NewPostgresGetter(q)), + varResolver: NewPostgresVarResolver(variableresolver.NewPostgresGetter(q), secretResolver), } worker, err := reconcile.NewWorker( diff --git a/apps/workspace-engine/svc/controllers/deploymentplan/controller_test.go b/apps/workspace-engine/svc/controllers/deploymentplan/controller_test.go index f57d7825d..58922b080 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplan/controller_test.go +++ b/apps/workspace-engine/svc/controllers/deploymentplan/controller_test.go @@ -174,14 +174,14 @@ func (m *mockVarResolver) Resolve( _ context.Context, _ *variableresolver.Scope, _, _ string, -) (map[string]oapi.LiteralValue, error) { +) (map[string]oapi.LiteralValue, []string, error) { if m.err != nil { - return nil, m.err + return nil, nil, m.err } if m.variables == nil { - return map[string]oapi.LiteralValue{}, nil + return map[string]oapi.LiteralValue{}, nil, nil } - return m.variables, nil + return m.variables, nil, nil } // --- helpers --- diff --git a/apps/workspace-engine/svc/controllers/deploymentplan/getters.go b/apps/workspace-engine/svc/controllers/deploymentplan/getters.go index 7608c386c..9ba478507 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplan/getters.go +++ b/apps/workspace-engine/svc/controllers/deploymentplan/getters.go @@ -27,11 +27,13 @@ type Getter interface { GetWorkspaceByID(ctx context.Context, id uuid.UUID) (db.Workspace, error) } -// VarResolver resolves deployment variables for a release target. +// VarResolver resolves deployment variables for a release target. The second +// return is the list of variable keys whose value originated from a +// secret_ref — used to populate release.EncryptedVariables. type VarResolver interface { Resolve( ctx context.Context, scope *variableresolver.Scope, deploymentID, resourceID string, - ) (map[string]oapi.LiteralValue, error) + ) (map[string]oapi.LiteralValue, []string, error) } diff --git a/apps/workspace-engine/svc/controllers/deploymentplan/getters_postgres.go b/apps/workspace-engine/svc/controllers/deploymentplan/getters_postgres.go index 8af0b3274..20d31a046 100644 --- a/apps/workspace-engine/svc/controllers/deploymentplan/getters_postgres.go +++ b/apps/workspace-engine/svc/controllers/deploymentplan/getters_postgres.go @@ -94,17 +94,28 @@ func (g *PostgresGetter) GetWorkspaceByID( } type PostgresVarResolver struct { - getter variableresolver.Getter + getter variableresolver.Getter + secretResolver variableresolver.SecretResolver } -func NewPostgresVarResolver(getter variableresolver.Getter) *PostgresVarResolver { - return &PostgresVarResolver{getter: getter} +func NewPostgresVarResolver( + getter variableresolver.Getter, + secretResolver variableresolver.SecretResolver, +) *PostgresVarResolver { + return &PostgresVarResolver{getter: getter, secretResolver: secretResolver} } func (r *PostgresVarResolver) Resolve( ctx context.Context, scope *variableresolver.Scope, deploymentID, resourceID string, -) (map[string]oapi.LiteralValue, error) { - return variableresolver.Resolve(ctx, r.getter, scope, deploymentID, resourceID) +) (map[string]oapi.LiteralValue, []string, error) { + return variableresolver.Resolve( + ctx, + r.getter, + r.secretResolver, + scope, + deploymentID, + resourceID, + ) } diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/adapters.go b/apps/workspace-engine/svc/controllers/desiredrelease/adapters.go index f690a523e..90ee39755 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/adapters.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/adapters.go @@ -10,7 +10,11 @@ func buildRelease( rt *ReleaseTarget, version *oapi.DeploymentVersion, variables map[string]oapi.LiteralValue, + sensitiveKeys []string, ) *oapi.Release { + if sensitiveKeys == nil { + sensitiveKeys = []string{} + } return &oapi.Release{ ReleaseTarget: oapi.ReleaseTarget{ DeploymentId: rt.DeploymentID.String(), @@ -19,7 +23,7 @@ func buildRelease( }, Version: *version, Variables: variables, - EncryptedVariables: []string{}, + EncryptedVariables: sensitiveKeys, CreatedAt: time.Now().Format(time.RFC3339), } } diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/controller.go b/apps/workspace-engine/svc/controllers/desiredrelease/controller.go index 928d5d563..6e52bb1fd 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/controller.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/controller.go @@ -16,18 +16,21 @@ import ( "workspace-engine/pkg/reconcile" "workspace-engine/pkg/reconcile/events" "workspace-engine/pkg/reconcile/postgres" + "workspace-engine/pkg/secrets" "workspace-engine/pkg/store/policies" "workspace-engine/pkg/store/releasetargets" "workspace-engine/svc" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" ) var tracer = otel.Tracer("workspace-engine/svc/controllers/desiredrelease") var _ reconcile.Processor = (*Controller)(nil) type Controller struct { - getter Getter - queries *db.Queries - setter Setter + getter Getter + queries *db.Queries + setter Setter + secretResolver variableresolver.SecretResolver } // Process implements [reconcile.Processor]. @@ -77,7 +80,7 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, nil } - result, err := Reconcile(ctx, item.WorkspaceID, getter, c.setter, rt) + result, err := Reconcile(ctx, item.WorkspaceID, getter, c.setter, c.secretResolver, rt) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -96,11 +99,15 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil // NewController creates a Controller with the given dependencies. // Use this constructor in tests to inject mock implementations. -func NewController(getter Getter, setter Setter) *Controller { - return &Controller{getter: getter, setter: setter} +func NewController( + getter Getter, + setter Setter, + secretResolver variableresolver.SecretResolver, +) *Controller { + return &Controller{getter: getter, setter: setter, secretResolver: secretResolver} } -func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { +func New(workerID string, pgxPool *pgxpool.Pool, secretResolver *secrets.Resolver) svc.Service { if pgxPool == nil { slog.Error("Failed to get pgx pool") os.Exit(1) @@ -127,8 +134,9 @@ func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { queue := postgres.NewForKinds(pgxPool, kind) enqueueQueue := postgres.New(pgxPool) controller := &Controller{ - queries: db.GetQueries(ctx), - setter: NewPostgresSetter(enqueueQueue), + queries: db.GetQueries(ctx), + setter: NewPostgresSetter(enqueueQueue), + secretResolver: secretResolver, } worker, err := reconcile.NewWorker( kind, diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go index 78697f69f..bd4a60943 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile.go @@ -26,14 +26,16 @@ type ReconcileResult struct { type reconciler struct { workspaceID uuid.UUID - getter Getter - setter Setter - rt *ReleaseTarget - - scope *evaluator.EvaluatorScope - policies []*oapi.Policy - version *oapi.DeploymentVersion - vars map[string]oapi.LiteralValue + getter Getter + setter Setter + secretResolver variableresolver.SecretResolver + rt *ReleaseTarget + + scope *evaluator.EvaluatorScope + policies []*oapi.Policy + version *oapi.DeploymentVersion + vars map[string]oapi.LiteralValue + sensitiveVars []string } func (r *reconciler) loadInput(ctx context.Context) (err error) { @@ -82,14 +84,15 @@ func (r *reconciler) resolveVariables(ctx context.Context) error { Deployment: r.scope.Deployment, Environment: r.scope.Environment, } - vars, err := variableresolver.Resolve( - ctx, r.getter, varScope, + vars, sensitive, err := variableresolver.Resolve( + ctx, r.getter, r.secretResolver, varScope, r.rt.DeploymentID.String(), r.rt.ResourceID.String(), ) if err != nil { return err } r.vars = vars + r.sensitiveVars = sensitive return nil } @@ -98,7 +101,7 @@ func (r *reconciler) persistNoDesiredRelease(ctx context.Context) error { } func (r *reconciler) persistRelease(ctx context.Context) (*oapi.Release, error) { - release := buildRelease(r.rt, r.version, r.vars) + release := buildRelease(r.rt, r.version, r.vars, r.sensitiveVars) if err := r.setter.SetDesiredRelease(ctx, r.rt, release); err != nil { return nil, err } @@ -113,6 +116,7 @@ func Reconcile( workspaceID string, getter Getter, setter Setter, + secretResolver variableresolver.SecretResolver, rt *ReleaseTarget, ) (*ReconcileResult, error) { ctx, span := tracer.Start(ctx, "desiredrelease.Reconcile") @@ -124,7 +128,13 @@ func Reconcile( if err != nil { return nil, fmt.Errorf("parse workspace id: %w", err) } - r := &reconciler{workspaceID: workspaceIDUUID, getter: getter, setter: setter, rt: rt} + r := &reconciler{ + workspaceID: workspaceIDUUID, + getter: getter, + setter: setter, + secretResolver: secretResolver, + rt: rt, + } r.rt.WorkspaceID = r.workspaceID if err := r.loadInput(ctx); err != nil { diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go index a00c1f460..e94791110 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/reconcile_test.go @@ -88,6 +88,13 @@ func (m *mockReconcileGetter) GetResourceVariables( return m.resourceVar, nil } +func (m *mockReconcileGetter) GetJobAgentVariables( + _ context.Context, + _ uuid.UUID, +) ([]oapi.DeploymentVariableWithValues, error) { + return nil, nil +} + func (m *mockReconcileGetter) GetRelationshipRules( _ context.Context, _ uuid.UUID, @@ -338,7 +345,7 @@ func TestReconcile_NoVersions(t *testing.T) { } setter := &mockReconcileSetter{} - result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.NotNil(t, result) assert.Nil(t, result.NextReconcileAt) @@ -358,7 +365,7 @@ func TestReconcile_AllPoliciesAllow(t *testing.T) { } setter := &mockReconcileSetter{} - result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.NotNil(t, result) assert.Nil(t, result.NextReconcileAt) @@ -394,7 +401,7 @@ func TestReconcile_PolicyDeniesAllVersions(t *testing.T) { } setter := &mockReconcileSetter{} - result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.NotNil(t, result) @@ -420,7 +427,7 @@ func TestReconcile_SelectsFirstPassingVersion(t *testing.T) { } setter := &mockReconcileSetter{} - result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.NotNil(t, result) @@ -460,7 +467,7 @@ func TestReconcile_AllVersionsDenied_NoRelease(t *testing.T) { } setter := &mockReconcileSetter{} - result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Nil(t, result.NextReconcileAt) assert.Empty(t, setter.releases, "no version should pass the approval gate") @@ -479,7 +486,7 @@ func TestReconcile_UpsertsEvaluationsForPassingVersion(t *testing.T) { } setter := &mockReconcileSetter{} - _, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(ctx, rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.releases, 1, "version should pass with no policies") diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters.go index 0474ddb67..db0d1920d 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters.go @@ -20,6 +20,10 @@ type Getter interface { ctx context.Context, resourceID string, ) (map[string][]oapi.ResourceVariable, error) + GetJobAgentVariables( + ctx context.Context, + jobAgentID uuid.UUID, + ) ([]oapi.DeploymentVariableWithValues, error) GetVariableSetsWithVariables( ctx context.Context, workspaceID uuid.UUID, diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go index 534489eb3..0b90c4eb4 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go @@ -124,6 +124,41 @@ func (g *PostgresGetter) GetDeploymentVariables( return result, nil } +func (g *PostgresGetter) GetJobAgentVariables( + ctx context.Context, + jobAgentID uuid.UUID, +) ([]oapi.DeploymentVariableWithValues, error) { + q := db.GetQueries(ctx) + + rows, err := q.ListVariablesWithValuesByJobAgentID(ctx, jobAgentID) + if err != nil { + return nil, fmt.Errorf("list job_agent variables for %s: %w", jobAgentID, err) + } + + result := make([]oapi.DeploymentVariableWithValues, 0, len(rows)) + for _, row := range rows { + var aggs []db.VariableValueAggRow + if err := json.Unmarshal(row.Values, &aggs); err != nil { + return nil, fmt.Errorf("unmarshal values for variable %s: %w", row.ID, err) + } + + oapiValues := make([]oapi.DeploymentVariableValue, 0, len(aggs)) + for _, a := range aggs { + val, err := db.ToOapiDeploymentVariableValueFromAgg(a) + if err != nil { + return nil, fmt.Errorf("map value %s: %w", a.ID, err) + } + oapiValues = append(oapiValues, val) + } + + result = append(result, oapi.DeploymentVariableWithValues{ + Variable: db.ToOapiJobAgentVariable(row), + Values: oapiValues, + }) + } + return result, nil +} + func (g *PostgresGetter) GetResourceVariables( ctx context.Context, resourceID string, diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/job_agent.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/job_agent.go new file mode 100644 index 000000000..f8a70967b --- /dev/null +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/job_agent.go @@ -0,0 +1,114 @@ +package variableresolver + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "workspace-engine/pkg/oapi" +) + +// JobAgentVarsGetter is the subset of Getter required to resolve +// job_agent-scoped variables. It exists so the jobs.Factory (which only +// needs this one method) can depend on the smaller interface. +type JobAgentVarsGetter interface { + GetJobAgentVariables( + ctx context.Context, + jobAgentID uuid.UUID, + ) ([]oapi.DeploymentVariableWithValues, error) +} + +// ResolveForJobAgent resolves every variable scoped to the given job agent +// and returns the resolved map plus the list of keys whose value originated +// from a secret_ref. Job-agent variables do not honor resource selectors — +// they apply to every dispatch through this agent — so only the highest +// priority candidate per key is evaluated. +// +// The boolean return on each helper is propagated through to populate +// release.EncryptedVariables. Errors wrapping ErrSecretResolution short +// circuit and block the dispatch. +func ResolveForJobAgent( + ctx context.Context, + getter JobAgentVarsGetter, + secretResolver SecretResolver, + workspaceID uuid.UUID, + jobAgentID uuid.UUID, +) (map[string]oapi.LiteralValue, []string, error) { + ctx, span := tracer.Start(ctx, "variableresolver.ResolveForJobAgent") + defer span.End() + span.SetAttributes( + attribute.String("workspace.id", workspaceID.String()), + attribute.String("job_agent.id", jobAgentID.String()), + ) + + vars, err := getter.GetJobAgentVariables(ctx, jobAgentID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "get job_agent variables failed") + return nil, nil, fmt.Errorf("get job_agent variables: %w", err) + } + span.SetAttributes(attribute.Int("job_agent_variables.count", len(vars))) + + if len(vars) == 0 { + return map[string]oapi.LiteralValue{}, nil, nil + } + + resolved := make(map[string]oapi.LiteralValue, len(vars)) + var sensitiveKeys []string + + for _, v := range vars { + key := v.Variable.Key + if len(v.Values) == 0 { + continue + } + + values := append([]oapi.DeploymentVariableValue(nil), v.Values...) + sort.Slice(values, func(i, j int) bool { + return values[i].Priority > values[j].Priority + }) + + for _, vv := range values { + // Job-agent variables resolve outside a release-target context, + // so reference values (which traverse related entities) cannot + // be resolved here. Skip non-literal, non-secret_ref kinds. + valueType, err := vv.Value.GetType() + if err != nil { + continue + } + if valueType != "literal" && valueType != "secret_ref" { + continue + } + lv, sensitive, err := ResolveValue( + ctx, + nil, + secretResolver, + workspaceID, + "", + nil, + &vv.Value, + ) + if errors.Is(err, ErrSecretResolution) { + span.RecordError(err) + span.SetStatus(codes.Error, "resolve job_agent secret_ref failed") + return nil, nil, fmt.Errorf("resolve job_agent variable %q: %w", key, err) + } + if err == nil && lv != nil { + resolved[key] = *lv + if sensitive { + sensitiveKeys = append(sensitiveKeys, key) + } + break + } + } + } + + span.SetAttributes( + attribute.Int("job_agent_variables.resolved", len(resolved)), + attribute.Int("job_agent_variables.sensitive", len(sensitiveKeys)), + ) + return resolved, sensitiveKeys, nil +} diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go index ab5a5290c..f9c14818b 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go @@ -2,6 +2,7 @@ package variableresolver import ( "context" + "errors" "fmt" "sort" @@ -33,7 +34,9 @@ type Scope struct { Environment *oapi.Environment } -// Resolve computes the final set of variables for a release target. +// Resolve computes the final set of variables for a release target. The +// second return is the list of variable keys whose value originated from a +// secret_ref — used to populate release.EncryptedVariables. // // Resolution priority (per variable key): // 1. Resource variable with matching key (highest priority) @@ -45,9 +48,10 @@ type Scope struct { func Resolve( ctx context.Context, getter Getter, + secretResolver SecretResolver, scope *Scope, deploymentID, resourceID string, -) (map[string]oapi.LiteralValue, error) { +) (map[string]oapi.LiteralValue, []string, error) { ctx, span := tracer.Start(ctx, "variableresolver.Resolve") defer span.End() @@ -60,32 +64,32 @@ func Resolve( if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "get deployment variables failed") - return nil, fmt.Errorf("get deployment variables: %w", err) + return nil, nil, fmt.Errorf("get deployment variables: %w", err) } span.SetAttributes(attribute.Int("deployment_variables.count", len(deploymentVars))) if len(deploymentVars) == 0 { - return map[string]oapi.LiteralValue{}, nil + return map[string]oapi.LiteralValue{}, nil, nil } resourceVars, err := getter.GetResourceVariables(ctx, resourceID) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "get resource variables failed") - return nil, fmt.Errorf("get resource variables: %w", err) + return nil, nil, fmt.Errorf("get resource variables: %w", err) } span.SetAttributes(attribute.Int("resource_variables.count", len(resourceVars))) wsID, err := uuid.Parse(scope.Resource.WorkspaceId) if err != nil { - return nil, fmt.Errorf("parse workspace id: %w", err) + return nil, nil, fmt.Errorf("parse workspace id: %w", err) } variableSets, err := getter.GetVariableSetsWithVariables(ctx, wsID) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "get variable sets with variables failed") - return nil, fmt.Errorf("get variable sets with variables: %w", err) + return nil, nil, fmt.Errorf("get variable sets with variables: %w", err) } span.SetAttributes(attribute.Int("variable_sets.count", len(variableSets))) @@ -96,7 +100,7 @@ func Resolve( if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "get relationship rules failed") - return nil, fmt.Errorf("get relationship rules: %w", err) + return nil, nil, fmt.Errorf("get relationship rules: %w", err) } resolver := newRealtimeResolver(getter, scope.Resource, wsID, rules) @@ -104,51 +108,84 @@ func Resolve( entity := NewResourceEntity(scope.Resource) resolved := make(map[string]oapi.LiteralValue, len(deploymentVars)) + var sensitiveKeys []string var fromResource, fromValue, fromVariableSet int for _, dv := range deploymentVars { key := dv.Variable.Key - if lv := resolveFromResource( + lv, sensitive, err := resolveFromResource( ctx, resolver, + secretResolver, + wsID, resourceID, key, resourceVars, scope.Resource, entity, - ); lv != nil { + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "resolve variable from resource failed") + return nil, nil, fmt.Errorf("resolve variable %q: %w", key, err) + } + if lv != nil { resolved[key] = *lv + if sensitive { + sensitiveKeys = append(sensitiveKeys, key) + } fromResource++ continue } - if lv := resolveFromValues( + lv, sensitive, err = resolveFromValues( ctx, resolver, + secretResolver, + wsID, resourceID, dv.Values, scope.Resource, entity, - ); lv != nil { + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "resolve variable from values failed") + return nil, nil, fmt.Errorf("resolve variable %q: %w", key, err) + } + if lv != nil { resolved[key] = *lv + if sensitive { + sensitiveKeys = append(sensitiveKeys, key) + } fromValue++ continue } - if lv := resolveFromVariableSets( + lv, sensitive, err = resolveFromVariableSets( ctx, key, filteredVariableSets, resolver, + secretResolver, + wsID, resourceID, entity, - ); lv != nil { + ) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "resolve variable from variable sets failed") + return nil, nil, fmt.Errorf("resolve variable %q: %w", key, err) + } + if lv != nil { resolved[key] = *lv + if sensitive { + sensitiveKeys = append(sensitiveKeys, key) + } fromVariableSet++ continue } - } span.SetAttributes( @@ -156,8 +193,9 @@ func Resolve( attribute.Int("resolved.from_resource", fromResource), attribute.Int("resolved.from_value", fromValue), attribute.Int("resolved.from_variable_set", fromVariableSet), + attribute.Int("resolved.sensitive", len(sensitiveKeys)), ) - return resolved, nil + return resolved, sensitiveKeys, nil } // realtimeResolver evaluates relationship rules in realtime to resolve @@ -248,15 +286,17 @@ func (r *realtimeResolver) ResolveRelated( func resolveFromResource( ctx context.Context, resolver RelatedEntityResolver, + secretResolver SecretResolver, + workspaceID uuid.UUID, resourceID string, key string, resourceVars map[string][]oapi.ResourceVariable, resource *oapi.Resource, entity *oapi.RelatableEntity, -) *oapi.LiteralValue { +) (*oapi.LiteralValue, bool, error) { candidates, ok := resourceVars[key] if !ok || len(candidates) == 0 { - return nil + return nil, false, nil } matched := make([]oapi.ResourceVariable, 0, len(candidates)) @@ -270,7 +310,7 @@ func resolveFromResource( } } if len(matched) == 0 { - return nil + return nil, false, nil } sort.Slice(matched, func(i, j int) bool { @@ -278,12 +318,23 @@ func resolveFromResource( }) for _, rv := range matched { - lv, err := ResolveValue(ctx, resolver, resourceID, entity, &rv.Value) + lv, sensitive, err := ResolveValue( + ctx, + resolver, + secretResolver, + workspaceID, + resourceID, + entity, + &rv.Value, + ) + if errors.Is(err, ErrSecretResolution) { + return nil, false, err + } if err == nil && lv != nil { - return lv + return lv, sensitive, nil } } - return nil + return nil, false, nil } // resolveFromValues finds the highest-priority deployment variable value @@ -291,11 +342,13 @@ func resolveFromResource( func resolveFromValues( ctx context.Context, resolver RelatedEntityResolver, + secretResolver SecretResolver, + workspaceID uuid.UUID, resourceID string, values []oapi.DeploymentVariableValue, resource *oapi.Resource, entity *oapi.RelatableEntity, -) *oapi.LiteralValue { +) (*oapi.LiteralValue, bool, error) { matched := make([]oapi.DeploymentVariableValue, 0, len(values)) for _, v := range values { if v.ResourceSelector == nil { @@ -308,7 +361,7 @@ func resolveFromValues( } } if len(matched) == 0 { - return nil + return nil, false, nil } sort.Slice(matched, func(i, j int) bool { @@ -316,12 +369,23 @@ func resolveFromValues( }) for _, v := range matched { - lv, err := ResolveValue(ctx, resolver, resourceID, entity, &v.Value) + lv, sensitive, err := ResolveValue( + ctx, + resolver, + secretResolver, + workspaceID, + resourceID, + entity, + &v.Value, + ) + if errors.Is(err, ErrSecretResolution) { + return nil, false, err + } if err == nil && lv != nil { - return lv + return lv, sensitive, nil } } - return nil + return nil, false, nil } func resolveFromVariableSets( @@ -329,20 +393,33 @@ func resolveFromVariableSets( key string, variableSets []oapi.VariableSetWithVariables, resolver RelatedEntityResolver, + secretResolver SecretResolver, + workspaceID uuid.UUID, resourceID string, entity *oapi.RelatableEntity, -) *oapi.LiteralValue { +) (*oapi.LiteralValue, bool, error) { for _, vs := range variableSets { for _, v := range vs.Variables { if v.Key == key { - lv, err := ResolveValue(ctx, resolver, resourceID, entity, &v.Value) + lv, sensitive, err := ResolveValue( + ctx, + resolver, + secretResolver, + workspaceID, + resourceID, + entity, + &v.Value, + ) + if errors.Is(err, ErrSecretResolution) { + return nil, false, err + } if err == nil && lv != nil { - return lv + return lv, sensitive, nil } } } } - return nil + return nil, false, nil } func filterVariableSets( diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve_test.go index 4241c6e2a..dc71a1e79 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve_test.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve_test.go @@ -31,9 +31,11 @@ func (m *mockResolver) ResolveRelated( // mock Getter (for Resolve tests) // --------------------------------------------------------------------------- +// mockGetter is the in-memory variableresolver.Getter used by these tests. type mockGetter struct { deploymentVars []oapi.DeploymentVariableWithValues resourceVars map[string][]oapi.ResourceVariable + jobAgentVars []oapi.DeploymentVariableWithValues variableSets []oapi.VariableSetWithVariables rules []eval.Rule candidates map[string][]eval.EntityData @@ -77,6 +79,13 @@ func (m *mockGetter) GetResourceVariables( return m.resourceVars, nil } +func (m *mockGetter) GetJobAgentVariables( + _ context.Context, + _ uuid.UUID, +) ([]oapi.DeploymentVariableWithValues, error) { + return m.jobAgentVars, nil +} + func (m *mockGetter) GetVariableSetsWithVariables( ctx context.Context, workspaceID uuid.UUID, @@ -196,7 +205,15 @@ func TestResolveValue_Literal_String(t *testing.T) { scope := newScope() val := literalStringValue("hello") entity := makeResourceEntity(scope.Resource) - lv, err := ResolveValue(context.Background(), emptyResolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + emptyResolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) s, err := lv.AsStringValue() require.NoError(t, err) @@ -207,7 +224,15 @@ func TestResolveValue_Literal_Int(t *testing.T) { scope := newScope() val := literalIntValue(42) entity := makeResourceEntity(scope.Resource) - lv, err := ResolveValue(context.Background(), emptyResolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + emptyResolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) i, err := lv.AsIntegerValue() require.NoError(t, err) @@ -218,7 +243,15 @@ func TestResolveValue_Literal_Bool(t *testing.T) { scope := newScope() val := literalBoolValue(true) entity := makeResourceEntity(scope.Resource) - lv, err := ResolveValue(context.Background(), emptyResolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + emptyResolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) b, err := lv.AsBooleanValue() require.NoError(t, err) @@ -250,7 +283,15 @@ func TestResolveValue_Reference_ResourceName(t *testing.T) { } val := referenceValue("database", "name") - lv, err := ResolveValue(context.Background(), resolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + resolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) s, err := lv.AsStringValue() require.NoError(t, err) @@ -278,7 +319,15 @@ func TestResolveValue_Reference_ResourceMetadata(t *testing.T) { } val := referenceValue("network", "metadata", "cidr") - lv, err := ResolveValue(context.Background(), resolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + resolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) s, err := lv.AsStringValue() require.NoError(t, err) @@ -301,7 +350,15 @@ func TestResolveValue_Reference_DeploymentName(t *testing.T) { } val := referenceValue("parent-deployment", "name") - lv, err := ResolveValue(context.Background(), resolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + resolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) s, err := lv.AsStringValue() require.NoError(t, err) @@ -323,7 +380,15 @@ func TestResolveValue_Reference_EnvironmentName(t *testing.T) { } val := referenceValue("env", "name") - lv, err := ResolveValue(context.Background(), resolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + resolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) s, err := lv.AsStringValue() require.NoError(t, err) @@ -334,7 +399,15 @@ func TestResolveValue_Reference_NotFound(t *testing.T) { scope := newScope() entity := makeResourceEntity(scope.Resource) val := referenceValue("nonexistent", "name") - _, err := ResolveValue(context.Background(), emptyResolver, scope.Resource.Id, &entity, &val) + _, _, err := ResolveValue( + context.Background(), + emptyResolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.Error(t, err) assert.Contains(t, err.Error(), "not found") } @@ -360,7 +433,15 @@ func TestResolveValue_Reference_BadPath(t *testing.T) { } val := referenceValue("database", "metadata", "missing_key") - _, err := ResolveValue(context.Background(), resolver, scope.Resource.Id, &entity, &val) + _, _, err := ResolveValue( + context.Background(), + resolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.Error(t, err) assert.Contains(t, err.Error(), "not found") } @@ -409,9 +490,10 @@ func TestResolve_ResourceVariableSelectorPriority(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -450,9 +532,10 @@ func TestResolve_ResourceVarWins(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -489,9 +572,10 @@ func TestResolve_DeploymentVariableValueUsedWhenNoResourceVar(t *testing.T) { resourceVars: map[string][]oapi.ResourceVariable{}, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -528,9 +612,10 @@ func TestResolve_DefaultValueFallback(t *testing.T) { resourceVars: map[string][]oapi.ResourceVariable{}, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -561,9 +646,10 @@ func TestResolve_NoMatchNoDefault_KeyAbsent(t *testing.T) { resourceVars: map[string][]oapi.ResourceVariable{}, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -611,9 +697,10 @@ func TestResolve_HighestPriorityValueWins(t *testing.T) { resourceVars: map[string][]oapi.ResourceVariable{}, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -812,9 +899,10 @@ func TestResolve_DeploymentVarValue_DefaultAndSelectorGatedOverride(t *testing.T candidates: candidates, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -844,9 +932,10 @@ func TestResolve_MultipleVariables(t *testing.T) { resourceVars: map[string][]oapi.ResourceVariable{}, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -871,9 +960,10 @@ func TestResolve_MultipleVariables(t *testing.T) { func TestResolve_NoDeploymentVars_EmptyMap(t *testing.T) { scope := newScope() - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), emptyGetter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -952,9 +1042,10 @@ func TestResolve_ResourceVar_WithReference(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1033,9 +1124,10 @@ func TestResolve_DeploymentVarValue_WithReference(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1130,9 +1222,10 @@ func TestResolve_MixedLiteralAndReference(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1179,9 +1272,10 @@ func TestResolve_ResourceVarRefFails_FallsToDeploymentValue(t *testing.T) { rules: []eval.Rule{}, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1213,7 +1307,15 @@ func TestResolveValue_Reference_ResourceConfig(t *testing.T) { } val := referenceValue("self", "config", "networking", "vpc_id") - lv, err := ResolveValue(context.Background(), resolver, scope.Resource.Id, &entity, &val) + lv, _, err := ResolveValue( + context.Background(), + resolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + &val, + ) require.NoError(t, err) s, err := lv.AsStringValue() require.NoError(t, err) @@ -1230,7 +1332,15 @@ func TestResolveValue_Sensitive_ReturnsError(t *testing.T) { _ = v.FromSensitiveValue(oapi.SensitiveValue{ValueHash: "abc123"}) entity := makeResourceEntity(scope.Resource) - _, err := ResolveValue(context.Background(), emptyResolver, scope.Resource.Id, &entity, v) + _, _, err := ResolveValue( + context.Background(), + emptyResolver, + nil, + uuid.Nil, + scope.Resource.Id, + &entity, + v, + ) require.Error(t, err) assert.Contains(t, err.Error(), "sensitive") } @@ -1293,9 +1403,10 @@ func TestResolve_VariableSet_SimpleInjection(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1343,9 +1454,10 @@ func TestResolve_VariableSet_DoesNotOverwriteResourceVar(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1393,9 +1505,10 @@ func TestResolve_VariableSet_DoesNotOverwriteDeploymentVarValue(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1453,9 +1566,10 @@ func TestResolve_VariableSet_HighestPriorityWins(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, @@ -1497,9 +1611,10 @@ func TestResolve_VariableSet_UnrelatedDoNotMatch(t *testing.T) { }, } - resolved, err := Resolve( + resolved, _, err := Resolve( context.Background(), getter, + nil, scope, scope.Deployment.Id, scope.Resource.Id, diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/value.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/value.go index 53b444f26..9e756a7a8 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/value.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/value.go @@ -2,12 +2,21 @@ package variableresolver import ( "context" + "errors" "fmt" + "github.com/google/uuid" "workspace-engine/pkg/oapi" + "workspace-engine/pkg/secrets" "workspace-engine/pkg/workspace/relationships" ) +// ErrSecretResolution is wrapped by every error originating from secret_ref +// resolution so the priority-cascade helpers can distinguish a transient +// candidate-skip (literal/reference) from a fatal upstream failure that must +// block the release. +var ErrSecretResolution = errors.New("secret resolution failed") + // RelatedEntityResolver resolves a reference name to the matched related // entities for a resource. Implementations may evaluate relationship rules // in realtime or return pre-computed/mocked results. @@ -15,37 +24,103 @@ type RelatedEntityResolver interface { ResolveRelated(ctx context.Context, reference string) ([]*oapi.RelatableEntity, error) } +// SecretResolver fetches the plaintext value for a SecretReferenceValue. +// *secrets.Resolver satisfies this interface; tests use fakes. +type SecretResolver interface { + Resolve(ctx context.Context, workspaceID uuid.UUID, ref secrets.SecretReference) (string, error) +} + // ResolveValue resolves a single oapi.Value to a concrete LiteralValue. // // Literal values are returned as-is. Reference values are resolved by // finding related entities through the resolver and traversing the property -// path on the matched entity. Sensitive values are not resolved and return -// an error — they must be handled by a separate decryption path. +// path on the matched entity. Secret references are fetched through the +// SecretResolver and returned as string literals. Sensitive values without a +// concrete provider reference remain unresolvable. +// +// The boolean return is true when the resolved value originated from a +// secret_ref. Callers use it to populate release.EncryptedVariables so that +// downstream consumers can mark the value as sensitive in logs and UI. func ResolveValue( ctx context.Context, resolver RelatedEntityResolver, + secretResolver SecretResolver, + workspaceID uuid.UUID, resourceID string, entity *oapi.RelatableEntity, value *oapi.Value, -) (*oapi.LiteralValue, error) { - _, span := tracer.Start(ctx, "variableresolver.ResolveValue") +) (*oapi.LiteralValue, bool, error) { + ctx, span := tracer.Start(ctx, "variableresolver.ResolveValue") defer span.End() valueType, err := value.GetType() if err != nil { - return nil, fmt.Errorf("determine value type: %w", err) + return nil, false, fmt.Errorf("determine value type: %w", err) } switch valueType { case "literal": - return resolveLiteral(value) + lv, err := resolveLiteral(value) + return lv, false, err case "reference": - return resolveReference(ctx, resolver, value, entity) + lv, err := resolveReference(ctx, resolver, value, entity) + return lv, false, err + case "secret_ref": + lv, err := resolveSecretReference(ctx, secretResolver, workspaceID, value) + return lv, err == nil, err case "sensitive": - return nil, fmt.Errorf("sensitive values are not resolved by the variable resolver") + return nil, false, fmt.Errorf( + "sensitive values are not resolved by the variable resolver", + ) default: - return nil, fmt.Errorf("unsupported value type: %s", valueType) + return nil, false, fmt.Errorf("unsupported value type: %s", valueType) + } +} + +func resolveSecretReference( + ctx context.Context, + secretResolver SecretResolver, + workspaceID uuid.UUID, + value *oapi.Value, +) (*oapi.LiteralValue, error) { + if secretResolver == nil { + return nil, fmt.Errorf( + "%w: no SecretResolver configured (VARIABLES_AES_256_KEY unset?)", + ErrSecretResolution, + ) + } + srv, err := value.AsSecretReferenceValue() + if err != nil { + return nil, fmt.Errorf("%w: extract secret reference value: %w", ErrSecretResolution, err) + } + ref := secrets.SecretReference{ + Provider: srv.SecretProvider, + Key: srv.SecretKey, + } + if srv.SecretPath != nil && len(*srv.SecretPath) > 0 { + // Provider-specific path serialization: join with "/" so Doppler + // (project/config) and AWS (secret name + optional segments) can + // reuse the canonical Path field rather than carrying an array. + ref.Path = (*srv.SecretPath)[0] + for i := 1; i < len(*srv.SecretPath); i++ { + ref.Path += "/" + (*srv.SecretPath)[i] + } + } + if srv.SecretVersion != nil { + ref.Version = *srv.SecretVersion + } + plaintext, err := secretResolver.Resolve(ctx, workspaceID, ref) + if err != nil { + return nil, fmt.Errorf( + "%w: %s/%s/%s: %w", + ErrSecretResolution, + ref.Provider, + ref.Path, + ref.Key, + err, + ) } + return oapi.NewLiteralValue(plaintext), nil } func resolveLiteral(value *oapi.Value) (*oapi.LiteralValue, error) { diff --git a/apps/workspace-engine/svc/controllers/forcedeploy/controller.go b/apps/workspace-engine/svc/controllers/forcedeploy/controller.go index 857c9c78f..41ca15a39 100644 --- a/apps/workspace-engine/svc/controllers/forcedeploy/controller.go +++ b/apps/workspace-engine/svc/controllers/forcedeploy/controller.go @@ -16,14 +16,16 @@ import ( "workspace-engine/pkg/reconcile/events" "workspace-engine/pkg/reconcile/postgres" "workspace-engine/svc" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" ) var tracer = otel.Tracer("workspace-engine/svc/controllers/forcedeploy") var _ reconcile.Processor = (*Controller)(nil) type Controller struct { - getter Getter - setter Setter + getter Getter + setter Setter + secretResolver variableresolver.SecretResolver } func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcile.Result, error) { @@ -50,7 +52,7 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, nil } - result, err := Reconcile(ctx, item.WorkspaceID, c.getter, c.setter, rt) + result, err := Reconcile(ctx, item.WorkspaceID, c.getter, c.setter, c.secretResolver, rt) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -66,11 +68,19 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil // NewController creates a Controller with the given dependencies. // Use this constructor in tests to inject mock implementations. -func NewController(getter Getter, setter Setter) *Controller { - return &Controller{getter: getter, setter: setter} +func NewController( + getter Getter, + setter Setter, + secretResolver variableresolver.SecretResolver, +) *Controller { + return &Controller{getter: getter, setter: setter, secretResolver: secretResolver} } -func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { +func New( + workerID string, + pgxPool *pgxpool.Pool, + secretResolver variableresolver.SecretResolver, +) svc.Service { if pgxPool == nil { slog.Error("Failed to get pgx pool") os.Exit(1) @@ -92,8 +102,9 @@ func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { queue := postgres.NewForKinds(pgxPool, kind) controller := &Controller{ - getter: &PostgresGetter{}, - setter: &PostgresSetter{}, + getter: &PostgresGetter{}, + setter: &PostgresSetter{}, + secretResolver: secretResolver, } worker, err := reconcile.NewWorker(kind, queue, controller, nodeConfig) diff --git a/apps/workspace-engine/svc/controllers/forcedeploy/reconcile.go b/apps/workspace-engine/svc/controllers/forcedeploy/reconcile.go index 0e7a65ad9..114a9b3f7 100644 --- a/apps/workspace-engine/svc/controllers/forcedeploy/reconcile.go +++ b/apps/workspace-engine/svc/controllers/forcedeploy/reconcile.go @@ -13,6 +13,7 @@ import ( "workspace-engine/pkg/oapi" "workspace-engine/pkg/selector" "workspace-engine/pkg/workspace/jobs" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" ) const requeueDelay = 5 * time.Second @@ -26,6 +27,7 @@ func Reconcile( workspaceID string, getter Getter, setter Setter, + secretResolver variableresolver.SecretResolver, rt *ReleaseTarget, ) (*ReconcileResult, error) { ctx, span := tracer.Start(ctx, "forcedeploy.Reconcile") @@ -69,6 +71,7 @@ func Reconcile( workspaceUUID, getter, setter, + secretResolver, rt, release, ); err != nil { @@ -84,6 +87,7 @@ func buildAndDispatchJob( workspaceID uuid.UUID, getter Getter, setter Setter, + secretResolver variableresolver.SecretResolver, rt *ReleaseTarget, release *oapi.Release, ) error { @@ -131,7 +135,7 @@ func buildAndDispatchJob( ) } - factory := jobs.NewFactoryFromGetters(getter) + factory := jobs.NewFactoryWithSecrets(getter, secretResolver) for i := range matchedAgents { agent := &matchedAgents[i] agent.Config = oapi.DeepMergeConfigs( diff --git a/apps/workspace-engine/svc/controllers/forcedeploy/reconcile_test.go b/apps/workspace-engine/svc/controllers/forcedeploy/reconcile_test.go index 815566b93..a253cf827 100644 --- a/apps/workspace-engine/svc/controllers/forcedeploy/reconcile_test.go +++ b/apps/workspace-engine/svc/controllers/forcedeploy/reconcile_test.go @@ -193,7 +193,7 @@ func TestReconcile_HappyPath_CreatesJobAndEnqueuesDispatch(t *testing.T) { release := testRelease(rt) getter, setter := defaultMocks(rt, release) - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Zero(t, result.RequeueAfter) @@ -215,7 +215,7 @@ func TestReconcile_HappyPath_WithCompletedJob(t *testing.T) { // are not included. getter.activeJobs = []*oapi.Job{} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Zero(t, result.RequeueAfter) @@ -230,7 +230,7 @@ func TestReconcile_NoDesiredRelease_Noop(t *testing.T) { } setter := &mockSetter{} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Zero(t, result.RequeueAfter) @@ -254,7 +254,7 @@ func TestReconcile_ActiveJobExists_Requeues(t *testing.T) { }, } - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Equal(t, requeueDelay, result.RequeueAfter) @@ -278,7 +278,7 @@ func TestReconcile_ActivePendingJob_Requeues(t *testing.T) { }, } - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Equal(t, requeueDelay, result.RequeueAfter) @@ -301,7 +301,7 @@ func TestProcess_ActiveJob_ReturnsRequeueResult(t *testing.T) { }, } - ctrl := NewController(getter, setter) + ctrl := NewController(getter, setter, nil) item := reconcile.Item{ ID: 1, WorkspaceID: rt.WorkspaceID.String(), diff --git a/apps/workspace-engine/svc/controllers/jobeligibility/controller.go b/apps/workspace-engine/svc/controllers/jobeligibility/controller.go index 74b9b04b7..f8269186e 100644 --- a/apps/workspace-engine/svc/controllers/jobeligibility/controller.go +++ b/apps/workspace-engine/svc/controllers/jobeligibility/controller.go @@ -17,14 +17,16 @@ import ( "workspace-engine/pkg/reconcile/postgres" "workspace-engine/pkg/store/policies" "workspace-engine/svc" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" ) var tracer = otel.Tracer("workspace-engine/svc/controllers/jobeligibility") var _ reconcile.Processor = (*Controller)(nil) type Controller struct { - getter Getter - setter Setter + getter Getter + setter Setter + secretResolver variableresolver.SecretResolver } // Process implements [reconcile.Processor]. @@ -56,7 +58,7 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil return reconcile.Result{}, nil } - result, err := Reconcile(ctx, item.WorkspaceID, c.getter, c.setter, rt) + result, err := Reconcile(ctx, item.WorkspaceID, c.getter, c.setter, c.secretResolver, rt) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -75,11 +77,19 @@ func (c *Controller) Process(ctx context.Context, item reconcile.Item) (reconcil // NewController creates a Controller with the given dependencies. // Use this constructor in tests to inject mock implementations. -func NewController(getter Getter, setter Setter) *Controller { - return &Controller{getter: getter, setter: setter} +func NewController( + getter Getter, + setter Setter, + secretResolver variableresolver.SecretResolver, +) *Controller { + return &Controller{getter: getter, setter: setter, secretResolver: secretResolver} } -func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { +func New( + workerID string, + pgxPool *pgxpool.Pool, + secretResolver variableresolver.SecretResolver, +) svc.Service { if pgxPool == nil { slog.Error("Failed to get pgx pool") os.Exit(1) @@ -109,7 +119,8 @@ func New(workerID string, pgxPool *pgxpool.Pool) svc.Service { getter: NewPostgresGetter( policies.NewPostgresGetPoliciesForReleaseTarget(policies.WithCache(5 * time.Minute)), ), - setter: &PostgresSetter{Queue: enqueueQueue}, + setter: &PostgresSetter{Queue: enqueueQueue}, + secretResolver: secretResolver, } worker, err := reconcile.NewWorker( kind, diff --git a/apps/workspace-engine/svc/controllers/jobeligibility/reconcile.go b/apps/workspace-engine/svc/controllers/jobeligibility/reconcile.go index 1b5243e8c..d468d11fb 100644 --- a/apps/workspace-engine/svc/controllers/jobeligibility/reconcile.go +++ b/apps/workspace-engine/svc/controllers/jobeligibility/reconcile.go @@ -15,6 +15,7 @@ import ( "workspace-engine/pkg/workspace/releasemanager/policy/evaluator" "workspace-engine/pkg/workspace/releasemanager/policy/evaluator/releasetargetconcurrency" "workspace-engine/pkg/workspace/releasemanager/policy/evaluator/retry" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" ) type ReconcileResult struct { @@ -24,9 +25,10 @@ type ReconcileResult struct { type reconciler struct { workspaceID uuid.UUID - getter Getter - setter Setter - rt *ReleaseTarget + getter Getter + setter Setter + secretResolver variableresolver.SecretResolver + rt *ReleaseTarget release *oapi.Release policies []*oapi.Policy @@ -151,7 +153,7 @@ func (r *reconciler) createFailureJob( ) error { now := time.Now() - factory := jobs.NewFactoryFromGetters(r.getter) + factory := jobs.NewFactoryWithSecrets(r.getter, r.secretResolver) deploymentID, err := uuid.Parse(r.release.ReleaseTarget.DeploymentId) if err != nil { return fmt.Errorf("parse deployment id: %w", err) @@ -278,7 +280,7 @@ func (r *reconciler) buildAndDispatchJob(ctx context.Context) error { agent.Config, deployment.JobAgentConfig, r.release.Version.JobAgentConfig, ) - job, err := jobs.NewFactoryFromGetters(r.getter). + job, err := jobs.NewFactoryWithSecrets(r.getter, r.secretResolver). CreateJobForRelease(ctx, r.release, agent) if err != nil { return recordErr(span, "build job", err) @@ -303,6 +305,7 @@ func Reconcile( workspaceID string, getter Getter, setter Setter, + secretResolver variableresolver.SecretResolver, rt *ReleaseTarget, ) (*ReconcileResult, error) { ctx, span := tracer.Start(ctx, "jobeligibility.Reconcile") @@ -313,7 +316,13 @@ func Reconcile( return nil, fmt.Errorf("parse workspace id: %w", err) } - r := &reconciler{workspaceID: workspaceIDUUID, getter: getter, setter: setter, rt: rt} + r := &reconciler{ + workspaceID: workspaceIDUUID, + getter: getter, + setter: setter, + secretResolver: secretResolver, + rt: rt, + } r.rt.WorkspaceID = r.workspaceID if err := r.loadInput(ctx); err != nil { diff --git a/apps/workspace-engine/svc/controllers/jobeligibility/reconcile_test.go b/apps/workspace-engine/svc/controllers/jobeligibility/reconcile_test.go index 8efb60838..8a3adc006 100644 --- a/apps/workspace-engine/svc/controllers/jobeligibility/reconcile_test.go +++ b/apps/workspace-engine/svc/controllers/jobeligibility/reconcile_test.go @@ -338,7 +338,7 @@ func TestNewReleaseTarget_NonUUIDLast(t *testing.T) { func TestProcess_InvalidScopeID(t *testing.T) { getter := &mockGetter{} setter := &mockSetter{} - ctrl := NewController(getter, setter) + ctrl := NewController(getter, setter, nil) item := reconcile.Item{ ID: 1, @@ -356,7 +356,7 @@ func TestProcess_ReleaseTargetNotFound(t *testing.T) { rt := testRT() getter := &mockGetter{rtExists: false} setter := &mockSetter{} - ctrl := NewController(getter, setter) + ctrl := NewController(getter, setter, nil) item := reconcile.Item{ ID: 1, @@ -374,7 +374,7 @@ func TestProcess_ReleaseTargetExistsCheckFails(t *testing.T) { rt := testRT() getter := &mockGetter{rtExistsErr: fmt.Errorf("db error")} setter := &mockSetter{} - ctrl := NewController(getter, setter) + ctrl := NewController(getter, setter, nil) item := reconcile.Item{ ID: 1, @@ -394,7 +394,7 @@ func TestProcess_ReconcileError(t *testing.T) { releaseErr: fmt.Errorf("release fetch failed"), } setter := &mockSetter{} - ctrl := NewController(getter, setter) + ctrl := NewController(getter, setter, nil) item := reconcile.Item{ ID: 1, @@ -433,7 +433,7 @@ func TestProcess_RequeueOnBackoff(t *testing.T) { workspaceAgents: []oapi.JobAgent{*agent}, } setter := &mockSetter{} - ctrl := NewController(getter, setter) + ctrl := NewController(getter, setter, nil) item := reconcile.Item{ ID: 1, @@ -456,7 +456,7 @@ func TestReconcile_NoDesiredRelease(t *testing.T) { getter := &mockGetter{release: nil} setter := &mockSetter{} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.NotNil(t, result) assert.Nil(t, result.NextReconcileAt) @@ -468,7 +468,7 @@ func TestReconcile_GetDesiredReleaseFails(t *testing.T) { getter := &mockGetter{releaseErr: fmt.Errorf("db down")} setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "get desired release") } @@ -483,7 +483,7 @@ func TestReconcile_GetPoliciesFails(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "get policies for release target") } @@ -493,7 +493,7 @@ func TestReconcile_InvalidWorkspaceID(t *testing.T) { getter := &mockGetter{} setter := &mockSetter{} - _, err := Reconcile(context.Background(), "not-a-uuid", getter, setter, rt) + _, err := Reconcile(context.Background(), "not-a-uuid", getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "parse workspace id") } @@ -503,7 +503,7 @@ func TestReconcile_HappyPath_CreatesAndDispatchesJob(t *testing.T) { release := testRelease(rt) getter, setter := setupHappyPath(rt, release) - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.NotNil(t, result) assert.Nil(t, result.NextReconcileAt) @@ -540,6 +540,7 @@ func TestReconcile_ActiveJobBlocks(t *testing.T) { rt.WorkspaceID.String(), getter, setter, + nil, rt, ) require.NoError(t, err) @@ -582,6 +583,7 @@ func TestReconcile_TerminalStatusDoesNotBlock(t *testing.T) { rt.WorkspaceID.String(), getter, setter, + nil, rt, ) require.NoError(t, err) @@ -607,7 +609,7 @@ func TestReconcile_ActiveJobFromDifferentReleaseStillBlocks(t *testing.T) { getter.jobs = []*oapi.Job{activeJob} getter.processingJobs = []*oapi.Job{activeJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty(t, setter.createdJobs, "an active job from a different release should still block") assert.Nil(t, result.NextReconcileAt) @@ -624,7 +626,7 @@ func TestReconcile_NoPolicyNoJobs_FirstAttemptAllowed(t *testing.T) { getter.jobs = []*oapi.Job{} getter.policies = []*oapi.Policy{} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.NotNil(t, result) require.Len(t, setter.createdJobs, 1, "first attempt should be allowed") @@ -656,6 +658,7 @@ func TestReconcile_NoPolicyOneCompletedJob_Denied(t *testing.T) { rt.WorkspaceID.String(), getter, setter, + nil, rt, ) require.NoError(t, err) @@ -681,7 +684,7 @@ func TestReconcile_PolicyMaxRetries3_NoFailures_Allowed(t *testing.T) { getter.policies = []*oapi.Policy{testPolicy(true, &oapi.RetryRule{MaxRetries: 3})} getter.jobs = []*oapi.Job{} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Nil(t, result.NextReconcileAt) @@ -703,7 +706,7 @@ func TestReconcile_PolicyMaxRetries3_AtLimit_Allowed(t *testing.T) { } getter.jobs = jobs - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len( t, @@ -730,7 +733,7 @@ func TestReconcile_PolicyMaxRetries3_Exceeded_Denied(t *testing.T) { } getter.jobs = jobs - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty(t, setter.createdJobs, "exceeding maxRetries should deny job creation") assert.Nil(t, result.NextReconcileAt) @@ -745,7 +748,7 @@ func TestReconcile_PolicyMaxRetries0_OneAttemptOnly(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Minute)) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty(t, setter.createdJobs, "maxRetries=0 should only allow one attempt") assert.Nil(t, result.NextReconcileAt) @@ -777,7 +780,7 @@ func TestReconcile_ExplicitRetryOnStatuses_OnlyCountsThose(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Minute)) getter.jobs = []*oapi.Job{cancelledJob, failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len( t, @@ -805,7 +808,7 @@ func TestReconcile_DefaultRetryOnStatuses_MaxRetriesGT0(t *testing.T) { ) getter.jobs = []*oapi.Job{successfulJob, failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len( t, @@ -829,7 +832,7 @@ func TestReconcile_DefaultRetryOnStatuses_MaxRetries0_SuccessfulCounts(t *testin ) getter.jobs = []*oapi.Job{successfulJob} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty( t, @@ -855,7 +858,7 @@ func TestReconcile_DifferentReleaseBreaksRetryChain(t *testing.T) { oldJob := testJobForRelease(oldRelease, oapi.JobStatusFailure, time.Now().Add(-30*time.Second)) getter.jobs = []*oapi.Job{oldJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len( t, @@ -886,7 +889,7 @@ func TestReconcile_LinearBackoff_WithinWindow_Denied(t *testing.T) { completedAt.Add(-time.Second), completedAt) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty(t, setter.createdJobs, "should not create job during backoff window") require.NotNil(t, result.NextReconcileAt, "should schedule requeue") @@ -913,7 +916,7 @@ func TestReconcile_LinearBackoff_PastWindow_Allowed(t *testing.T) { completedAt.Add(-time.Second), completedAt) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1, "should create job after backoff window") assert.Nil(t, result.NextReconcileAt) @@ -952,7 +955,7 @@ func TestReconcile_ExponentialBackoff_SecondAttempt(t *testing.T) { ) getter.jobs = []*oapi.Job{job2, job1} // newest first - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty(t, setter.createdJobs, "should be in exponential backoff (20s, only 4s elapsed)") require.NotNil(t, result.NextReconcileAt) @@ -986,7 +989,7 @@ func TestReconcile_ExponentialBackoff_WithMaxCap(t *testing.T) { jobs[0].CompletedAt = &recentCompleted getter.jobs = jobs - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty( t, @@ -1013,7 +1016,7 @@ func TestReconcile_DisabledPolicyIgnored(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Minute)) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty( t, @@ -1038,7 +1041,7 @@ func TestReconcile_FirstEnabledPolicyRetryRuleWins(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Minute)) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty( t, @@ -1063,7 +1066,7 @@ func TestReconcile_DisabledPolicySkipped_EnabledUsed(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Minute)) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len( t, @@ -1088,7 +1091,7 @@ func TestReconcile_PolicyWithNoRetryRule_SkippedToNextPolicy(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Minute)) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1, "should use second policy's retry rule") assert.Nil(t, result.NextReconcileAt) @@ -1103,7 +1106,7 @@ func TestReconcile_CreatedJobHasPendingStatus(t *testing.T) { release := testRelease(rt) getter, setter := setupHappyPath(rt, release) - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusPending, setter.createdJobs[0].Status) @@ -1114,7 +1117,7 @@ func TestReconcile_CreatedJobHasCorrectReleaseID(t *testing.T) { release := testRelease(rt) getter, setter := setupHappyPath(rt, release) - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, release.Id.String(), setter.createdJobs[0].ReleaseId) @@ -1125,7 +1128,7 @@ func TestReconcile_CreatedJobHasDispatchContext(t *testing.T) { release := testRelease(rt) getter, setter := setupHappyPath(rt, release) - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) @@ -1146,7 +1149,7 @@ func TestReconcile_CreatedJobHasValidUUID(t *testing.T) { release := testRelease(rt) getter, setter := setupHappyPath(rt, release) - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) @@ -1160,7 +1163,7 @@ func TestReconcile_NoJobAgentSelector_CreatesFailureJob(t *testing.T) { getter, setter := setupHappyPath(rt, release) getter.deployment.JobAgentSelector = "" - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusInvalidJobAgent, setter.createdJobs[0].Status) @@ -1173,7 +1176,7 @@ func TestReconcile_NilJobAgentSelector_CreatesFailureJob(t *testing.T) { getter, setter := setupHappyPath(rt, release) getter.deployment.JobAgentSelector = "" - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusInvalidJobAgent, setter.createdJobs[0].Status) @@ -1186,7 +1189,7 @@ func TestReconcile_NoMatchingAgents_CreatesFailureJob(t *testing.T) { getter, setter := setupHappyPath(rt, release) getter.workspaceAgents = []oapi.JobAgent{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusInvalidJobAgent, setter.createdJobs[0].Status) @@ -1234,7 +1237,7 @@ func TestReconcile_NoMatchingAgents_IncludesMissingKeyDiagnostic(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusInvalidJobAgent, setter.createdJobs[0].Status) @@ -1274,7 +1277,7 @@ func TestReconcile_MultipleJobAgents_CreatesMultipleJobs(t *testing.T) { } setter := &mockSetter{} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Nil(t, result.NextReconcileAt) require.Len(t, setter.createdJobs, 2, "should create one job per agent") @@ -1301,7 +1304,7 @@ func TestReconcile_GetDeploymentFails_Error(t *testing.T) { getter.deploymentErr = fmt.Errorf("deployment not found") getter.deployment = nil - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "get deployment") } @@ -1313,7 +1316,7 @@ func TestReconcile_GetEnvironmentFails_Error(t *testing.T) { getter.environmentErr = fmt.Errorf("env not found") getter.environment = nil - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "get environment") } @@ -1325,7 +1328,7 @@ func TestReconcile_GetResourceFails_Error(t *testing.T) { getter.resourceErr = fmt.Errorf("resource not found") getter.resource = nil - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "get resource") } @@ -1336,7 +1339,7 @@ func TestReconcile_CreateJobFails_Error(t *testing.T) { getter, setter := setupHappyPath(rt, release) setter.createJobErr = fmt.Errorf("create failed") - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "create job") assert.Empty(t, setter.enqueueCalls, "should not enqueue when job creation fails") @@ -1348,7 +1351,7 @@ func TestReconcile_EnqueueFails_Error(t *testing.T) { getter, setter := setupHappyPath(rt, release) setter.enqueueErr = fmt.Errorf("enqueue failed") - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "enqueue job dispatch") } @@ -1368,7 +1371,7 @@ func TestReconcile_JobsSortedByCreatedAt(t *testing.T) { newer := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-1*time.Minute)) getter.jobs = []*oapi.Job{old, newer} // intentionally out of order - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len( t, @@ -1396,7 +1399,7 @@ func TestReconcile_BackoffUsesCompletedAtWhenAvailable(t *testing.T) { failedJob := testJobWithCompletion(release, oapi.JobStatusFailure, createdAt, completedAt) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) assert.Empty(t, setter.createdJobs, "should use completedAt for backoff timing, not createdAt") require.NotNil(t, result.NextReconcileAt) @@ -1414,7 +1417,7 @@ func TestReconcile_NoBackoffWhenBackoffSecondsNil(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Second)) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1, "without backoff configured, retry should be immediate") assert.Nil(t, result.NextReconcileAt) @@ -1434,7 +1437,7 @@ func TestReconcile_NoBackoffWhenBackoffSecondsZero(t *testing.T) { failedJob := testJobForRelease(release, oapi.JobStatusFailure, time.Now().Add(-time.Second)) getter.jobs = []*oapi.Job{failedJob} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1, "backoffSeconds=0 should not delay retry") assert.Nil(t, result.NextReconcileAt) @@ -1460,7 +1463,7 @@ func TestReconcile_MixedJobStatuses_ConsecutiveCounting(t *testing.T) { j4 := testJobForRelease(release, oapi.JobStatusFailure, now.Add(-40*time.Second)) getter.jobs = []*oapi.Job{j1, j2, j3, j4} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len( t, @@ -1476,7 +1479,7 @@ func TestReconcile_EnqueueCalledWithCorrectWorkspaceID(t *testing.T) { release := testRelease(rt) getter, setter := setupHappyPath(rt, release) - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.enqueueCalls, 1) assert.Equal(t, rt.WorkspaceID.String(), setter.enqueueCalls[0].WorkspaceID) @@ -1489,7 +1492,7 @@ func TestReconcile_NoJobsNoPolices_FirstAttempt(t *testing.T) { getter.jobs = []*oapi.Job{} getter.policies = []*oapi.Policy{} - result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + result, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Nil(t, result.NextReconcileAt) @@ -1557,7 +1560,7 @@ func TestReconcile_JobAgentConfig_DeepMergesThreeLevels(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) @@ -1636,7 +1639,7 @@ func TestReconcile_SelectorMatchesSpecificAgent(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1, "should create exactly one job for the matched agent") assert.Equal(t, target.Id, setter.createdJobs[0].JobAgentId) @@ -1697,7 +1700,7 @@ func TestReconcile_SelectorByType_MatchesMultiple(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 2, "should create jobs for both argo-cd agents") @@ -1743,7 +1746,7 @@ func TestReconcile_SelectorFalse_NoAgentsMatched(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusInvalidJobAgent, setter.createdJobs[0].Status) @@ -1794,7 +1797,7 @@ func TestReconcile_SelectorByName_MatchesSingle(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, prod.Id, setter.createdJobs[0].JobAgentId) @@ -1856,7 +1859,7 @@ func TestReconcile_ResourceAwareSelector_MatchesAgentByResourceConfig(t *testing } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, argoUS.Id, setter.createdJobs[0].JobAgentId) @@ -1906,7 +1909,7 @@ func TestReconcile_ResourceAwareSelector_NoMatch(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusInvalidJobAgent, setter.createdJobs[0].Status) @@ -1963,7 +1966,7 @@ func TestReconcile_ResourceAwareSelector_MatchesByMetadata(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, agentProd.Id, setter.createdJobs[0].JobAgentId) @@ -2029,7 +2032,7 @@ func TestReconcile_ResourceAwareSelector_MultipleAgentsMatch(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 2) @@ -2097,7 +2100,7 @@ func TestReconcile_ResourceAwareSelector_MixedWithJobAgentFields(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, argoUS.Id, setter.createdJobs[0].JobAgentId) @@ -2143,7 +2146,7 @@ func TestReconcile_ResourceAwareSelector_MissingResourceConfigKey(t *testing.T) } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.NoError(t, err) require.Len(t, setter.createdJobs, 1) assert.Equal(t, oapi.JobStatusInvalidJobAgent, setter.createdJobs[0].Status) @@ -2174,7 +2177,7 @@ func TestReconcile_ResourceAwareSelector_GetResourceFails(t *testing.T) { } setter := &mockSetter{} - _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, rt) + _, err := Reconcile(context.Background(), rt.WorkspaceID.String(), getter, setter, nil, rt) require.Error(t, err) assert.Contains(t, err.Error(), "get resource") } diff --git a/apps/workspace-engine/test/controllers/harness/assertions.go b/apps/workspace-engine/test/controllers/harness/assertions.go index 24e9ab125..b61b77ec2 100644 --- a/apps/workspace-engine/test/controllers/harness/assertions.go +++ b/apps/workspace-engine/test/controllers/harness/assertions.go @@ -93,6 +93,16 @@ func (p *TestPipeline) ReleaseVariables(t *testing.T, idx int) map[string]oapi.L return p.ReleaseSetter.Releases[idx].Variables } +// AssertReleaseEncryptedVariables asserts the set of variable keys marked as +// originating from a secret_ref on the release at the given index. Order is +// not significant. +func (p *TestPipeline) AssertReleaseEncryptedVariables(t *testing.T, idx int, keys ...string) { + t.Helper() + require.Greater(t, len(p.ReleaseSetter.Releases), idx, + "release index %d out of range (have %d)", idx, len(p.ReleaseSetter.Releases)) + assert.ElementsMatch(t, keys, p.ReleaseSetter.Releases[idx].EncryptedVariables) +} + // --------------------------------------------------------------------------- // Job assertions // --------------------------------------------------------------------------- diff --git a/apps/workspace-engine/test/controllers/harness/mocks.go b/apps/workspace-engine/test/controllers/harness/mocks.go index c7d00b5f1..b63d81bf0 100644 --- a/apps/workspace-engine/test/controllers/harness/mocks.go +++ b/apps/workspace-engine/test/controllers/harness/mocks.go @@ -255,6 +255,13 @@ func (g *DesiredReleaseGetter) GetResourceVariables( return g.ResourceVars, nil } +func (g *DesiredReleaseGetter) GetJobAgentVariables( + _ context.Context, + _ uuid.UUID, +) ([]oapi.DeploymentVariableWithValues, error) { + return nil, nil +} + func (g *DesiredReleaseGetter) GetRelationshipRules( _ context.Context, _ uuid.UUID, diff --git a/apps/workspace-engine/test/controllers/harness/pipeline.go b/apps/workspace-engine/test/controllers/harness/pipeline.go index bd63e027d..4d723066d 100644 --- a/apps/workspace-engine/test/controllers/harness/pipeline.go +++ b/apps/workspace-engine/test/controllers/harness/pipeline.go @@ -10,6 +10,7 @@ import ( "workspace-engine/pkg/workspace/relationships/eval" selectoreval "workspace-engine/svc/controllers/deploymentresourceselectoreval" "workspace-engine/svc/controllers/desiredrelease" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" "workspace-engine/svc/controllers/jobdispatch" ) @@ -79,6 +80,8 @@ type ScenarioState struct { ResourceVars map[string][]oapi.ResourceVariable RelationshipRules []eval.Rule Candidates map[string][]eval.EntityData + + SecretResolver variableresolver.SecretResolver } type ResourceDef struct { @@ -150,7 +153,7 @@ func NewTestPipeline(t *testing.T, opts ...PipelineOption) *TestPipeline { releaseSetter.Agents = sc.JobAgents selectorCtrl := selectoreval.NewController(selectorGetter, selectorSetter, qs.shared) - releaseCtrl := desiredrelease.NewController(releaseGetter, releaseSetter) + releaseCtrl := desiredrelease.NewController(releaseGetter, releaseSetter, sc.SecretResolver) sel := "true" jobDispatchGetter := &JobDispatchGetter{ @@ -228,6 +231,16 @@ func (p *TestPipeline) ProcessDesiredReleases() int { return res.Processed } +// ProcessDesiredReleasesErr claims and processes all pending desired-release +// items and returns the processor error without failing the test. Use this +// in tests that assert a controller-level failure (e.g. secret resolution +// outage blocks the release). +func (p *TestPipeline) ProcessDesiredReleasesErr() error { + p.t.Helper() + _, err := DrainQueue(context.Background(), p.releaseQueue, p.releaseCtrl) + return err +} + // ProcessJobDispatches claims and processes all pending job-dispatch items. // Returns the count processed. func (p *TestPipeline) ProcessJobDispatches() int { diff --git a/apps/workspace-engine/test/controllers/harness/secrets.go b/apps/workspace-engine/test/controllers/harness/secrets.go new file mode 100644 index 000000000..828771628 --- /dev/null +++ b/apps/workspace-engine/test/controllers/harness/secrets.go @@ -0,0 +1,102 @@ +package harness + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/secrets" + "workspace-engine/svc/controllers/desiredrelease/variableresolver" +) + +// FakeSecretResolver satisfies variableresolver.SecretResolver with a +// canned in-memory map keyed by (provider, path, key). Use +// FailingSecretResolver to assert error propagation. +type FakeSecretResolver struct { + Entries map[string]string + Calls []secrets.SecretReference + WorkspaceIDs []uuid.UUID +} + +func fakeRefKey(provider, path, key string) string { + return provider + "|" + path + "|" + key +} + +// NewFakeSecretResolver constructs a resolver with no canned entries. Use +// Set to populate. +func NewFakeSecretResolver() *FakeSecretResolver { + return &FakeSecretResolver{Entries: make(map[string]string)} +} + +// Set seeds the resolver with a canned value for the given ref. +func (f *FakeSecretResolver) Set(provider, path, key, value string) { + f.Entries[fakeRefKey(provider, path, key)] = value +} + +// Resolve implements variableresolver.SecretResolver. +func (f *FakeSecretResolver) Resolve( + _ context.Context, + workspaceID uuid.UUID, + ref secrets.SecretReference, +) (string, error) { + f.Calls = append(f.Calls, ref) + f.WorkspaceIDs = append(f.WorkspaceIDs, workspaceID) + v, ok := f.Entries[fakeRefKey(ref.Provider, ref.Path, ref.Key)] + if !ok { + return "", fmt.Errorf( + "fake secret resolver: no entry for %s/%s/%s", + ref.Provider, + ref.Path, + ref.Key, + ) + } + return v, nil +} + +var _ variableresolver.SecretResolver = (*FakeSecretResolver)(nil) + +// FailingSecretResolver returns the same error for every Resolve call. Use it +// in tests asserting how the reconciler handles secret resolution failures. +type FailingSecretResolver struct { + Err error + Calls []secrets.SecretReference +} + +// Resolve implements variableresolver.SecretResolver. +func (f *FailingSecretResolver) Resolve( + _ context.Context, + _ uuid.UUID, + ref secrets.SecretReference, +) (string, error) { + f.Calls = append(f.Calls, ref) + return "", f.Err +} + +var _ variableresolver.SecretResolver = (*FailingSecretResolver)(nil) + +// WithSecretResolver injects a SecretResolver into the pipeline so the +// desired-release controller can resolve variable_value rows of kind +// secret_ref. The resolver is consumed by ResolveValue during +// variableresolver.Resolve. +func WithSecretResolver(r variableresolver.SecretResolver) PipelineOption { + return func(sc *ScenarioState) { + sc.SecretResolver = r + } +} + +// SecretRefValue builds an oapi.Value carrying a SecretReferenceValue. Path +// is optional; pass zero or more components. +func SecretRefValue(provider, key string, path ...string) oapi.Value { + srv := oapi.SecretReferenceValue{ + SecretProvider: provider, + SecretKey: key, + } + if len(path) > 0 { + p := append([]string(nil), path...) + srv.SecretPath = &p + } + v := oapi.Value{} + _ = v.FromSecretReferenceValue(srv) + return v +} diff --git a/apps/workspace-engine/test/controllers/secret_ref_test.go b/apps/workspace-engine/test/controllers/secret_ref_test.go new file mode 100644 index 000000000..034e50132 --- /dev/null +++ b/apps/workspace-engine/test/controllers/secret_ref_test.go @@ -0,0 +1,149 @@ +package controllers_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + . "workspace-engine/test/controllers/harness" +) + +// TestSecretRef_Resolved_FlowsThroughRelease covers the happy path: a +// variable_value of kind secret_ref → variableresolver.Resolve calls the +// injected SecretResolver → resolved plaintext lands on release.Variables +// and the variable key is appended to release.EncryptedVariables. +func TestSecretRef_Resolved_FlowsThroughRelease(t *testing.T) { + fake := NewFakeSecretResolver() + fake.Set("doppler-platform", "backend/production", "ARGOCD_TOKEN", "resolved-token-value") + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true")), + WithEnvironment(EnvironmentName("production")), + WithResource(ResourceName("srv"), ResourceKind("Server")), + WithVersion(VersionTag("v1.0.0")), + WithDeploymentVariable("argocd_token", + WithVariableValue(SecretRefValue( + "doppler-platform", + "ARGOCD_TOKEN", + "backend", "production", + )), + ), + WithSecretResolver(fake), + ) + p.Run() + + p.AssertReleaseCreated(t) + p.AssertReleaseVariableCount(t, 0, 1) + p.AssertReleaseVariableEquals(t, 0, "argocd_token", "resolved-token-value") + p.AssertReleaseEncryptedVariables(t, 0, "argocd_token") + + require.Len(t, fake.Calls, 1, "expected secret resolver to be called once") + assert.Equal(t, "doppler-platform", fake.Calls[0].Provider) + assert.Equal(t, "backend/production", fake.Calls[0].Path) + assert.Equal(t, "ARGOCD_TOKEN", fake.Calls[0].Key) +} + +// TestSecretRef_MixedWithLiteral verifies that EncryptedVariables only +// contains the secret_ref-originated keys, not literals. +func TestSecretRef_MixedWithLiteral(t *testing.T) { + fake := NewFakeSecretResolver() + fake.Set("aws-prod", "prod/db", "password", "hunter2") + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true")), + WithEnvironment(EnvironmentName("production")), + WithResource(ResourceName("srv"), ResourceKind("Server")), + WithVersion(VersionTag("v1.0.0")), + WithDeploymentVariable("image", DefaultValue("nginx:latest")), + WithDeploymentVariable("db_password", + WithVariableValue(SecretRefValue("aws-prod", "password", "prod/db")), + ), + WithSecretResolver(fake), + ) + p.Run() + + p.AssertReleaseCreated(t) + p.AssertReleaseVariableCount(t, 0, 2) + p.AssertReleaseVariableEquals(t, 0, "image", "nginx:latest") + p.AssertReleaseVariableEquals(t, 0, "db_password", "hunter2") + p.AssertReleaseEncryptedVariables(t, 0, "db_password") +} + +// TestSecretRef_NoPath covers providers whose reference does not carry a +// path component (e.g. env). The SecretReference passed to the resolver has +// an empty Path. +func TestSecretRef_NoPath(t *testing.T) { + fake := NewFakeSecretResolver() + fake.Set("env-defaults", "", "LICENSE_KEY", "abc-123") + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true")), + WithEnvironment(EnvironmentName("production")), + WithResource(ResourceName("srv"), ResourceKind("Server")), + WithVersion(VersionTag("v1.0.0")), + WithDeploymentVariable("license_key", + WithVariableValue(SecretRefValue("env-defaults", "LICENSE_KEY")), + ), + WithSecretResolver(fake), + ) + p.Run() + + p.AssertReleaseCreated(t) + p.AssertReleaseVariableEquals(t, 0, "license_key", "abc-123") + p.AssertReleaseEncryptedVariables(t, 0, "license_key") + + require.Len(t, fake.Calls, 1) + assert.Empty(t, fake.Calls[0].Path) +} + +// TestSecretRef_ResolverError_NoRelease covers the failure path: a +// provider outage (or any resolver error) propagates up and blocks the +// release. desiredrelease.Reconcile must not persist a release in that +// case — Phase 5 was explicit that re-resolve-each-dispatch means an +// outage is observable as a stuck reconcile, not a silent literal. +func TestSecretRef_ResolverError_NoRelease(t *testing.T) { + failing := &FailingSecretResolver{Err: errors.New("upstream 503")} + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true")), + WithEnvironment(EnvironmentName("production")), + WithResource(ResourceName("srv"), ResourceKind("Server")), + WithVersion(VersionTag("v1.0.0")), + WithDeploymentVariable("db_password", + WithVariableValue(SecretRefValue("doppler-prod", "DB_PASSWORD", "backend/prod")), + ), + WithSecretResolver(failing), + ) + p.EnqueueSelectorEval() + p.ProcessSelectorEvals() + err := p.ProcessDesiredReleasesErr() + + require.Error(t, err, "expected reconcile to propagate the upstream failure") + assert.Contains(t, err.Error(), "upstream 503") + p.AssertNoRelease(t) + require.Len(t, failing.Calls, 1, + "secret resolver must be called once before the failure surfaces") +} + +// TestSecretRef_NoResolverConfigured covers the case where a secret_ref is +// encountered but no resolver was wired (e.g. VARIABLES_AES_256_KEY unset +// on the workspace-engine). The release is blocked with a clear error. +func TestSecretRef_NoResolverConfigured(t *testing.T) { + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true")), + WithEnvironment(EnvironmentName("production")), + WithResource(ResourceName("srv"), ResourceKind("Server")), + WithVersion(VersionTag("v1.0.0")), + WithDeploymentVariable("db_password", + WithVariableValue(SecretRefValue("doppler-prod", "DB_PASSWORD", "backend/prod")), + ), + ) + p.EnqueueSelectorEval() + p.ProcessSelectorEvals() + err := p.ProcessDesiredReleasesErr() + + require.Error(t, err, "expected reconcile to fail with no SecretResolver wired") + assert.Contains(t, err.Error(), "no SecretResolver configured") + p.AssertNoRelease(t) +} diff --git a/e2e/api/schema.ts b/e2e/api/schema.ts index 6e0fc5abe..6bc1e5824 100644 --- a/e2e/api/schema.ts +++ b/e2e/api/schema.ts @@ -936,6 +936,56 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/secret-providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List secret providers + * @description Returns the metadata of every secret provider configured in the workspace. Encrypted configurations are never returned. + */ + get: operations["listSecretProviders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + /** Get a secret provider */ + get: operations["getSecretProvider"]; + /** + * Upsert a secret provider + * @description Creates or updates a secret provider. The config is encrypted at rest before persistence. + */ + put: operations["requestSecretProviderUpsert"]; + post?: never; + /** + * Delete a secret provider + * @description Variable values that reference this provider will fail to resolve until they are updated or the provider is recreated. + */ + delete: operations["requestSecretProviderDeletion"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/systems": { parameters: { query?: never; @@ -1185,6 +1235,14 @@ export interface components { }; /** @enum {string} */ ApprovalStatus: "approved" | "rejected"; + AwsSecretsManagerConfig: { + /** @description Optional static AWS access key id. Omit to use the workspace-engine instance role. */ + accessKeyId?: string; + /** @description AWS region. */ + region: string; + /** @description Optional static AWS secret access key. */ + secretAccessKey?: string; + }; BooleanValue: boolean; CreateDeploymentPlanRequest: { /** @description Arbitrary key-value metadata for the plan (e.g. GitHub PR links, CI run URLs) */ @@ -1555,6 +1613,14 @@ export interface components { workflowJob?: components["schemas"]["WorkflowJob"]; workflowRun?: components["schemas"]["WorkflowRun"]; }; + DopplerConfig: { + /** @description Doppler service token (dp.st.<...>). */ + serviceToken: string; + }; + EnvConfig: { + /** @description Explicit allowlist of environment variable names this provider may expose. */ + allowedKeys: string[]; + }; Environment: { /** Format: date-time */ createdAt: string; @@ -2040,6 +2106,30 @@ export interface components { /** @description Job statuses that count toward the retry limit. If null or empty, defaults to ["failure", "invalidIntegration", "invalidJobAgent"] for maxRetries > 0, or ["failure", "invalidIntegration", "invalidJobAgent", "successful"] for maxRetries = 0. Cancelled and skipped jobs never count by default (allows redeployment after cancellation). Example: ["failure", "cancelled"] will only count failed/cancelled jobs. */ retryOnStatuses?: components["schemas"]["JobStatus"][]; }; + /** @description Secret provider metadata. The encrypted configuration is never returned. */ + SecretProvider: { + /** Format: date-time */ + createdAt: string; + /** Format: uuid */ + id: string; + name: string; + type: components["schemas"]["SecretProviderType"]; + /** Format: date-time */ + updatedAt: string; + /** Format: uuid */ + workspaceId: string; + }; + /** @description Provider-specific configuration. Shape depends on the provider type. */ + SecretProviderConfig: components["schemas"]["AwsSecretsManagerConfig"] | components["schemas"]["DopplerConfig"] | components["schemas"]["EnvConfig"]; + SecretProviderRequestAccepted: { + id: string; + message: string; + }; + /** + * @description Type of secret provider. + * @enum {string} + */ + SecretProviderType: "aws_secrets_manager" | "doppler" | "env"; SensitiveValue: { valueHash: string; }; @@ -2267,6 +2357,12 @@ export interface components { }; version: string; }; + UpsertSecretProviderRequest: { + config: components["schemas"]["SecretProviderConfig"]; + /** @description Workspace-unique name used to reference the provider from variable values. */ + name: string; + type: components["schemas"]["SecretProviderType"]; + }; UpsertSystemRequest: { description?: string; metadata?: { @@ -5823,6 +5919,175 @@ export interface operations { }; }; }; + listSecretProviders: { + parameters: { + query?: { + /** @description Maximum number of items to return */ + limit?: number; + /** @description Number of items to skip */ + offset?: number; + }; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Paginated list of items */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["SecretProvider"][]; + /** @description Maximum number of items returned */ + limit: number; + /** @description Number of items skipped */ + offset: number; + /** @description Total number of items available */ + total: number; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getSecretProvider: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecretProvider"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + requestSecretProviderUpsert: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertSecretProviderRequest"]; + }; + }; + responses: { + /** @description Accepted response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecretProviderRequestAccepted"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + requestSecretProviderDeletion: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description ID of the secret provider */ + providerId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Secret provider deleted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SecretProviderRequestAccepted"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; listSystems: { parameters: { query?: { diff --git a/e2e/tests/api/secret-providers.spec.ts b/e2e/tests/api/secret-providers.spec.ts new file mode 100644 index 000000000..5be659af1 --- /dev/null +++ b/e2e/tests/api/secret-providers.spec.ts @@ -0,0 +1,190 @@ +import { faker } from "@faker-js/faker"; +import { expect } from "@playwright/test"; +import { v4 as uuidv4 } from "uuid"; + +import { test } from "../fixtures"; + +test.describe("Secret Provider API", () => { + test("upserts, retrieves, lists, and deletes a provider", async ({ + api, + workspace, + }) => { + const providerId = uuidv4(); + const name = `sp-${faker.string.alphanumeric(8)}`; + + const upsertRes = await api.PUT( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + body: { + name, + type: "doppler", + config: { serviceToken: "dp.st.testtoken1234567890" }, + }, + }, + ); + + try { + expect(upsertRes.response.status).toBe(202); + expect(upsertRes.data!.id).toBe(providerId); + + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + }, + ); + + expect(getRes.response.status).toBe(200); + expect(getRes.data!.id).toBe(providerId); + expect(getRes.data!.name).toBe(name); + expect(getRes.data!.type).toBe("doppler"); + expect(getRes.data!.workspaceId).toBe(workspace.id); + // Encrypted config must never be returned. + expect((getRes.data as Record).config).toBeUndefined(); + + const listRes = await api.GET( + "/v1/workspaces/{workspaceId}/secret-providers", + { + params: { path: { workspaceId: workspace.id } }, + }, + ); + + expect(listRes.response.status).toBe(200); + expect(listRes.data!.items.some((p) => p.id === providerId)).toBe(true); + expect( + listRes.data!.items.every( + (p) => (p as Record).config === undefined, + ), + ).toBe(true); + } finally { + await api.DELETE( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + }, + ); + } + }); + + test("rejects unknown providerId on get", async ({ api, workspace }) => { + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId: uuidv4() } }, + }, + ); + + expect(getRes.response.status).toBe(404); + }); + + test("rejects delete on unknown providerId", async ({ api, workspace }) => { + const deleteRes = await api.DELETE( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId: uuidv4() } }, + }, + ); + + expect(deleteRes.response.status).toBe(404); + }); + + test("accepts repeat upsert (idempotent on same id)", async ({ + api, + workspace, + }) => { + const providerId = uuidv4(); + const name = `sp-idem-${faker.string.alphanumeric(8)}`; + + const first = await api.PUT( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + body: { + name, + type: "aws_secrets_manager", + config: { region: "us-east-1" }, + }, + }, + ); + + try { + expect(first.response.status).toBe(202); + + const second = await api.PUT( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + body: { + name, + type: "aws_secrets_manager", + config: { region: "us-west-2" }, + }, + }, + ); + + expect(second.response.status).toBe(202); + + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + }, + ); + + expect(getRes.response.status).toBe(200); + expect(getRes.data!.name).toBe(name); + expect(getRes.data!.type).toBe("aws_secrets_manager"); + } finally { + await api.DELETE( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + }, + ); + } + }); + + test("rejects env provider with empty allowlist", async ({ + api, + workspace, + }) => { + const providerId = uuidv4(); + const upsertRes = await api.PUT( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + // Body fails OpenAPI minItems validation before reaching the handler. + body: { + name: `sp-bad-${faker.string.alphanumeric(8)}`, + type: "env", + // @ts-expect-error intentionally invalid for the test + config: { allowedKeys: [] }, + }, + }, + ); + + expect(upsertRes.response.status).toBe(400); + }); + + test("rejects doppler provider with malformed token", async ({ + api, + workspace, + }) => { + const providerId = uuidv4(); + const upsertRes = await api.PUT( + "/v1/workspaces/{workspaceId}/secret-providers/{providerId}", + { + params: { path: { workspaceId: workspace.id, providerId } }, + body: { + name: `sp-bad-${faker.string.alphanumeric(8)}`, + type: "doppler", + // Doesn't start with dp.st. - Zod discriminator rejects. + config: { serviceToken: "not-a-doppler-token" }, + }, + }, + ); + + expect(upsertRes.response.status).toBe(400); + }); +}); diff --git a/packages/db/drizzle/0196_nifty_stark_industries.sql b/packages/db/drizzle/0196_nifty_stark_industries.sql new file mode 100644 index 000000000..fb729e117 --- /dev/null +++ b/packages/db/drizzle/0196_nifty_stark_industries.sql @@ -0,0 +1,14 @@ +CREATE TYPE "public"."secret_provider_type" AS ENUM('aws_secrets_manager', 'doppler', 'env');--> statement-breakpoint +CREATE TABLE "secret_provider" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "name" text NOT NULL, + "type" "secret_provider_type" NOT NULL, + "config" "bytea" NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "variable_value" ADD COLUMN "secret_version" text;--> statement-breakpoint +ALTER TABLE "secret_provider" ADD CONSTRAINT "secret_provider_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "secret_provider_workspace_name_uniq" ON "secret_provider" USING btree ("workspace_id","name"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0196_snapshot.json b/packages/db/drizzle/meta/0196_snapshot.json new file mode 100644 index 000000000..4352eb0f1 --- /dev/null +++ b/packages/db/drizzle/meta/0196_snapshot.json @@ -0,0 +1,7141 @@ +{ + "id": "c5e22694-bed0-4626-bdbe-ae62620520de", + "prevId": "3eefe300-c0c8-409f-aec5-22f254b1ff59", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_session_token_unique": { + "name": "session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "active_workspace_id": { + "name": "active_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "system_role": { + "name": "system_role", + "type": "system_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_active_workspace_id_workspace_id_fk": { + "name": "user_active_workspace_id_workspace_id_fk", + "tableFrom": "user", + "tableTo": "workspace", + "columnsFrom": ["active_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_key": { + "name": "user_api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key_preview": { + "name": "key_preview", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_api_key_key_prefix_key_hash_index": { + "name": "user_api_key_key_prefix_key_hash_index", + "columns": [ + { + "expression": "key_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_key_user_id_user_id_fk": { + "name": "user_api_key_user_id_user_id_fk", + "tableFrom": "user_api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changelog_entry": { + "name": "changelog_entry", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_data": { + "name": "entity_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "changelog_entry_workspace_id_workspace_id_fk": { + "name": "changelog_entry_workspace_id_workspace_id_fk", + "tableFrom": "changelog_entry", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "changelog_entry_workspace_id_entity_type_entity_id_pk": { + "name": "changelog_entry_workspace_id_entity_type_entity_id_pk", + "columns": ["workspace_id", "entity_type", "entity_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard": { + "name": "dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_workspace_id_workspace_id_fk": { + "name": "dashboard_workspace_id_workspace_id_fk", + "tableFrom": "dashboard", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_widget": { + "name": "dashboard_widget", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "widget": { + "name": "widget", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "w": { + "name": "w", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "h": { + "name": "h", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_widget_dashboard_id_dashboard_id_fk": { + "name": "dashboard_widget_dashboard_id_dashboard_id_fk", + "tableFrom": "dashboard_widget", + "tableTo": "dashboard", + "columnsFrom": ["dashboard_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_plan": { + "name": "deployment_plan", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_tag": { + "name": "version_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_name": { + "name": "version_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_config": { + "name": "version_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "version_job_agent_config": { + "name": "version_job_agent_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "version_metadata": { + "name": "version_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deployment_plan_workspace_id_index": { + "name": "deployment_plan_workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_plan_deployment_id_index": { + "name": "deployment_plan_deployment_id_index", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_plan_expires_at_index": { + "name": "deployment_plan_expires_at_index", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_plan_workspace_id_workspace_id_fk": { + "name": "deployment_plan_workspace_id_workspace_id_fk", + "tableFrom": "deployment_plan", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_plan_deployment_id_deployment_id_fk": { + "name": "deployment_plan_deployment_id_deployment_id_fk", + "tableFrom": "deployment_plan", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_plan_target": { + "name": "deployment_plan_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_id": { + "name": "plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_release_id": { + "name": "current_release_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployment_plan_target_plan_id_index": { + "name": "deployment_plan_target_plan_id_index", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_plan_target_plan_id_environment_id_resource_id_index": { + "name": "deployment_plan_target_plan_id_environment_id_resource_id_index", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_plan_target_plan_id_deployment_plan_id_fk": { + "name": "deployment_plan_target_plan_id_deployment_plan_id_fk", + "tableFrom": "deployment_plan_target", + "tableTo": "deployment_plan", + "columnsFrom": ["plan_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_plan_target_environment_id_environment_id_fk": { + "name": "deployment_plan_target_environment_id_environment_id_fk", + "tableFrom": "deployment_plan_target", + "tableTo": "environment", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_plan_target_resource_id_resource_id_fk": { + "name": "deployment_plan_target_resource_id_resource_id_fk", + "tableFrom": "deployment_plan_target", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_plan_target_current_release_id_release_id_fk": { + "name": "deployment_plan_target_current_release_id_release_id_fk", + "tableFrom": "deployment_plan_target", + "tableTo": "release", + "columnsFrom": ["current_release_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_plan_target_result": { + "name": "deployment_plan_target_result", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dispatch_context": { + "name": "dispatch_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "deployment_plan_target_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'computing'" + }, + "has_changes": { + "name": "has_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current": { + "name": "current", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposed": { + "name": "proposed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployment_plan_target_result_target_id_index": { + "name": "deployment_plan_target_result_target_id_index", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_plan_target_result_target_id_deployment_plan_target_id_fk": { + "name": "deployment_plan_target_result_target_id_deployment_plan_target_id_fk", + "tableFrom": "deployment_plan_target_result", + "tableTo": "deployment_plan_target", + "columnsFrom": ["target_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_plan_target_result_validation": { + "name": "deployment_plan_target_result_validation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "result_id": { + "name": "result_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "violations": { + "name": "violations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "evaluated_at": { + "name": "evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_plan_target_result_validation_result_id_rule_id_index": { + "name": "deployment_plan_target_result_validation_result_id_rule_id_index", + "columns": [ + { + "expression": "result_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_plan_target_result_validation_result_id_deployment_plan_target_result_id_fk": { + "name": "deployment_plan_target_result_validation_result_id_deployment_plan_target_result_id_fk", + "tableFrom": "deployment_plan_target_result_validation", + "tableTo": "deployment_plan_target_result", + "columnsFrom": ["result_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_plan_target_variable": { + "name": "deployment_plan_target_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "deployment_plan_target_variable_target_id_key_index": { + "name": "deployment_plan_target_variable_target_id_key_index", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_plan_target_variable_target_id_deployment_plan_target_id_fk": { + "name": "deployment_plan_target_variable_target_id_deployment_plan_target_id_fk", + "tableFrom": "deployment_plan_target_variable", + "tableTo": "deployment_plan_target", + "columnsFrom": ["target_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_trace_span": { + "name": "deployment_trace_span", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_span_id": { + "name": "parent_span_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "release_target_key": { + "name": "release_target_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_trace_id": { + "name": "parent_trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "node_type": { + "name": "node_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_trace_span_trace_span_idx": { + "name": "deployment_trace_span_trace_span_idx", + "columns": [ + { + "expression": "trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "span_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_trace_id_idx": { + "name": "deployment_trace_span_trace_id_idx", + "columns": [ + { + "expression": "trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_parent_span_id_idx": { + "name": "deployment_trace_span_parent_span_id_idx", + "columns": [ + { + "expression": "parent_span_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_workspace_id_idx": { + "name": "deployment_trace_span_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_release_target_key_idx": { + "name": "deployment_trace_span_release_target_key_idx", + "columns": [ + { + "expression": "release_target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_release_id_idx": { + "name": "deployment_trace_span_release_id_idx", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_job_id_idx": { + "name": "deployment_trace_span_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_parent_trace_id_idx": { + "name": "deployment_trace_span_parent_trace_id_idx", + "columns": [ + { + "expression": "parent_trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_created_at_idx": { + "name": "deployment_trace_span_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_phase_idx": { + "name": "deployment_trace_span_phase_idx", + "columns": [ + { + "expression": "phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_node_type_idx": { + "name": "deployment_trace_span_node_type_idx", + "columns": [ + { + "expression": "node_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_trace_span_status_idx": { + "name": "deployment_trace_span_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_trace_span_workspace_id_workspace_id_fk": { + "name": "deployment_trace_span_workspace_id_workspace_id_fk", + "tableFrom": "deployment_trace_span", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_variable": { + "name": "deployment_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_value": { + "name": "default_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployment_variable_deployment_id_index": { + "name": "deployment_variable_deployment_id_index", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_variable_deployment_id_deployment_id_fk": { + "name": "deployment_variable_deployment_id_deployment_id_fk", + "tableFrom": "deployment_variable", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_variable_deployment_id_key_unique": { + "name": "deployment_variable_deployment_id_key_unique", + "nullsNotDistinct": false, + "columns": ["deployment_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_variable_value": { + "name": "deployment_variable_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_variable_id": { + "name": "deployment_variable_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "resource_selector": { + "name": "resource_selector", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "deployment_variable_value_deployment_variable_id_index": { + "name": "deployment_variable_value_deployment_variable_id_index", + "columns": [ + { + "expression": "deployment_variable_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_variable_value_deployment_variable_id_deployment_variable_id_fk": { + "name": "deployment_variable_value_deployment_variable_id_deployment_variable_id_fk", + "tableFrom": "deployment_variable_value", + "tableTo": "deployment_variable", + "columnsFrom": ["deployment_variable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_version": { + "name": "deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "job_agent_config": { + "name": "job_agent_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "deployment_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployment_version_deployment_id_tag_index": { + "name": "deployment_version_deployment_id_tag_index", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployment_version_created_at_idx": { + "name": "deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_version_workspace_id_workspace_id_fk": { + "name": "deployment_version_workspace_id_workspace_id_fk", + "tableFrom": "deployment_version", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_version_dependency": { + "name": "deployment_version_dependency", + "schema": "", + "columns": { + "deployment_version_id": { + "name": "deployment_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dependency_deployment_id": { + "name": "dependency_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_selector": { + "name": "version_selector", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'false'" + } + }, + "indexes": { + "deployment_version_dependency_target_idx": { + "name": "deployment_version_dependency_target_idx", + "columns": [ + { + "expression": "dependency_deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_version_dependency_deployment_version_id_deployment_version_id_fk": { + "name": "deployment_version_dependency_deployment_version_id_deployment_version_id_fk", + "tableFrom": "deployment_version_dependency", + "tableTo": "deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_version_dependency_dependency_deployment_id_deployment_id_fk": { + "name": "deployment_version_dependency_dependency_deployment_id_deployment_id_fk", + "tableFrom": "deployment_version_dependency", + "tableTo": "deployment", + "columnsFrom": ["dependency_deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deployment_version_dependency_deployment_version_id_dependency_deployment_id_pk": { + "name": "deployment_version_dependency_deployment_version_id_dependency_deployment_id_pk", + "columns": ["deployment_version_id", "dependency_deployment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computed_deployment_resource": { + "name": "computed_deployment_resource", + "schema": "", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "computed_deployment_resource_deployment_id_deployment_id_fk": { + "name": "computed_deployment_resource_deployment_id_deployment_id_fk", + "tableFrom": "computed_deployment_resource", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "computed_deployment_resource_resource_id_resource_id_fk": { + "name": "computed_deployment_resource_resource_id_resource_id_fk", + "tableFrom": "computed_deployment_resource", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "computed_deployment_resource_deployment_id_resource_id_pk": { + "name": "computed_deployment_resource_deployment_id_resource_id_pk", + "columns": ["deployment_id", "resource_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment": { + "name": "deployment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_selector": { + "name": "resource_selector", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'false'" + }, + "job_agent_selector": { + "name": "job_agent_selector", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'false'" + }, + "job_agent_config": { + "name": "job_agent_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_workspace_id_workspace_id_fk": { + "name": "deployment_workspace_id_workspace_id_fk", + "tableFrom": "deployment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_workspace_id_name_unique": { + "name": "deployment_workspace_id_name_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computed_environment_resource": { + "name": "computed_environment_resource", + "schema": "", + "columns": { + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "computed_environment_resource_environment_id_environment_id_fk": { + "name": "computed_environment_resource_environment_id_environment_id_fk", + "tableFrom": "computed_environment_resource", + "tableTo": "environment", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "computed_environment_resource_resource_id_resource_id_fk": { + "name": "computed_environment_resource_resource_id_resource_id_fk", + "tableFrom": "computed_environment_resource", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "computed_environment_resource_environment_id_resource_id_pk": { + "name": "computed_environment_resource_environment_id_resource_id_pk", + "columns": ["environment_id", "resource_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "resource_selector": { + "name": "resource_selector", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'false'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "environment_workspace_id_workspace_id_fk": { + "name": "environment_workspace_id_workspace_id_fk", + "tableFrom": "environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_workspace_id_name_unique": { + "name": "environment_workspace_id_name_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "event_workspace_id_workspace_id_fk": { + "name": "event_workspace_id_workspace_id_fk", + "tableFrom": "event", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource": { + "name": "resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resource_identifier_workspace_id_index": { + "name": "resource_identifier_workspace_id_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_workspace_id_active_idx": { + "name": "resource_workspace_id_active_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_workspace_id_deleted_at_index": { + "name": "resource_workspace_id_deleted_at_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_provider_id_resource_provider_id_fk": { + "name": "resource_provider_id_resource_provider_id_fk", + "tableFrom": "resource", + "tableTo": "resource_provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_workspace_id_workspace_id_fk": { + "name": "resource_workspace_id_workspace_id_fk", + "tableFrom": "resource", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_aggregate": { + "name": "resource_aggregate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "filter": { + "name": "filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'true'" + }, + "group_by": { + "name": "group_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resource_aggregate_workspace_id_index": { + "name": "resource_aggregate_workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_aggregate_workspace_id_workspace_id_fk": { + "name": "resource_aggregate_workspace_id_workspace_id_fk", + "tableFrom": "resource_aggregate", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "resource_aggregate_created_by_user_id_fk": { + "name": "resource_aggregate_created_by_user_id_fk", + "tableFrom": "resource_aggregate", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_schema": { + "name": "resource_schema", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "json_schema": { + "name": "json_schema", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "resource_schema_version_kind_workspace_id_index": { + "name": "resource_schema_version_kind_workspace_id_index", + "columns": [ + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_schema_workspace_id_workspace_id_fk": { + "name": "resource_schema_workspace_id_workspace_id_fk", + "tableFrom": "resource_schema", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_provider": { + "name": "resource_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "resource_provider_workspace_id_name_index": { + "name": "resource_provider_workspace_id_name_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_provider_workspace_id_workspace_id_fk": { + "name": "resource_provider_workspace_id_workspace_id_fk", + "tableFrom": "resource_provider", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system": { + "name": "system", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "system_workspace_id_index": { + "name": "system_workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_workspace_id_workspace_id_fk": { + "name": "system_workspace_id_workspace_id_fk", + "tableFrom": "system", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_deployment": { + "name": "system_deployment", + "schema": "", + "columns": { + "system_id": { + "name": "system_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_deployment_system_id_system_id_fk": { + "name": "system_deployment_system_id_system_id_fk", + "tableFrom": "system_deployment", + "tableTo": "system", + "columnsFrom": ["system_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_deployment_deployment_id_deployment_id_fk": { + "name": "system_deployment_deployment_id_deployment_id_fk", + "tableFrom": "system_deployment", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "system_deployment_system_id_deployment_id_pk": { + "name": "system_deployment_system_id_deployment_id_pk", + "columns": ["system_id", "deployment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_environment": { + "name": "system_environment", + "schema": "", + "columns": { + "system_id": { + "name": "system_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_environment_system_id_system_id_fk": { + "name": "system_environment_system_id_system_id_fk", + "tableFrom": "system_environment", + "tableTo": "system", + "columnsFrom": ["system_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "system_environment_environment_id_environment_id_fk": { + "name": "system_environment_environment_id_environment_id_fk", + "tableFrom": "system_environment", + "tableTo": "environment", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "system_environment_system_id_environment_id_pk": { + "name": "system_environment_system_id_environment_id_pk", + "columns": ["system_id", "environment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "team_workspace_id_workspace_id_fk": { + "name": "team_workspace_id_workspace_id_fk", + "tableFrom": "team", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "team_member_team_id_user_id_index": { + "name": "team_member_team_id_user_id_index", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_agent_id": { + "name": "job_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_agent_config": { + "name": "job_agent_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trace_token": { + "name": "trace_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatch_context": { + "name": "dispatch_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "job_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'policy_passing'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_created_at_idx": { + "name": "job_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_status_idx": { + "name": "job_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_external_id_idx": { + "name": "job_external_id_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_job_agent_id_job_agent_id_fk": { + "name": "job_job_agent_id_job_agent_id_fk", + "tableFrom": "job", + "tableTo": "job_agent", + "columnsFrom": ["job_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_metadata": { + "name": "job_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "job_metadata_key_job_id_index": { + "name": "job_metadata_key_job_id_index", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_metadata_job_id_idx": { + "name": "job_metadata_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_metadata_job_id_job_id_fk": { + "name": "job_metadata_job_id_job_id_fk", + "tableFrom": "job_metadata", + "tableTo": "job", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_variable": { + "name": "job_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "job_variable_job_id_key_index": { + "name": "job_variable_job_id_key_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_variable_job_id_job_id_fk": { + "name": "job_variable_job_id_job_id_fk", + "tableFrom": "job_variable", + "tableTo": "job", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_email_domain_matching": { + "name": "workspace_email_domain_matching", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verification_code": { + "name": "verification_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification_email": { + "name": "verification_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_email_domain_matching_workspace_id_domain_index": { + "name": "workspace_email_domain_matching_workspace_id_domain_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_email_domain_matching_workspace_id_workspace_id_fk": { + "name": "workspace_email_domain_matching_workspace_id_workspace_id_fk", + "tableFrom": "workspace_email_domain_matching", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_email_domain_matching_role_id_role_id_fk": { + "name": "workspace_email_domain_matching_role_id_role_id_fk", + "tableFrom": "workspace_email_domain_matching", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invite_token": { + "name": "workspace_invite_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invite_token_role_id_role_id_fk": { + "name": "workspace_invite_token_role_id_role_id_fk", + "tableFrom": "workspace_invite_token", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invite_token_workspace_id_workspace_id_fk": { + "name": "workspace_invite_token_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invite_token", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invite_token_created_by_user_id_fk": { + "name": "workspace_invite_token_created_by_user_id_fk", + "tableFrom": "workspace_invite_token", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invite_token_token_unique": { + "name": "workspace_invite_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entity_role": { + "name": "entity_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "scope_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entity_role_role_id_entity_type_entity_id_scope_id_scope_type_index": { + "name": "entity_role_role_id_entity_type_entity_id_scope_id_scope_type_index", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entity_role_role_id_role_id_fk": { + "name": "entity_role_role_id_role_id_fk", + "tableFrom": "entity_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "role_workspace_id_workspace_id_fk": { + "name": "role_workspace_id_workspace_id_fk", + "tableFrom": "role", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission": { + "name": "role_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "role_permission_role_id_permission_index": { + "name": "role_permission_role_id_permission_index", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_permission_role_id_role_id_fk": { + "name": "role_permission_role_id_role_id_fk", + "tableFrom": "role_permission", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release": { + "name": "release", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "release_resource_id_environment_id_deployment_id_index": { + "name": "release_resource_id_environment_id_deployment_id_index", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "release_deployment_id_index": { + "name": "release_deployment_id_index", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_resource_id_resource_id_fk": { + "name": "release_resource_id_resource_id_fk", + "tableFrom": "release", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_environment_id_environment_id_fk": { + "name": "release_environment_id_environment_id_fk", + "tableFrom": "release", + "tableTo": "environment", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_deployment_id_deployment_id_fk": { + "name": "release_deployment_id_deployment_id_fk", + "tableFrom": "release", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_version_id_deployment_version_id_fk": { + "name": "release_version_id_deployment_version_id_fk", + "tableFrom": "release", + "tableTo": "deployment_version", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_job": { + "name": "release_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "release_id": { + "name": "release_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "release_job_release_id_job_id_index": { + "name": "release_job_release_id_job_id_index", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "release_job_job_id_index": { + "name": "release_job_job_id_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "release_job_release_id_index": { + "name": "release_job_release_id_index", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_job_job_id_job_id_fk": { + "name": "release_job_job_id_job_id_fk", + "tableFrom": "release_job", + "tableTo": "job", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_job_release_id_release_id_fk": { + "name": "release_job_release_id_release_id_fk", + "tableFrom": "release_job", + "tableTo": "release", + "columnsFrom": ["release_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_target_desired_release": { + "name": "release_target_desired_release", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "desired_release_id": { + "name": "desired_release_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "release_target_desired_release_resource_id_environment_id_deployment_id_index": { + "name": "release_target_desired_release_resource_id_environment_id_deployment_id_index", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_target_desired_release_resource_id_resource_id_fk": { + "name": "release_target_desired_release_resource_id_resource_id_fk", + "tableFrom": "release_target_desired_release", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_target_desired_release_environment_id_environment_id_fk": { + "name": "release_target_desired_release_environment_id_environment_id_fk", + "tableFrom": "release_target_desired_release", + "tableTo": "environment", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_target_desired_release_deployment_id_deployment_id_fk": { + "name": "release_target_desired_release_deployment_id_deployment_id_fk", + "tableFrom": "release_target_desired_release", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "release_target_desired_release_desired_release_id_release_id_fk": { + "name": "release_target_desired_release_desired_release_id_release_id_fk", + "tableFrom": "release_target_desired_release", + "tableTo": "release", + "columnsFrom": ["desired_release_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_variable": { + "name": "release_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "release_id": { + "name": "release_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "release_variable_release_id_key_index": { + "name": "release_variable_release_id_key_index", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "release_variable_release_id_release_id_fk": { + "name": "release_variable_release_id_release_id_fk", + "tableFrom": "release_variable", + "tableTo": "release", + "columnsFrom": ["release_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reconcile_work_scope": { + "name": "reconcile_work_scope", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "reconcile_work_scope_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "event_ts": { + "name": "event_ts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "priority": { + "name": "priority", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "not_before": { + "name": "not_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_until": { + "name": "claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reconcile_work_scope_workspace_id_kind_scope_type_scope_id_index": { + "name": "reconcile_work_scope_workspace_id_kind_scope_type_scope_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reconcile_work_scope_unclaimed_idx": { + "name": "reconcile_work_scope_unclaimed_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"reconcile_work_scope\".\"claimed_until\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "reconcile_work_scope_expired_claims_idx": { + "name": "reconcile_work_scope_expired_claims_idx", + "columns": [ + { + "expression": "claimed_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"reconcile_work_scope\".\"claimed_until\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy": { + "name": "policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selector": { + "name": "selector", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'true'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "policy_workspace_id_index": { + "name": "policy_workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "policy_workspace_id_workspace_id_fk": { + "name": "policy_workspace_id_workspace_id_fk", + "tableFrom": "policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_any_approval": { + "name": "policy_rule_any_approval", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "min_approvals": { + "name": "min_approvals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_any_approval_policy_id_policy_id_fk": { + "name": "policy_rule_any_approval_policy_id_policy_id_fk", + "tableFrom": "policy_rule_any_approval", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_deployment_dependency": { + "name": "policy_rule_deployment_dependency", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "depends_on": { + "name": "depends_on", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_deployment_dependency_policy_id_policy_id_fk": { + "name": "policy_rule_deployment_dependency_policy_id_policy_id_fk", + "tableFrom": "policy_rule_deployment_dependency", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_deployment_window": { + "name": "policy_rule_deployment_window", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allow_window": { + "name": "allow_window", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_deployment_window_policy_id_policy_id_fk": { + "name": "policy_rule_deployment_window_policy_id_policy_id_fk", + "tableFrom": "policy_rule_deployment_window", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_environment_progression": { + "name": "policy_rule_environment_progression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "depends_on_environment_selector": { + "name": "depends_on_environment_selector", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "maximum_age_hours": { + "name": "maximum_age_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "minimum_soak_time_minutes": { + "name": "minimum_soak_time_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "minimum_success_percentage": { + "name": "minimum_success_percentage", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "success_statuses": { + "name": "success_statuses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_verification_passed": { + "name": "require_verification_passed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_environment_progression_policy_id_policy_id_fk": { + "name": "policy_rule_environment_progression_policy_id_policy_id_fk", + "tableFrom": "policy_rule_environment_progression", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_gradual_rollout": { + "name": "policy_rule_gradual_rollout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rollout_type": { + "name": "rollout_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "time_scale_interval": { + "name": "time_scale_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_gradual_rollout_policy_id_policy_id_fk": { + "name": "policy_rule_gradual_rollout_policy_id_policy_id_fk", + "tableFrom": "policy_rule_gradual_rollout", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_plan_validation_opa": { + "name": "policy_rule_plan_validation_opa", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rego": { + "name": "rego", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "policy_rule_plan_validation_opa_policy_id_index": { + "name": "policy_rule_plan_validation_opa_policy_id_index", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "policy_rule_plan_validation_opa_policy_id_policy_id_fk": { + "name": "policy_rule_plan_validation_opa_policy_id_policy_id_fk", + "tableFrom": "policy_rule_plan_validation_opa", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_retry": { + "name": "policy_rule_retry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "backoff_seconds": { + "name": "backoff_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "backoff_strategy": { + "name": "backoff_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_backoff_seconds": { + "name": "max_backoff_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "retry_on_statuses": { + "name": "retry_on_statuses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_retry_policy_id_policy_id_fk": { + "name": "policy_rule_retry_policy_id_policy_id_fk", + "tableFrom": "policy_rule_retry", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_rollback": { + "name": "policy_rule_rollback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "on_job_statuses": { + "name": "on_job_statuses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "on_verification_failure": { + "name": "on_verification_failure", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_rollback_policy_id_policy_id_fk": { + "name": "policy_rule_rollback_policy_id_policy_id_fk", + "tableFrom": "policy_rule_rollback", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_verification": { + "name": "policy_rule_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "trigger_on": { + "name": "trigger_on", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_verification_policy_id_policy_id_fk": { + "name": "policy_rule_verification_policy_id_policy_id_fk", + "tableFrom": "policy_rule_verification", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_version_cooldown": { + "name": "policy_rule_version_cooldown", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_version_cooldown_policy_id_policy_id_fk": { + "name": "policy_rule_version_cooldown_policy_id_policy_id_fk", + "tableFrom": "policy_rule_version_cooldown", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_version_selector": { + "name": "policy_rule_version_selector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selector": { + "name": "selector", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_version_selector_policy_id_policy_id_fk": { + "name": "policy_rule_version_selector_policy_id_policy_id_fk", + "tableFrom": "policy_rule_version_selector", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_approval_record": { + "name": "user_approval_record", + "schema": "", + "columns": { + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_approval_record_version_id_user_id_environment_id_pk": { + "name": "user_approval_record_version_id_user_id_environment_id_pk", + "columns": ["version_id", "user_id", "environment_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_variable": { + "name": "resource_variable", + "schema": "", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "resource_variable_resource_id_resource_id_fk": { + "name": "resource_variable_resource_id_resource_id_fk", + "tableFrom": "resource_variable", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "resource_variable_resource_id_key_pk": { + "name": "resource_variable_resource_id_key_pk", + "columns": ["resource_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "job_agents": { + "name": "job_agents", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_workspace_id_slug_unique": { + "name": "workflow_workspace_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_job": { + "name": "workflow_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_job_workflow_run_id_workflow_run_id_fk": { + "name": "workflow_job_workflow_run_id_workflow_run_id_fk", + "tableFrom": "workflow_job", + "tableTo": "workflow_run", + "columnsFrom": ["workflow_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_job_job_id_job_id_fk": { + "name": "workflow_job_job_id_job_id_fk", + "tableFrom": "workflow_job", + "tableTo": "job", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run": { + "name": "workflow_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workflow_id": { + "name": "workflow_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_run_workflow_id_workflow_id_fk": { + "name": "workflow_run_workflow_id_workflow_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_skip": { + "name": "policy_skip", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computed_policy_release_target": { + "name": "computed_policy_release_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computed_policy_release_target_policy_id_environment_id_deployment_id_resource_id_index": { + "name": "computed_policy_release_target_policy_id_environment_id_deployment_id_resource_id_index", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "computed_policy_release_target_policy_id_index": { + "name": "computed_policy_release_target_policy_id_index", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "computed_policy_release_target_resource_id_environment_id_deployment_id_index": { + "name": "computed_policy_release_target_resource_id_environment_id_deployment_id_index", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "computed_policy_release_target_policy_id_policy_id_fk": { + "name": "computed_policy_release_target_policy_id_policy_id_fk", + "tableFrom": "computed_policy_release_target", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "computed_policy_release_target_environment_id_environment_id_fk": { + "name": "computed_policy_release_target_environment_id_environment_id_fk", + "tableFrom": "computed_policy_release_target", + "tableTo": "environment", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "computed_policy_release_target_deployment_id_deployment_id_fk": { + "name": "computed_policy_release_target_deployment_id_deployment_id_fk", + "tableFrom": "computed_policy_release_target", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "computed_policy_release_target_resource_id_resource_id_fk": { + "name": "computed_policy_release_target_resource_id_resource_id_fk", + "tableFrom": "computed_policy_release_target", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_evaluation": { + "name": "policy_rule_evaluation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "rule_type": { + "name": "rule_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "action_required": { + "name": "action_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "satisfied_at": { + "name": "satisfied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_evaluation_at": { + "name": "next_evaluation_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evaluated_at": { + "name": "evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "policy_rule_evaluation_rule_id_environment_id_version_id_resource_id_index": { + "name": "policy_rule_evaluation_rule_id_environment_id_version_id_resource_id_index", + "columns": [ + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "policy_rule_evaluation_environment_id_version_id_resource_id_rule_type_index": { + "name": "policy_rule_evaluation_environment_id_version_id_resource_id_rule_type_index", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "policy_rule_evaluation_environment_id_environment_id_fk": { + "name": "policy_rule_evaluation_environment_id_environment_id_fk", + "tableFrom": "policy_rule_evaluation", + "tableTo": "environment", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "policy_rule_evaluation_version_id_deployment_version_id_fk": { + "name": "policy_rule_evaluation_version_id_deployment_version_id_fk", + "tableFrom": "policy_rule_evaluation", + "tableTo": "deployment_version", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "policy_rule_evaluation_resource_id_resource_id_fk": { + "name": "policy_rule_evaluation_resource_id_resource_id_fk", + "tableFrom": "policy_rule_evaluation", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_verification_metric_measurement": { + "name": "job_verification_metric_measurement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_verification_metric_status_id": { + "name": "job_verification_metric_status_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "measured_at": { + "name": "measured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "job_verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "job_verification_metric_measurement_job_verification_metric_status_id_index": { + "name": "job_verification_metric_measurement_job_verification_metric_status_id_index", + "columns": [ + { + "expression": "job_verification_metric_status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_verification_metric_measurement_job_verification_metric_status_id_job_verification_metric_id_fk": { + "name": "job_verification_metric_measurement_job_verification_metric_status_id_job_verification_metric_id_fk", + "tableFrom": "job_verification_metric_measurement", + "tableTo": "job_verification_metric", + "columnsFrom": ["job_verification_metric_status_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_verification_metric": { + "name": "job_verification_metric", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_rule_verification_metric_id": { + "name": "policy_rule_verification_metric_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_threshold": { + "name": "success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "failure_condition": { + "name": "failure_condition", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'false'" + }, + "failure_threshold": { + "name": "failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": { + "job_verification_metric_job_id_index": { + "name": "job_verification_metric_job_id_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_verification_metric_policy_rule_verification_metric_id_index": { + "name": "job_verification_metric_policy_rule_verification_metric_id_index", + "columns": [ + { + "expression": "policy_rule_verification_metric_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_verification_metric_policy_rule_verification_metric_id_policy_rule_job_verification_metric_id_fk": { + "name": "job_verification_metric_policy_rule_verification_metric_id_policy_rule_job_verification_metric_id_fk", + "tableFrom": "job_verification_metric", + "tableTo": "policy_rule_job_verification_metric", + "columnsFrom": ["policy_rule_verification_metric_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_rule_job_verification_metric": { + "name": "policy_rule_job_verification_metric", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger_on": { + "name": "trigger_on", + "type": "job_verification_trigger_on", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'jobSuccess'" + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_threshold": { + "name": "success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "failure_condition": { + "name": "failure_condition", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'false'" + }, + "failure_threshold": { + "name": "failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "policy_rule_job_verification_metric_policy_id_policy_id_fk": { + "name": "policy_rule_job_verification_metric_policy_id_policy_id_fk", + "tableFrom": "policy_rule_job_verification_metric", + "tableTo": "policy", + "columnsFrom": ["policy_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computed_entity_relationship": { + "name": "computed_entity_relationship", + "schema": "", + "columns": { + "rule_id": { + "name": "rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_entity_type": { + "name": "from_entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to_entity_type": { + "name": "to_entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_entity_id": { + "name": "to_entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computed_entity_relationship_from_idx": { + "name": "computed_entity_relationship_from_idx", + "columns": [ + { + "expression": "from_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "computed_entity_relationship_to_idx": { + "name": "computed_entity_relationship_to_idx", + "columns": [ + { + "expression": "to_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "computed_entity_relationship_rule_id_relationship_rule_id_fk": { + "name": "computed_entity_relationship_rule_id_relationship_rule_id_fk", + "tableFrom": "computed_entity_relationship", + "tableTo": "relationship_rule", + "columnsFrom": ["rule_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "computed_entity_relationship_rule_id_from_entity_type_from_entity_id_to_entity_type_to_entity_id_pk": { + "name": "computed_entity_relationship_rule_id_from_entity_type_from_entity_id_to_entity_type_to_entity_id_pk", + "columns": [ + "rule_id", + "from_entity_type", + "from_entity_id", + "to_entity_type", + "to_entity_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.relationship_rule": { + "name": "relationship_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cel": { + "name": "cel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "relationship_rule_workspace_id_reference_index": { + "name": "relationship_rule_workspace_id_reference_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "relationship_rule_workspace_id_index": { + "name": "relationship_rule_workspace_id_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "relationship_rule_workspace_id_workspace_id_fk": { + "name": "relationship_rule_workspace_id_workspace_id_fk", + "tableFrom": "relationship_rule", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_agent": { + "name": "job_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "job_agent_workspace_id_name_index": { + "name": "job_agent_workspace_id_name_index", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_agent_workspace_id_workspace_id_fk": { + "name": "job_agent_workspace_id_workspace_id_fk", + "tableFrom": "job_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.variable_set": { + "name": "variable_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "selector": { + "name": "selector", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "variable_set_workspace_id_workspace_id_fk": { + "name": "variable_set_workspace_id_workspace_id_fk", + "tableFrom": "variable_set", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.variable_set_variable": { + "name": "variable_set_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "variable_set_id": { + "name": "variable_set_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "variable_set_variable_variable_set_id_variable_set_id_fk": { + "name": "variable_set_variable_variable_set_id_variable_set_id_fk", + "tableFrom": "variable_set_variable", + "tableTo": "variable_set", + "columnsFrom": ["variable_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "variable_set_variable_variable_set_id_key_unique": { + "name": "variable_set_variable_variable_set_id_key_unique", + "nullsNotDistinct": false, + "columns": ["variable_set_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.variable": { + "name": "variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scope": { + "name": "scope", + "type": "variable_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_agent_id": { + "name": "job_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "variable_resource_key_uniq": { + "name": "variable_resource_key_uniq", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"variable\".\"resource_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "variable_deployment_key_uniq": { + "name": "variable_deployment_key_uniq", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"variable\".\"deployment_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "variable_job_agent_key_uniq": { + "name": "variable_job_agent_key_uniq", + "columns": [ + { + "expression": "job_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"variable\".\"job_agent_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "variable_scope_idx": { + "name": "variable_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "variable_resource_id_resource_id_fk": { + "name": "variable_resource_id_resource_id_fk", + "tableFrom": "variable", + "tableTo": "resource", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "variable_deployment_id_deployment_id_fk": { + "name": "variable_deployment_id_deployment_id_fk", + "tableFrom": "variable", + "tableTo": "deployment", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "variable_job_agent_id_job_agent_id_fk": { + "name": "variable_job_agent_id_job_agent_id_fk", + "tableFrom": "variable", + "tableTo": "job_agent", + "columnsFrom": ["job_agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "variable_scope_target_check": { + "name": "variable_scope_target_check", + "value": "\n (\n \"variable\".\"scope\" = 'resource'\n and \"variable\".\"resource_id\" is not null\n and \"variable\".\"deployment_id\" is null\n and \"variable\".\"job_agent_id\" is null\n )\n or\n (\n \"variable\".\"scope\" = 'deployment'\n and \"variable\".\"deployment_id\" is not null\n and \"variable\".\"resource_id\" is null\n and \"variable\".\"job_agent_id\" is null\n )\n or\n (\n \"variable\".\"scope\" = 'job_agent'\n and \"variable\".\"job_agent_id\" is not null\n and \"variable\".\"resource_id\" is null\n and \"variable\".\"deployment_id\" is null\n )\n " + } + }, + "isRLSEnabled": false + }, + "public.variable_value": { + "name": "variable_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "variable_id": { + "name": "variable_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_selector": { + "name": "resource_selector", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "kind": { + "name": "kind", + "type": "variable_value_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "literal_value": { + "name": "literal_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ref_key": { + "name": "ref_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_path": { + "name": "ref_path", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "secret_provider": { + "name": "secret_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_key": { + "name": "secret_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_path": { + "name": "secret_path", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "secret_version": { + "name": "secret_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "variable_value_variable_priority_idx": { + "name": "variable_value_variable_priority_idx", + "columns": [ + { + "expression": "variable_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "variable_value_kind_idx": { + "name": "variable_value_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "variable_value_resolution_uniq": { + "name": "variable_value_resolution_uniq", + "columns": [ + { + "expression": "variable_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"resource_selector\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "variable_value_variable_id_variable_id_fk": { + "name": "variable_value_variable_id_variable_id_fk", + "tableFrom": "variable_value", + "tableTo": "variable", + "columnsFrom": ["variable_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "variable_value_kind_shape_check": { + "name": "variable_value_kind_shape_check", + "value": "\n (\n \"variable_value\".\"kind\" = 'literal'\n and \"variable_value\".\"literal_value\" is not null\n and \"variable_value\".\"ref_key\" is null\n and \"variable_value\".\"ref_path\" is null\n and \"variable_value\".\"secret_provider\" is null\n and \"variable_value\".\"secret_key\" is null\n and \"variable_value\".\"secret_path\" is null\n )\n or\n (\n \"variable_value\".\"kind\" = 'ref'\n and \"variable_value\".\"literal_value\" is null\n and \"variable_value\".\"ref_key\" is not null\n and \"variable_value\".\"secret_provider\" is null\n and \"variable_value\".\"secret_key\" is null\n and \"variable_value\".\"secret_path\" is null\n )\n or\n (\n \"variable_value\".\"kind\" = 'secret_ref'\n and \"variable_value\".\"literal_value\" is null\n and \"variable_value\".\"ref_key\" is null\n and \"variable_value\".\"ref_path\" is null\n and \"variable_value\".\"secret_provider\" is not null\n and \"variable_value\".\"secret_key\" is not null\n )\n " + } + }, + "isRLSEnabled": false + }, + "public.secret_provider": { + "name": "secret_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "secret_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_provider_workspace_name_uniq": { + "name": "secret_provider_workspace_name_uniq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_provider_workspace_id_workspace_id_fk": { + "name": "secret_provider_workspace_id_workspace_id_fk", + "tableFrom": "secret_provider", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.system_role": { + "name": "system_role", + "schema": "public", + "values": ["user", "admin"] + }, + "public.deployment_plan_target_status": { + "name": "deployment_plan_target_status", + "schema": "public", + "values": ["computing", "completed", "errored", "unsupported"] + }, + "public.deployment_version_status": { + "name": "deployment_version_status", + "schema": "public", + "values": [ + "unspecified", + "building", + "ready", + "failed", + "rejected", + "paused" + ] + }, + "public.job_reason": { + "name": "job_reason", + "schema": "public", + "values": [ + "policy_passing", + "policy_override", + "env_policy_override", + "config_policy_override", + "redeploy" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "cancelled", + "skipped", + "in_progress", + "action_required", + "pending", + "failure", + "invalid_job_agent", + "invalid_integration", + "external_run_not_found", + "successful" + ] + }, + "public.entity_type": { + "name": "entity_type", + "schema": "public", + "values": ["user", "team"] + }, + "public.scope_type": { + "name": "scope_type", + "schema": "public", + "values": [ + "deploymentVersion", + "resource", + "resourceProvider", + "workspace", + "environment", + "system", + "deployment" + ] + }, + "public.job_verification_status": { + "name": "job_verification_status", + "schema": "public", + "values": ["failed", "inconclusive", "passed"] + }, + "public.job_verification_trigger_on": { + "name": "job_verification_trigger_on", + "schema": "public", + "values": ["jobCreated", "jobStarted", "jobSuccess", "jobFailure"] + }, + "public.variable_scope": { + "name": "variable_scope", + "schema": "public", + "values": ["resource", "deployment", "job_agent"] + }, + "public.variable_value_kind": { + "name": "variable_value_kind", + "schema": "public", + "values": ["literal", "ref", "secret_ref"] + }, + "public.secret_provider_type": { + "name": "secret_provider_type", + "schema": "public", + "values": ["aws_secrets_manager", "doppler", "env"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 10efec842..81eb86bae 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1373,6 +1373,13 @@ "when": 1778768912269, "tag": "0195_left_silverclaw", "breakpoints": true + }, + { + "idx": 196, + "version": "7", + "when": 1778870463416, + "tag": "0196_nifty_stark_industries", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3bd0d2481..b32dce637 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -30,3 +30,4 @@ export * from "./relationships.js"; export * from "./job-agent.js"; export * from "./variable-set.js"; export * from "./variable.js"; +export * from "./secret-provider.js"; diff --git a/packages/db/src/schema/secret-provider.ts b/packages/db/src/schema/secret-provider.ts new file mode 100644 index 000000000..a24b28187 --- /dev/null +++ b/packages/db/src/schema/secret-provider.ts @@ -0,0 +1,56 @@ +import type { InferSelectModel } from "drizzle-orm"; +import { + customType, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +import { workspace } from "./workspace.js"; + +export const secretProviderTypeEnum = pgEnum("secret_provider_type", [ + "aws_secrets_manager", + "doppler", + "env", +]); + +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType: () => "bytea", +}); + +export const secretProvider = pgTable( + "secret_provider", + { + id: uuid("id").defaultRandom().primaryKey(), + + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspace.id, { onDelete: "cascade" }), + + name: text("name").notNull(), + + type: secretProviderTypeEnum("type").notNull(), + + config: bytea("config").notNull(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + uniqueIndex("secret_provider_workspace_name_uniq").on( + table.workspaceId, + table.name, + ), + ], +); + +export type SecretProvider = InferSelectModel; diff --git a/packages/db/src/schema/variable.ts b/packages/db/src/schema/variable.ts index f1db98736..194f73bad 100644 --- a/packages/db/src/schema/variable.ts +++ b/packages/db/src/schema/variable.ts @@ -128,6 +128,7 @@ export const variableValue = pgTable( secretProvider: text("secret_provider"), secretKey: text("secret_key"), secretPath: text("secret_path").array(), + secretVersion: text("secret_version"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daaceb03b..c7d153736 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: '@ctrlplane/logger': specifier: workspace:* version: link:../../packages/logger + '@ctrlplane/secrets': + specifier: workspace:* + version: link:../../packages/secrets '@ctrlplane/trpc': specifier: workspace:* version: link:../../packages/trpc @@ -206,7 +209,7 @@ importers: version: 2.4.3 better-auth: specifier: ^1.4.6 - version: 1.4.6(next@15.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) cel-js: specifier: ^0.8.2 version: 0.8.2 @@ -670,7 +673,7 @@ importers: version: 0.11.1(typescript@5.9.3)(zod@3.24.2) better-auth: specifier: ^1.4.6 - version: 1.4.6(next@15.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) lodash: specifier: 'catalog:' version: 4.17.21 @@ -957,7 +960,7 @@ importers: version: 11.0.0-rc.364 better-auth: specifier: ^1.4.6 - version: 1.4.6(next@15.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) cel-js: specifier: ^0.8.2 version: 0.8.2 @@ -5201,7 +5204,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.1, please upgrade + deprecated: Security vulnerability fixed in 5.2.0, please upgrade bcryptjs@2.4.3: resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} @@ -13628,6 +13631,26 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + better-auth@1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.24.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) + '@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@3.24.2))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@noble/ciphers': 2.0.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.5(zod@4.1.12) + defu: 6.1.4 + jose: 6.1.0 + kysely: 0.28.8 + ms: 4.0.0-nightly.202508271359 + nanostores: 1.0.1 + zod: 4.1.12 + optionalDependencies: + next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + better-call@1.1.5(zod@4.1.12): dependencies: '@better-auth/utils': 0.3.0 @@ -16377,6 +16400,34 @@ snapshots: - babel-plugin-macros optional: true + next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + '@next/env': 15.2.4 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001760 + postcss: 8.4.31 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + styled-jsx: 5.1.6(@babel/core@7.24.5)(react@19.2.1) + optionalDependencies: + '@next/swc-darwin-arm64': 15.2.4 + '@next/swc-darwin-x64': 15.2.4 + '@next/swc-linux-arm64-gnu': 15.2.4 + '@next/swc-linux-arm64-musl': 15.2.4 + '@next/swc-linux-x64-gnu': 15.2.4 + '@next/swc-linux-x64-musl': 15.2.4 + '@next/swc-win32-arm64-msvc': 15.2.4 + '@next/swc-win32-x64-msvc': 15.2.4 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.53.2 + sharp: 0.33.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + optional: true + no-case@2.3.2: dependencies: lower-case: 1.1.4