diff --git a/plugins/argocd/src/functions.ts b/plugins/argocd/src/functions.ts index f0c07b1e4..0c7357106 100644 --- a/plugins/argocd/src/functions.ts +++ b/plugins/argocd/src/functions.ts @@ -2,7 +2,7 @@ import type { ClusterObject, Environment, ListMinimumResources, Project, Reposit import { parseError, uniqueResource } from '@cpn-console/hooks' import { dump } from 'js-yaml' import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' -import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import { PatchUtils } from '@kubernetes/client-node' import { inClusterLabel } from '@cpn-console/shared' import { generateAppProjectName, generateApplicationName, getConfig, getCustomK8sApi } from './utils.js' @@ -209,7 +209,7 @@ async function ensureInfraEnvValues(project: Project, environment: Environment, const cluster = getCluster(project, environment) const infraProject = await gitlabApi.getProjectById(repoId) const valueFilePath = getValueFilePath(project, cluster, environment) - const vaultCredentials = await vaultApi.Project.getCredentials() + const vaultValues = await vaultApi.Project.getValues() const repositories: ArgoRepoSource[] = await Promise.all([ ...infraRepositories.map(async (repository) => { const repoURL = await gitlabApi.getPublicRepoUrl(repository.internalRepoName) @@ -258,7 +258,7 @@ async function ensureInfraEnvValues(project: Project, environment: Environment, name: cluster.label, }, autosync: environment.autosync, - vault: vaultCredentials, + vault: vaultValues, repositories, }, } diff --git a/plugins/gitlab/src/class.ts b/plugins/gitlab/src/class.ts index 94963278f..d76e03397 100644 --- a/plugins/gitlab/src/class.ts +++ b/plugins/gitlab/src/class.ts @@ -3,7 +3,7 @@ import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks' import type { AccessTokenScopes, CommitAction, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, ProjectSchema, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' import { AccessLevel } from '@gitbeaker/core' -import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import { objectEntries } from '@cpn-console/shared' import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { getApi, getGroupRootId, infraAppsRepoName, internalMirrorRepoName } from './utils.js' diff --git a/plugins/gitlab/src/repositories.ts b/plugins/gitlab/src/repositories.ts index aef68526a..3217eafb9 100644 --- a/plugins/gitlab/src/repositories.ts +++ b/plugins/gitlab/src/repositories.ts @@ -1,5 +1,5 @@ import type { Project, Repository } from '@cpn-console/hooks' -import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' import { shallowEqual } from '@cpn-console/shared' import { type GitlabProjectApi, pluginManagedTopic } from './class.js' diff --git a/plugins/harbor/src/robot.ts b/plugins/harbor/src/robot.ts index d6104e2b1..e92b6099d 100644 --- a/plugins/harbor/src/robot.ts +++ b/plugins/harbor/src/robot.ts @@ -1,4 +1,4 @@ -import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import type { HarborApi } from './utils.js' import { getConfig, toVaultSecret } from './utils.js' import type { Access, Robot, RobotCreated } from './api/Api.js' diff --git a/plugins/sonarqube/src/functions.ts b/plugins/sonarqube/src/functions.ts index c5bff9baa..b31747c7b 100644 --- a/plugins/sonarqube/src/functions.ts +++ b/plugins/sonarqube/src/functions.ts @@ -1,7 +1,7 @@ import { adminGroupPath } from '@cpn-console/shared' import type { Project, StepCall } from '@cpn-console/hooks' import { generateProjectKey, parseError } from '@cpn-console/hooks' -import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import { ensureGroupExists, findGroupByName } from './group.js' import type { VaultSonarSecret } from './tech.js' import { getAxiosInstance } from './tech.js' diff --git a/plugins/vault/src/class.ts b/plugins/vault/src/class.ts deleted file mode 100644 index 2ca0555d9..000000000 --- a/plugins/vault/src/class.ts +++ /dev/null @@ -1,443 +0,0 @@ -import type { AxiosInstance } from 'axios' -import axios from 'axios' -import type { ProjectLite } from '@cpn-console/hooks' -import { PluginApi } from '@cpn-console/hooks' -import getConfig from './config.js' -import { - generateKVConfigUpdate, - getAuthMethod, - isAppRoleEnabled, -} from './utils.js' - -interface ReadOptions { - throwIfNoEntry: boolean -} -export interface AppRoleCredentials { - url: string - coreKvName: string - roleId: string - secretId: string -} - -abstract class VaultApi extends PluginApi { - protected readonly axios: AxiosInstance - private token: string | undefined = undefined - - constructor() { - super() - this.axios = axios.create({ - baseURL: getConfig().internalUrl, - headers: { - 'X-Vault-Token': getConfig().token, - }, - }) - } - - protected async getToken() { - if (!this.token) { - this.token = (await this.axios.post('/v1/auth/token/create')).data.auth - .client_token as string - } - return this.token - } - - protected async destroy(path: string, kvName: string) { - if (path.startsWith('/')) path = path.slice(1) - return await this.axios({ - method: 'delete', - url: `/v1/${kvName}/metadata/${path}`, - headers: { 'X-Vault-Token': await this.getToken() }, - }) - } - - Kv = { - upsert: async (kvName: string) => { - const token = await this.getToken() - const kvRes = await this.axios({ - method: 'get', - url: `/v1/sys/mounts/${kvName}/tune`, - headers: { 'X-Vault-Token': token }, - validateStatus: code => [400, 200].includes(code), - }) - if (kvRes.status === 400) { - await this.axios({ - method: 'post', - url: `/v1/sys/mounts/${kvName}`, - headers: { - 'X-Vault-Token': token, - }, - data: { - type: 'kv', - config: { - force_no_cache: true, - }, - options: { - version: 2, - }, - }, - }) - } else { - // means 200 status - const configUpdate = generateKVConfigUpdate(kvRes.data) - if (configUpdate) { - await this.axios({ - method: 'put', - url: `/v1/sys/mounts/${kvName}/tune`, - headers: { - 'X-Vault-Token': token, - }, - data: configUpdate, - }) - } - } - }, - delete: async (kvName: string) => { - const token = await this.getToken() - return await this.axios({ - method: 'delete', - url: `/v1/sys/mounts/${kvName}`, - headers: { 'X-Vault-Token': token }, - validateStatus: code => [400, 200, 204].includes(code), - }) - }, - } - - Policy = { - upsert: async (policyName: string, policy: string) => { - await this.axios({ - method: 'put', - url: `/v1/sys/policies/acl/${policyName}`, - data: { policy }, - headers: { - 'X-Vault-Token': await this.getToken(), - 'Content-Type': 'application/json', - }, - }) - }, - delete: async (policyName: string) => { - await this.axios({ - method: 'delete', - url: `/v1/sys/policies/acl/${policyName}`, - headers: { - 'X-Vault-Token': await this.getToken(), - 'Content-Type': 'application/json', - }, - }) - }, - } - - Role = { - upsert: async (roleName: string, policies: string[]) => { - const appRoleEnabled = await isAppRoleEnabled( - this.axios, - await this.getToken(), - ) - if (!appRoleEnabled) return - - await this.axios({ - method: 'post', - url: `/v1/auth/approle/role/${roleName}`, - data: { - secret_id_num_uses: '0', - secret_id_ttl: '0', - token_max_ttl: '0', - token_num_uses: '0', - token_ttl: '0', - token_type: 'batch', - token_policies: policies, - }, - headers: { 'X-Vault-Token': await this.getToken() }, - }) - }, - delete: async (roleName: string) => { - await this.axios.delete(`/v1/auth/approle/role/${roleName}`, { - headers: { 'X-Vault-Token': await this.getToken() }, - }) - }, - getCredentials: async (roleName: string) => { - const { data: dataRole } = await this.axios.get( - `/v1/auth/approle/role/${roleName}/role-id`, - { - headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - const { data: dataSecret } = await this.axios.put( - `/v1/auth/approle/role/${roleName}/secret-id`, - 'null', // Force empty data - { - headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - return { - roleId: dataRole.data?.role_id, - secretId: dataSecret.data?.secret_id, - } - }, - } -} - -export class VaultProjectApi extends VaultApi { - private readonly basePath: string - private readonly roleName: string - private readonly projectRootDir: string - private readonly defaultAppRoleCredentials: AppRoleCredentials - private readonly coreKvName: string = 'forge-dso' - private readonly projectKvName: string - private readonly groupName: string - private readonly policyName: { - techRO: string - appFull: string - } - - constructor(project: ProjectLite) { - super() - this.basePath = project.slug - this.roleName = project.slug - this.projectRootDir = getConfig().projectsRootDir - this.projectKvName = project.slug - this.groupName = project.slug - this.policyName = { - techRO: `tech--${project.slug}--ro`, - appFull: `app--${project.slug}--admin`, - } - this.defaultAppRoleCredentials = { - url: getConfig().deployVaultConnectionInNs ? getConfig().publicUrl : '', - coreKvName: this.coreKvName, - roleId: 'none', - secretId: 'none', - } - } - - public async list(path: string = '/'): Promise { - if (!path.startsWith('/')) path = `/${path}` - - const listSecretPath: string[] = [] - const response = await this.axios({ - url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, - headers: { - 'X-Vault-Token': await this.getToken(), - }, - method: 'list', - validateStatus: code => [200, 404].includes(code), - }) - - if (response.status === 404) return listSecretPath - for (const key of response.data.data.keys) { - if (key.endsWith('/')) { - const subSecrets = await this.list( - `${path.substring(this.basePath.length)}/${key}`, - ) - subSecrets.forEach((secret) => { - listSecretPath.push(`${key}${secret}`) - }) - } else { - listSecretPath.push(`/${key}`) - } - } - return listSecretPath.flat() - } - - public async read( - path: string = '/', - options: ReadOptions = { throwIfNoEntry: true }, - ) { - if (path.startsWith('/')) path = path.slice(1) - const response = await this.axios.get( - `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, - { - headers: { 'X-Vault-Token': await this.getToken() }, - validateStatus: status => - (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), - }, - ) - return response.data.data - } - - public async write(body: object, path: string = '/') { - if (path.startsWith('/')) path = path.slice(1) - const response = await this.axios.post( - `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, - { - headers: { 'X-Vault-Token': await this.getToken() }, - data: body, - }, - ) - return await response.data - } - - public async destroy(path: string = '/') { - if (path.startsWith('/')) path = path.slice(1) - return super.destroy( - `${this.projectRootDir}/${this.basePath}/${path}`, - this.coreKvName, - ) - } - - Project = { - upsert: async () => { - await this.Kv.upsert(this.projectKvName) - await this.Policy.upsert( - this.policyName.appFull, - `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, - ) - await this.Policy.upsert( - this.policyName.techRO, - `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, - ) - await this.Group.upsert() - await this.Role.upsert(this.roleName, [ - this.policyName.techRO, - this.policyName.appFull, - ]) - }, - delete: async () => { - await this.Kv.delete(this.projectKvName) - await this.Policy.delete(this.policyName.appFull) - await this.Policy.delete(this.policyName.techRO) - await this.Group.delete() - await this.Role.delete(this.roleName) - }, - getCredentials: async () => { - const appRoleEnabled = await isAppRoleEnabled( - this.axios, - await this.getToken(), - ) - if (!appRoleEnabled) return this.defaultAppRoleCredentials - const creds = await this.Role.getCredentials(this.roleName) - return { - ...this.defaultAppRoleCredentials, - ...creds, - } - }, - } - - Group = { - upsert: async () => { - // TODO check api responses for POST and GET maybe duplicates - await this.axios({ - method: 'post', - url: `/v1/identity/group/name/${this.groupName}`, - headers: { 'X-Vault-Token': await this.getToken() }, - data: { - name: this.groupName, - type: 'external', - policies: [this.policyName.appFull], - }, - }) - - const response = await this.axios({ - method: 'get', - url: `/v1/identity/group/name/${this.groupName}`, - headers: { 'X-Vault-Token': await this.getToken() }, - validateStatus: code => [404, 200].includes(code), - }) - const group = response.data - const groupAliasName = `/${this.groupName}` - if (group.data.alias?.name === groupAliasName) { - return - } - const methods = await getAuthMethod(this.axios, await this.getToken()) - - await this.axios({ - url: `/v1/identity/group-alias`, - method: 'post', - headers: { 'X-Vault-Token': await this.getToken() }, - data: { - name: groupAliasName, - mount_accessor: methods['oidc/'].accessor, - canonical_id: group.data.id, - }, - }) - }, - delete: async () => { - await this.axios({ - method: 'delete', - url: `/v1/identity/group/name/${this.groupName}`, - headers: { 'X-Vault-Token': await this.getToken() }, - }) - }, - } -} - -interface VaultValuesWithoutCredentials { - /** Slash-separated directory (root node of all Gitlab projects) */ - projectsRootDir: string -} -interface VaultCredentialsWithoutRole { - url: string - kvName: string -} -interface VaultCredentialsWithRole { - url: string - kvName: string - roleId: string - secretId: string -} -type VaultCredentials = VaultCredentialsWithRole | VaultCredentialsWithoutRole -type VaultValues = VaultCredentials & VaultValuesWithoutCredentials - -export class VaultZoneApi extends VaultApi { - private readonly kvName: string - private readonly policyName: string - private readonly roleName: string - constructor(name: string) { - super() - this.kvName = `zone-${name}` - this.policyName = `tech--${this.kvName}--ro` - this.roleName = `zone-${name}` - } - - public async upsert() { - await this.Kv.upsert(this.kvName) - await this.Policy.upsert( - this.policyName, - `path "${this.kvName}/*" { capabilities = ["read"] }`, - ) - await this.Role.upsert(this.roleName, [this.policyName]) - } - - public async delete() { - await this.Kv.delete(this.kvName) - await this.Policy.delete(this.policyName) - await this.Role.delete(this.roleName) - } - - public async write(body: object, path: string = '/') { - if (path.startsWith('/')) path = path.slice(1) - const response = await this.axios.post(`/v1/${this.kvName}/data/${path}`, { - headers: { 'X-Vault-Token': await this.getToken() }, - data: body, - }) - return await response.data - } - - public async destroy(path: string = '/') { - if (path.startsWith('/')) path = path.slice(1) - return super.destroy(path, this.kvName) - } - - public async getCredentials(): Promise { - const appRoleEnabled = await isAppRoleEnabled( - this.axios, - await this.getToken(), - ) - if (appRoleEnabled) { - return { - url: getConfig().publicUrl, - kvName: this.kvName, - ...(await this.Role.getCredentials(this.roleName)), - } as VaultCredentialsWithRole - } - return { - url: getConfig().publicUrl, - kvName: this.kvName, - } as VaultCredentialsWithoutRole - } - - public async getValues(): Promise { - return { - projectsRootDir: getConfig().projectsRootDir, - ...(await this.getCredentials()), - } - } -} diff --git a/plugins/vault/src/index.ts b/plugins/vault/src/index.ts index 017ecf14d..d42b7030b 100644 --- a/plugins/vault/src/index.ts +++ b/plugins/vault/src/index.ts @@ -2,7 +2,8 @@ import type { ClusterObject, DefaultArgs, Plugin, Project, ProjectLite, ZoneObje import { archiveDsoProject, deleteZone, deployAuth, getSecrets, upsertProject, upsertZone } from './functions.js' import infos from './infos.js' import monitor from './monitor.js' -import { VaultProjectApi, VaultZoneApi } from './class.js' +import { VaultProjectApi } from './vault-project-api.js' +import { VaultZoneApi } from './vault-zone-api.js' const onlyApi = { api: (project: ProjectLite) => new VaultProjectApi(project) } diff --git a/plugins/vault/src/utils.ts b/plugins/vault/src/utils.ts index 4d1facf85..d7036913f 100644 --- a/plugins/vault/src/utils.ts +++ b/plugins/vault/src/utils.ts @@ -9,11 +9,6 @@ export async function getAuthMethod(axiosInstance: AxiosInstance, token: string) return response.data } -export async function isAppRoleEnabled(axiosInstance: AxiosInstance, token: string) { - const methods = await getAuthMethod(axiosInstance, token) - return Object.keys(methods).includes('approle/') -} - export const minimumConfig = { type: 'kv', options: { diff --git a/plugins/vault/src/vault-api.ts b/plugins/vault/src/vault-api.ts new file mode 100644 index 000000000..68f504e57 --- /dev/null +++ b/plugins/vault/src/vault-api.ts @@ -0,0 +1,165 @@ +import type { AxiosInstance } from 'axios' +import axios from 'axios' +import { PluginApi } from '@cpn-console/hooks' +import getConfig from './config.js' +import { + generateKVConfigUpdate, +} from './utils.js' + +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +export abstract class VaultApi extends PluginApi { + protected readonly axios: AxiosInstance + private token: string | undefined = undefined + + constructor() { + super() + this.axios = axios.create({ + baseURL: getConfig().internalUrl, + headers: { + 'X-Vault-Token': getConfig().token, + }, + }) + } + + protected async getToken() { + if (!this.token) { + this.token = (await this.axios.post('/v1/auth/token/create')).data.auth + .client_token as string + } + return this.token + } + + protected async destroy(path: string, kvName: string) { + if (path.startsWith('/')) path = path.slice(1) + return await this.axios({ + method: 'delete', + url: `/v1/${kvName}/metadata/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + } + + Kv = { + upsert: async (kvName: string) => { + const token = await this.getToken() + const kvRes = await this.axios({ + method: 'get', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200].includes(code), + }) + if (kvRes.status === 400) { + await this.axios({ + method: 'post', + url: `/v1/sys/mounts/${kvName}`, + headers: { + 'X-Vault-Token': token, + }, + data: { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + }, + }) + } else { + // means 200 status + const configUpdate = generateKVConfigUpdate(kvRes.data) + if (configUpdate) { + await this.axios({ + method: 'put', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { + 'X-Vault-Token': token, + }, + data: configUpdate, + }) + } + } + }, + delete: async (kvName: string) => { + const token = await this.getToken() + return await this.axios({ + method: 'delete', + url: `/v1/sys/mounts/${kvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200, 204].includes(code), + }) + }, + } + + Policy = { + upsert: async (policyName: string, policy: string) => { + await this.axios({ + method: 'put', + url: `/v1/sys/policies/acl/${policyName}`, + data: { policy }, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + delete: async (policyName: string) => { + await this.axios({ + method: 'delete', + url: `/v1/sys/policies/acl/${policyName}`, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + } + + Role = { + upsert: async (roleName: string, policies: string[]) => { + await this.axios({ + method: 'post', + url: `/v1/auth/approle/role/${roleName}`, + data: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + }, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + delete: async (roleName: string) => { + await this.axios.delete(`/v1/auth/approle/role/${roleName}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + getCredentials: async (roleName: string) => { + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, + } +} diff --git a/plugins/vault/src/vault-project-api.ts b/plugins/vault/src/vault-project-api.ts new file mode 100644 index 000000000..45ec6280c --- /dev/null +++ b/plugins/vault/src/vault-project-api.ts @@ -0,0 +1,207 @@ +import type { ProjectLite } from '@cpn-console/hooks' +import getConfig from './config.js' +import { + getAuthMethod, +} from './utils.js' +import { VaultApi } from './vault-api.js' + +interface ReadOptions { + throwIfNoEntry: boolean +} +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +interface VaultValuesWithoutCredentials { + /** Slash-separated directory (root node of all Gitlab projects) */ + projectsRootDir: string +} + +type VaultValues = AppRoleCredentials & VaultValuesWithoutCredentials + +export class VaultProjectApi extends VaultApi { + private readonly basePath: string + private readonly roleName: string + private readonly projectRootDir: string + private readonly defaultAppRoleCredentials: AppRoleCredentials + private readonly coreKvName: string = 'forge-dso' + private readonly projectKvName: string + private readonly groupName: string + private readonly policyName: { + techRO: string + appFull: string + } + + constructor(project: ProjectLite) { + super() + this.basePath = project.slug + this.roleName = project.slug + this.projectRootDir = getConfig().projectsRootDir + this.projectKvName = project.slug + this.groupName = project.slug + this.policyName = { + techRO: `tech--${project.slug}--ro`, + appFull: `app--${project.slug}--admin`, + } + this.defaultAppRoleCredentials = { + url: getConfig().deployVaultConnectionInNs ? getConfig().publicUrl : '', + coreKvName: this.coreKvName, + roleId: 'none', + secretId: 'none', + } + } + + public async list(path: string = '/'): Promise { + if (!path.startsWith('/')) path = `/${path}` + + const listSecretPath: string[] = [] + const response = await this.axios({ + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, + headers: { + 'X-Vault-Token': await this.getToken(), + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return listSecretPath + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + const subSecrets = await this.list( + `${path.substring(this.basePath.length)}/${key}`, + ) + subSecrets.forEach((secret) => { + listSecretPath.push(`${key}${secret}`) + }) + } else { + listSecretPath.push(`/${key}`) + } + } + return listSecretPath.flat() + } + + public async read( + path: string = '/', + options: ReadOptions = { throwIfNoEntry: true }, + ) { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.get( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: status => + (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), + }, + ) + return response.data.data + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }, + ) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy( + `${this.projectRootDir}/${this.basePath}/${path}`, + this.coreKvName, + ) + } + + Project = { + upsert: async () => { + await this.Kv.upsert(this.projectKvName) + await this.Policy.upsert( + this.policyName.appFull, + `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + ) + await this.Policy.upsert( + this.policyName.techRO, + `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + ) + await this.Group.upsert() + await this.Role.upsert(this.roleName, [ + this.policyName.techRO, + this.policyName.appFull, + ]) + }, + delete: async () => { + await this.Kv.delete(this.projectKvName) + await this.Policy.delete(this.policyName.appFull) + await this.Policy.delete(this.policyName.techRO) + await this.Group.delete() + await this.Role.delete(this.roleName) + }, + getCredentials: async (): Promise => { + const creds = await this.Role.getCredentials(this.roleName) + return { + ...this.defaultAppRoleCredentials, + ...creds, + } + }, + getValues: async (): Promise => { + return { + projectsRootDir: getConfig().projectsRootDir, + ...(await this.Project.getCredentials()), + } + }, + } + + Group = { + upsert: async () => { + // TODO check api responses for POST and GET maybe duplicates + await this.axios({ + method: 'post', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: this.groupName, + type: 'external', + policies: [this.policyName.appFull], + }, + }) + + const response = await this.axios({ + method: 'get', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: code => [404, 200].includes(code), + }) + const group = response.data + const groupAliasName = `/${this.groupName}` + if (group.data.alias?.name === groupAliasName) { + return + } + const methods = await getAuthMethod(this.axios, await this.getToken()) + + await this.axios({ + url: `/v1/identity/group-alias`, + method: 'post', + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: groupAliasName, + mount_accessor: methods['oidc/'].accessor, + canonical_id: group.data.id, + }, + }) + }, + delete: async () => { + await this.axios({ + method: 'delete', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + } +} diff --git a/plugins/vault/src/vault-zone-api.ts b/plugins/vault/src/vault-zone-api.ts new file mode 100644 index 000000000..378d39308 --- /dev/null +++ b/plugins/vault/src/vault-zone-api.ts @@ -0,0 +1,63 @@ +import getConfig from './config.js' +import { VaultApi } from './vault-api.js' + +interface VaultCredentials { + url: string + kvName: string + roleId: string + secretId: string +} +type VaultValues = VaultCredentials + +export class VaultZoneApi extends VaultApi { + private readonly kvName: string + private readonly policyName: string + private readonly roleName: string + constructor(name: string) { + super() + this.kvName = `zone-${name}` + this.policyName = `tech--${this.kvName}--ro` + this.roleName = `zone-${name}` + } + + public async upsert() { + await this.Kv.upsert(this.kvName) + await this.Policy.upsert( + this.policyName, + `path "${this.kvName}/*" { capabilities = ["read"] }`, + ) + await this.Role.upsert(this.roleName, [this.policyName]) + } + + public async delete() { + await this.Kv.delete(this.kvName) + await this.Policy.delete(this.policyName) + await this.Role.delete(this.roleName) + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post(`/v1/${this.kvName}/data/${path}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy(path, this.kvName) + } + + public async getCredentials(): Promise { + return { + url: getConfig().publicUrl, + kvName: this.kvName, + ...(await this.Role.getCredentials(this.roleName)), + } as VaultCredentials + } + + public async getValues(): Promise { + return this.getCredentials() + } +} diff --git a/plugins/vault/src/vso.ts b/plugins/vault/src/vso.ts index 0a61c2978..579eb8d8f 100644 --- a/plugins/vault/src/vso.ts +++ b/plugins/vault/src/vso.ts @@ -1,4 +1,4 @@ -import type { AppRoleCredentials } from './class.js' +import type { AppRoleCredentials } from './vault-project-api.js' export function generateVsoVaultConnection(creds: AppRoleCredentials) { return {