import { CreateVariableSetRequest, ResolveVariableSetAttachmentsRequest, ResolveVariableSetAttachmentsResponse, SetVariableSetVariableRequest, UpdateVariableSetRequest, VariableSetVariableName, } from "@opengeni/contracts"; import { countVariableSets, createVariableSet, decryptVariableSetValue, deleteVariableSet, deleteVariableSetVariable, encryptVariableSetValue, getVariableSetByName, listVariableSets, readVariableSetSecretAtomically, resolveVariableSetAttachments, setVariableSetVariable, updateVariableSet, VariableSetAttachedError, } from "@opengeni/db"; import type { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { requireAccessGrant, requireAccessGrantAuthorization, requireLiteralPermission, requirePermission, } from "@opengeni/core"; import type { ApiRouteDeps } from "@opengeni/core"; import { assertAllowedVariableSetVariableName, MAX_ENVIRONMENTS_PER_WORKSPACE, MAX_VARIABLES_PER_ENVIRONMENT, recordVariableSetAuditEvent, requireVariableSetEncryption, requireVariableSetForApi, } from "@opengeni/core"; export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void { const { settings, db } = deps; const prefixes = [ "/v1/workspaces/:workspaceId/variable-sets", "/v1/workspaces/:workspaceId/environments", ]; for (const prefix of prefixes) { app.get(`${prefix}`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const grant = await requireAccessGrant(c, deps, workspaceId); requirePermission(grant, "variable-sets:list"); requirePermission(grant, "secrets:list"); return c.json( await listVariableSets(db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, }), ); }); app.post(`${prefix}`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId); const grant = authorization.grant; requirePermission(grant, "variable-sets:write"); const key = requireVariableSetEncryption(settings); const payload = CreateVariableSetRequest.parse(await c.req.json()); if (prefix.endsWith("/environments") && payload.scope !== "workspace") { throw new HTTPException(422, { message: "the legacy environments route only creates workspace-scoped variable sets", }); } if ( payload.scope === "organization" && authorization.accountGrant?.permissions.includes("account:admin") !== true ) { throw new HTTPException(403, { message: "missing permission: account:admin", }); } if (payload.variables.length > 0) { requirePermission(grant, "secrets:write"); } const name = trimmedVariableSetName(payload.name); if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT) { throw new HTTPException(422, { message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`, }); } const variableNames = new Set(); for (const variable of payload.variables) { assertAllowedVariableSetVariableName(variable.name); if (variableNames.has(variable.name)) { throw new HTTPException(422, { message: `duplicate variable set variable name: ${variable.name}`, }); } variableNames.add(variable.name); } const access = { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, }; if ((await countVariableSets(db, access, payload.scope)) >= MAX_ENVIRONMENTS_PER_WORKSPACE) { throw new HTTPException(422, { message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`, }); } if (await getVariableSetByName(db, access, name, payload.scope)) { throw new HTTPException(409, { message: `variable set name is already in use: ${name}`, }); } // Values are encrypted up front and the variableSet plus all initial // variables are written in one transaction: a failure leaves nothing. const created = await createVariableSet(db, { accountId: grant.accountId, workspaceId, scope: payload.scope, subjectId: grant.subjectId, allowOrganization: payload.scope === "organization", name, description: payload.description ?? null, variables: payload.variables.map((variable) => ({ name: variable.name, valueEncrypted: encryptVariableSetValue(key, variable.value), })), }); await recordVariableSetAuditEvent(db, { grant, action: "variable_set.created", variableSetId: created.id, }); return c.json(created, 201); }); if (prefix.endsWith("/variable-sets")) { app.post(`${prefix}/resolve-attachments`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const grant = await requireAccessGrant(c, deps, workspaceId); requirePermission(grant, "variable-sets:attach"); requirePermission(grant, "variable-sets:use"); const payload = ResolveVariableSetAttachmentsRequest.parse(await c.req.json()); const variableSets = await resolveVariableSetAttachments( db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, }, payload.variableSetIds, ); return c.json(ResolveVariableSetAttachmentsResponse.parse({ variableSets })); }); } app.get(`${prefix}/:variableSetId`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const grant = await requireAccessGrant(c, deps, workspaceId); requirePermission(grant, "variable-sets:read"); requirePermission(grant, "secrets:list"); return c.json(await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!)); }); if (prefix.endsWith("/variable-sets")) { app.get(`${prefix}/:variableSetId/variables/:name`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const grant = await requireAccessGrant(c, deps, workspaceId); requirePermission(grant, "variable-sets:read"); requireLiteralPermission(grant, "secrets:read"); const key = requireVariableSetEncryption(settings); const name = parseVariableName(c.req.param("name")!); const secret = await readVariableSetSecretAtomically(db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, variableSetId: c.req.param("variableSetId")!, name, actor: { kind: "subject" }, decrypt: (valueEncrypted) => decryptVariableSetValue(key, valueEncrypted), }); if (!secret) { throw new HTTPException(404, { message: "variable set variable not found", }); } return c.json(secret); }); } app.patch(`${prefix}/:variableSetId`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId); const grant = authorization.grant; requirePermission(grant, "variable-sets:write"); const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!); const allowOrganization = variableSet.scope === "organization" && authorization.accountGrant?.permissions.includes("account:admin") === true; if (variableSet.scope === "organization" && !allowOrganization) { throw new HTTPException(403, { message: "missing permission: account:admin", }); } const payload = UpdateVariableSetRequest.parse(await c.req.json()); const name = payload.name !== undefined ? trimmedVariableSetName(payload.name) : undefined; if (name !== undefined && name !== variableSet.name) { const existing = await getVariableSetByName( db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, }, name, variableSet.scope, ); if (existing && existing.id !== variableSet.id) { throw new HTTPException(409, { message: `variable set name is already in use: ${name}`, }); } } const updated = await updateVariableSet( db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId }, variableSet.id, { ...(name !== undefined ? { name } : {}), ...(payload.description !== undefined ? { description: payload.description } : {}), allowOrganization, }, ); await recordVariableSetAuditEvent(db, { grant, action: "variable_set.updated", variableSetId: variableSet.id, }); return c.json(updated); }); app.delete(`${prefix}/:variableSetId`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId); const grant = authorization.grant; requirePermission(grant, "variable-sets:write"); requirePermission(grant, "secrets:write"); const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!); const allowOrganization = variableSet.scope === "organization" && authorization.accountGrant?.permissions.includes("account:admin") === true; if (variableSet.scope === "organization" && !allowOrganization) { throw new HTTPException(403, { message: "missing permission: account:admin", }); } try { await deleteVariableSet( db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, }, variableSet.id, { allowOrganization }, ); } catch (error) { if (error instanceof VariableSetAttachedError) { throw new HTTPException(409, { message: error.message }); } throw error; } await recordVariableSetAuditEvent(db, { grant, action: "variable_set.deleted", variableSetId: variableSet.id, }); return c.json({ ok: true }); }); app.put(`${prefix}/:variableSetId/variables/:name`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId); const grant = authorization.grant; requirePermission(grant, "variable-sets:write"); requirePermission(grant, "secrets:write"); const key = requireVariableSetEncryption(settings); const name = parseVariableName(c.req.param("name")!); const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!); const allowOrganization = variableSet.scope === "organization" && authorization.accountGrant?.permissions.includes("account:admin") === true; if (variableSet.scope === "organization" && !allowOrganization) { throw new HTTPException(403, { message: "missing permission: account:admin", }); } const payload = SetVariableSetVariableRequest.parse(await c.req.json()); const exists = variableSet.variables.some((variable) => variable.name === name); if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) { throw new HTTPException(422, { message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables`, }); } const metadata = await setVariableSetVariable(db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, variableSetId: variableSet.id, name, valueEncrypted: encryptVariableSetValue(key, payload.value), allowOrganization, }); await recordVariableSetAuditEvent(db, { grant, action: "variable_set.variable.set", variableSetId: variableSet.id, variableName: name, }); return c.json(metadata); }); app.delete(`${prefix}/:variableSetId/variables/:name`, async (c) => { const workspaceId = c.req.param("workspaceId")!; const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId); const grant = authorization.grant; requirePermission(grant, "variable-sets:write"); requirePermission(grant, "secrets:write"); const name = parseVariableName(c.req.param("name")!); const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!); const allowOrganization = variableSet.scope === "organization" && authorization.accountGrant?.permissions.includes("account:admin") === true; if (variableSet.scope === "organization" && !allowOrganization) { throw new HTTPException(403, { message: "missing permission: account:admin", }); } const deleted = await deleteVariableSetVariable(db, { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId, variableSetId: variableSet.id, name, allowOrganization, }); if (!deleted) { throw new HTTPException(404, { message: "variable set variable not found", }); } await recordVariableSetAuditEvent(db, { grant, action: "variable_set.variable.deleted", variableSetId: variableSet.id, variableName: name, }); return c.json({ ok: true }); }); } } /** @deprecated use registerVariableSetRoutes */ export const registerEnvironmentRoutes = registerVariableSetRoutes; function parseVariableName(raw: string): string { const parsed = VariableSetVariableName.safeParse(raw); if (!parsed.success) { throw new HTTPException(422, { message: "variable set variable names must match ^[A-Z][A-Z0-9_]*$", }); } assertAllowedVariableSetVariableName(parsed.data); return parsed.data; } function trimmedVariableSetName(name: string): string { const trimmed = name.trim(); if (!trimmed) { throw new HTTPException(422, { message: "variable set name is required" }); } return trimmed; }