diff --git a/.changeset/add-akeyless-plugin.md b/.changeset/add-akeyless-plugin.md new file mode 100644 index 00000000..b8cea634 --- /dev/null +++ b/.changeset/add-akeyless-plugin.md @@ -0,0 +1,5 @@ +--- +"@varlock/akeyless-plugin": patch +--- + +Add Akeyless plugin for loading secrets from Akeyless Platform. Supports API Key authentication, static/dynamic/rotated secrets, self-hosted gateway, multiple instances, and automatic token caching. diff --git a/packages/plugins/akeyless/CHANGELOG.md b/packages/plugins/akeyless/CHANGELOG.md new file mode 100644 index 00000000..a97ac6af --- /dev/null +++ b/packages/plugins/akeyless/CHANGELOG.md @@ -0,0 +1 @@ +# @varlock/akeyless-plugin diff --git a/packages/plugins/akeyless/README.md b/packages/plugins/akeyless/README.md new file mode 100644 index 00000000..100a2f9d --- /dev/null +++ b/packages/plugins/akeyless/README.md @@ -0,0 +1,247 @@ +# @varlock/akeyless-plugin + +[![npm version](https://img.shields.io/npm/v/@varlock/akeyless-plugin.svg)](https://www.npmjs.com/package/@varlock/akeyless-plugin) [![GitHub stars](https://img.shields.io/github/stars/dmno-dev/varlock.svg?style=social&label=Star)](https://github.com/dmno-dev/varlock) [![license](https://img.shields.io/npm/l/@varlock/akeyless-plugin.svg)](https://github.com/dmno-dev/varlock/blob/main/LICENSE) + +This package is a [Varlock](https://varlock.dev) [plugin](https://varlock.dev/guides/plugins/) that enables loading secrets from [Akeyless Platform](https://www.akeyless.io/) into your configuration. + +## Features + +- **API Key authentication** - Simple access_id + access_key authentication +- **Static secrets** - Fetch static (key/value) secrets +- **Dynamic secrets** - Fetch on-demand generated credentials (database, cloud, etc.) +- **Rotated secrets** - Fetch auto-rotated credentials +- **JSON key extraction** from secrets using `#` syntax or named `key` parameter +- **Path prefixing** with `pathPrefix` option for organized secret management +- **Gateway support** - Use a self-hosted Akeyless Gateway via custom `apiUrl` +- **Auto-infer secret name** from environment variable names +- Support for multiple Akeyless instances +- Automatic token caching and renewal +- Response caching for deduplicating concurrent fetches +- Lightweight implementation using REST API (no heavy SDK dependencies) + +## Installation + +If you are in a JavaScript based project and have a package.json file, you can either install the plugin explicitly +```bash +npm install @varlock/akeyless-plugin +``` +And then register the plugin without any version number +```env-spec title=".env.schema" +# @plugin(@varlock/akeyless-plugin) +``` + +Otherwise just set the explicit version number when you register it +```env-spec title=".env.schema" +# @plugin(@varlock/akeyless-plugin@1.2.3) +``` + +See our [Plugin Guide](https://varlock.dev/guides/plugins/#installation) for more details. + +## Setup + Auth + +After registering the plugin, you must initialize it with the `@initAkeyless` root decorator. + +### API Key authentication + +The simplest auth method uses an API Key (Access ID + Access Key): + +```env-spec +# @plugin(@varlock/akeyless-plugin) +# @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY) +# --- + +# @type=akeylessAccessId +AKEYLESS_ACCESS_ID= +# @type=akeylessAccessKey @sensitive +AKEYLESS_ACCESS_KEY= +``` + +You would then need to inject these env vars using your CI/CD system or set them locally. + +### Using an Akeyless Gateway + +If you are running a self-hosted [Akeyless Gateway](https://docs.akeyless.io/docs/api-gateway), provide the gateway URL via `apiUrl`: + +```env-spec +# @initAkeyless( +# accessId=$AKEYLESS_ACCESS_ID, +# accessKey=$AKEYLESS_ACCESS_KEY, +# apiUrl="https://gateway.example.com:8080" +# ) +``` + +### Multiple instances + +If you need to connect to multiple Akeyless instances, register named instances: + +```env-spec +# @initAkeyless(id=prod, accessId=$PROD_ACCESS_ID, accessKey=$PROD_ACCESS_KEY) +# @initAkeyless(id=dev, accessId=$DEV_ACCESS_ID, accessKey=$DEV_ACCESS_KEY) +``` + +## Reading secrets + +This plugin introduces the `akeyless()` function to fetch secret values from Akeyless. + +### Static secrets + +Static secrets are simple key/value pairs. This is the default secret type. + +```env-spec title=".env.schema" +# @plugin(@varlock/akeyless-plugin) +# @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY) +# --- + +# Fetch a static secret by its full path +DB_PASSWORD=akeyless("/MyApp/DB_PASSWORD") + +# Extract a JSON key from a static secret +DB_HOST=akeyless("/MyApp/DBConfig#host") + +# Or use named key parameter +DB_PORT=akeyless("/MyApp/DBConfig", key="port") + +# If using multiple instances +PROD_SECRET=akeyless(prod, "/MyApp/Secret") +DEV_SECRET=akeyless(dev, "/MyApp/Secret") +``` + +### Path prefixing + +Use `pathPrefix` to automatically prefix all secret paths: + +```env-spec +# @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY, pathPrefix="/MyApp") +# --- + +# Fetches from "/MyApp/DB_PASSWORD" +DB_PASSWORD=akeyless("DB_PASSWORD") + +# Auto-infer also uses the prefix: fetches from "/MyApp/API_KEY" +API_KEY=akeyless() +``` + +### Dynamic secrets + +Dynamic secrets generate on-demand credentials (e.g., temporary database credentials, cloud access tokens). Use the `type=dynamic` parameter: + +```env-spec +# Fetch entire dynamic secret as JSON +DB_CREDENTIALS=akeyless("/MyApp/DynamicDBSecret", type=dynamic) + +# Extract specific keys from the dynamic secret response +DB_USER=akeyless("/MyApp/DynamicDBSecret#user", type=dynamic) +DB_PASS=akeyless("/MyApp/DynamicDBSecret#password", type=dynamic) +``` + +Multiple items referencing the same secret path are cached — only one API call is made. + +### Rotated secrets + +Rotated secrets are auto-rotated credentials. Use the `type=rotated` parameter: + +```env-spec +# Fetch entire rotated secret as JSON +DB_ROTATED_CREDS=akeyless("/MyApp/RotatedDBPassword", type=rotated) + +# Extract individual keys +DB_USER=akeyless("/MyApp/RotatedDBPassword#user", type=rotated) +DB_PASS=akeyless("/MyApp/RotatedDBPassword#password", type=rotated) +``` + +--- + +## Reference + +### Root decorators + +#### `@initAkeyless()` + +Initialize an Akeyless plugin instance. + +**Parameters:** + +- `accessId: string` (required) - Akeyless Access ID (starts with `p-` for API Key auth) +- `accessKey: string` (required) - Akeyless Access Key +- `apiUrl?: string` - Akeyless API URL (defaults to `https://api.akeyless.io`). Use this for self-hosted Akeyless Gateway. +- `pathPrefix?: string` - Prefix automatically prepended to all secret paths +- `id?: string` - Instance identifier for multiple instances (defaults to `_default`) + +### Functions + +#### `akeyless()` + +Fetch a secret from Akeyless. + +**Signatures:** + +- `akeyless()` - Uses the item key (variable name) as the secret name +- `akeyless(secretName)` - Fetch by explicit secret path +- `akeyless(instanceId, secretName)` - Fetch from a specific instance +- `akeyless("path#key")` - Extract a JSON key using `#` syntax +- `akeyless(secretName, key="field")` - Extract a JSON key using named parameter +- `akeyless(secretName, type=dynamic)` - Fetch a dynamic secret +- `akeyless(secretName, type=rotated)` - Fetch a rotated secret + +**Secret types:** + +- `static` (default) - Simple key/value secrets. If the value is JSON, use `#KEY` or `key=` to extract individual keys. +- `dynamic` - On-demand generated credentials. Returns JSON by default, or extract a specific key. +- `rotated` - Auto-rotated credentials. Returns JSON by default, or extract a specific key. + +**Caching:** Multiple items referencing the same secret path and type share a single API call. + +### Data Types + +- `akeylessAccessId` - Akeyless Access ID (validates `p-` prefix) +- `akeylessAccessKey` - Akeyless Access Key (sensitive) + +--- + +## Akeyless Setup + +### Create an API Key + +1. Log in to the [Akeyless Console](https://console.akeyless.io) +2. Go to **Auth Methods** → **New** → **API Key** +3. Save the generated **Access ID** and **Access Key** + +### Create a Static Secret + +```bash +# Using the Akeyless CLI +akeyless create-secret --name "/MyApp/DB_PASSWORD" --value "supersecret" + +# Or via the Console: Secrets & Keys → New → Static Secret +``` + +### Set Up Access Permissions + +1. Go to **Access Roles** in the Akeyless Console +2. Create or edit a role +3. Add rules to grant **read** access to the secrets your application needs +4. Associate the role with your API Key auth method + +## Troubleshooting + +### Secret not found +- Verify the secret exists in the Akeyless Console +- Check the full secret path (e.g., `/MyFolder/MySecret`) +- Ensure the path starts with `/` +- If using `pathPrefix`, check the combined path is correct + +### JSON key not found +- Verify the key exists in the secret value +- Key names are case-sensitive +- For static secrets, ensure the value is valid JSON when using `#KEY` or `key=` + +### Permission denied +- Check the Access Role associated with your API Key auth method +- Ensure the role includes read permission for the secret path +- Verify the role is associated with the correct auth method + +### Authentication failed +- Verify the Access ID starts with `p-` (API Key auth) +- Ensure the Access Key matches the Access ID +- If using a Gateway, verify the `apiUrl` is correct and reachable +- Check if the auth method is active in the Akeyless Console diff --git a/packages/plugins/akeyless/package.json b/packages/plugins/akeyless/package.json new file mode 100644 index 00000000..801e0201 --- /dev/null +++ b/packages/plugins/akeyless/package.json @@ -0,0 +1,55 @@ +{ + "name": "@varlock/akeyless-plugin", + "description": "Varlock plugin to load secrets from Akeyless Platform", + "version": "0.0.1", + "type": "module", + "homepage": "https://varlock.dev/plugins/akeyless/", + "bugs": "https://github.com/dmno-dev/varlock/issues", + "repository": { + "type": "git", + "url": "https://github.com/dmno-dev/varlock.git", + "directory": "packages/plugins/akeyless" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + "./plugin": "./dist/plugin.cjs" + }, + "files": ["dist"], + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "test": "vitest", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "varlock", + "plugin", + "varlock-plugin", + "akeyless", + "secrets", + "secrets-manager", + "env", + ".env", + "dotenv", + "environment variables", + "env vars", + "config" + ], + "author": "dmno-dev", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "varlock": "workspace:^" + }, + "devDependencies": { + "@env-spec/utils": "workspace:^", + "@types/node": "catalog:", + "ky": "catalog:", + "tsup": "catalog:", + "varlock": "workspace:^", + "vitest": "catalog:" + } +} diff --git a/packages/plugins/akeyless/src/plugin.ts b/packages/plugins/akeyless/src/plugin.ts new file mode 100644 index 00000000..02de0180 --- /dev/null +++ b/packages/plugins/akeyless/src/plugin.ts @@ -0,0 +1,523 @@ +import { type Resolver, plugin } from 'varlock/plugin-lib'; +import ky from 'ky'; + +const { SchemaError, ResolutionError } = plugin.ERRORS; + +plugin.name = 'akeyless'; +const { debug } = plugin; +debug('init - version =', plugin.version); +plugin.standardVars = { + initDecorator: '@initAkeyless', + params: { + accessId: { key: 'AKEYLESS_ACCESS_ID', dataType: 'akeylessAccessId' }, + accessKey: { key: 'AKEYLESS_ACCESS_KEY', dataType: 'akeylessAccessKey' }, + }, +}; + +const DEFAULT_API_URL = 'https://api.akeyless.io'; + +const FIX_AUTH_TIP = [ + 'Verify your Akeyless credentials are configured correctly:', + ' 1. Provide an API Key via @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY)', + ' 2. Ensure the Access ID starts with "p-" (API Key auth)', + ' 3. Verify the Access Key matches the Access ID in the Akeyless Console', +].join('\n'); + +interface CachedToken { + token: string; + expiresAt: number; +} + +/** Extract a key from a JSON object, or return full JSON string if no key specified */ +function extractJsonKey( + data: Record, + jsonKey: string | undefined, + label: string, +): string { + if (jsonKey) { + if (!(jsonKey in data)) { + throw new ResolutionError(`Key "${jsonKey}" not found in ${label}`, { + tip: `Available keys: ${Object.keys(data).join(', ')}`, + }); + } + return String(data[jsonKey]); + } + return JSON.stringify(data); +} + +class AkeylessPluginInstance { + private accessId?: string; + private accessKey?: string; + private apiUrl: string = DEFAULT_API_URL; + private pathPrefix?: string; + private cachedToken?: CachedToken; + private secretCache = new Map>(); + + constructor( + readonly id: string, + ) {} + + setAuth( + accessId?: any, + accessKey?: any, + apiUrl?: any, + pathPrefix?: any, + ) { + this.accessId = accessId ? String(accessId) : undefined; + this.accessKey = accessKey ? String(accessKey) : undefined; + if (apiUrl) this.apiUrl = String(apiUrl).replace(/\/+$/, ''); + this.pathPrefix = pathPrefix ? String(pathPrefix) : undefined; + debug( + 'akeyless instance', + this.id, + 'set auth - apiUrl:', + this.apiUrl, + 'hasAccessId:', + !!this.accessId, + 'hasAccessKey:', + !!this.accessKey, + 'pathPrefix:', + this.pathPrefix, + ); + } + + applyPathPrefix(name: string): string { + if (this.pathPrefix) { + const prefix = this.pathPrefix.replace(/\/+$/, ''); + const path = name.replace(/^\/+/, ''); + return `${prefix}/${path}`; + } + return name; + } + + private async authenticate(): Promise { + // Check cached token (with 30s buffer before expiry) + if (this.cachedToken && this.cachedToken.expiresAt > Date.now() + 30_000) { + debug('Using cached Akeyless token'); + return this.cachedToken.token; + } + + if (!this.accessId || !this.accessKey) { + throw new SchemaError('Akeyless Access ID and Access Key are required', { + tip: FIX_AUTH_TIP, + }); + } + + try { + debug('Authenticating with Akeyless API Key'); + + const response = await ky.post(`${this.apiUrl}/auth`, { + json: { + 'access-id': this.accessId, + 'access-key': this.accessKey, + 'access-type': 'access_key', + }, + }).json<{ token: string; expiry?: number }>(); + + const token = response.token; + if (!token) { + throw new SchemaError('Authentication succeeded but no token was returned'); + } + + // Cache the token; use expiry from response if available, otherwise default to 30 minutes + const expiresIn = response.expiry + ? (response.expiry * 1000) - Date.now() + : 30 * 60 * 1000; + this.cachedToken = { + token, + expiresAt: Date.now() + expiresIn, + }; + + debug('Successfully authenticated with Akeyless'); + return token; + } catch (err: any) { + // Re-throw our own errors as-is + if (err instanceof SchemaError) throw err; + + let errorMessage = 'Akeyless authentication failed'; + let errorTip: string | undefined; + + if (err.response) { + const status = err.response.status; + if (status === 401 || status === 403) { + errorMessage = 'Akeyless authentication failed - invalid credentials'; + errorTip = FIX_AUTH_TIP; + } else { + try { + const errorBody = await err.response.json(); + const msg = errorBody.message || errorBody.error || ''; + errorMessage = `Akeyless auth error (HTTP ${status}): ${msg}`; + } catch { + errorMessage = `Akeyless auth error (HTTP ${status})`; + } + } + } else if (err.message) { + errorMessage = `Akeyless auth error: ${err.message}`; + errorTip = 'Verify the Akeyless API URL is correct and reachable'; + } + + throw new SchemaError(errorMessage, { tip: errorTip }); + } + } + + /** Deduplicate concurrent fetches for the same cache key */ + private cachedFetch(cacheKey: string, fetchFn: () => Promise): Promise { + const cached = this.secretCache.get(cacheKey); + if (cached) { + debug(`Using cached fetch for: ${cacheKey}`); + return cached; + } + const promise = fetchFn(); + this.secretCache.set(cacheKey, promise); + // Clear cache entry on failure so retries can try again + promise.catch(() => this.secretCache.delete(cacheKey)); + return promise; + } + + /** Handle common API error responses and convert to ResolutionError */ + private handleApiError(err: any, secretType: string, secretName: string): never { + let errorMessage = `Failed to fetch ${secretType} secret from Akeyless`; + let errorTip: string | undefined; + + if (err.response) { + const status = err.response.status; + + if (status === 404) { + errorMessage = `${secretType} secret "${secretName}" not found`; + errorTip = [ + `Verify the ${secretType} secret exists in the Akeyless Console`, + 'Ensure the path is correct (e.g., "/MyFolder/MySecret")', + ].join('\n'); + } else if (status === 403) { + errorMessage = `Permission denied for ${secretType} secret "${secretName}"`; + errorTip = [ + `Ensure your access credentials have read permission for this ${secretType} secret.`, + 'Check your Access Role configuration in the Akeyless Console.', + ].join('\n'); + } else if (status === 401) { + this.cachedToken = undefined; + errorMessage = 'Akeyless authentication token expired or invalid'; + errorTip = FIX_AUTH_TIP; + } else { + errorMessage = `Akeyless error (HTTP ${status})`; + } + } else if (err.message) { + errorMessage = `Network error: ${err.message}`; + errorTip = 'Verify the Akeyless API URL is correct and the service is reachable'; + } + + throw new ResolutionError(errorMessage, { tip: errorTip }); + } + + async getStaticSecret(secretName: string, jsonKey?: string): Promise { + const value = await this.cachedFetch(`static:${secretName}`, async () => { + const token = await this.authenticate(); + try { + debug(`Fetching static secret: ${secretName}`); + const response = await ky.post(`${this.apiUrl}/get-secret-value`, { + json: { names: [secretName], token }, + }).json>(); + + const val = response[secretName]; + if (val === undefined || val === null) { + throw new ResolutionError(`Secret "${secretName}" not found in response`, { + tip: [ + 'Verify the secret exists in Akeyless:', + ` Secret name: ${secretName}`, + '', + 'Common issues:', + ' - The secret name must include the full path (e.g., "/MyFolder/MySecret")', + ' - The secret may have been deleted or moved', + ].join('\n'), + }); + } + return val; + } catch (err: any) { + if (err instanceof ResolutionError) throw err; + this.handleApiError(err, 'static', secretName); + } + }); + + // For static secrets, JSON key extraction requires parsing the string value + if (jsonKey) { + try { + const parsed = JSON.parse(value); + return extractJsonKey(parsed, jsonKey, 'secret JSON'); + } catch (err) { + if (err instanceof ResolutionError) throw err; + throw new ResolutionError(`Failed to parse secret as JSON: ${err instanceof Error ? err.message : String(err)}`, { + tip: 'Ensure the secret value is valid JSON when extracting a specific key', + }); + } + } + return value; + } + + async getDynamicSecret(secretName: string, jsonKey?: string): Promise { + const response = await this.cachedFetch(`dynamic:${secretName}`, async () => { + const token = await this.authenticate(); + try { + debug(`Fetching dynamic secret: ${secretName}`); + return await ky.post(`${this.apiUrl}/get-dynamic-secret-value`, { + json: { name: secretName, token }, + }).json>(); + } catch (err: any) { + if (err instanceof ResolutionError) throw err; + this.handleApiError(err, 'dynamic', secretName); + } + }); + + return extractJsonKey(response, jsonKey, 'dynamic secret response'); + } + + async getRotatedSecret(secretName: string, jsonKey?: string): Promise { + const value = await this.cachedFetch(`rotated:${secretName}`, async () => { + const token = await this.authenticate(); + try { + debug(`Fetching rotated secret: ${secretName}`); + const response = await ky.post(`${this.apiUrl}/get-rotated-secret-value`, { + json: { names: secretName, token }, + }).json<{ value: Record }>(); + + if (!response.value) { + throw new ResolutionError(`Rotated secret "${secretName}" returned no value`); + } + return response.value; + } catch (err: any) { + if (err instanceof ResolutionError) throw err; + this.handleApiError(err, 'rotated', secretName); + } + }); + + return extractJsonKey(value, jsonKey, 'rotated secret response'); + } +} + +const pluginInstances: Record = {}; + +plugin.registerRootDecorator({ + name: 'initAkeyless', + description: 'Initialize an Akeyless plugin instance for akeyless() resolver', + isFunction: true, + async process(argsVal) { + const objArgs = argsVal.objArgs; + if (!objArgs) throw new SchemaError('Expected some args'); + + // Validate id is static + if (objArgs.id && !objArgs.id.isStatic) { + throw new SchemaError('Expected id to be static'); + } + const id = String(objArgs?.id?.staticValue || '_default'); + if (pluginInstances[id]) { + throw new SchemaError(`Instance with id "${id}" already initialized`); + } + + // accessId is required + if (!objArgs.accessId) { + throw new SchemaError('accessId parameter is required', { + tip: 'Provide your Akeyless Access ID: @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY)', + }); + } + // accessKey is required + if (!objArgs.accessKey) { + throw new SchemaError('accessKey parameter is required', { + tip: 'Provide your Akeyless Access Key: @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY)', + }); + } + + pluginInstances[id] = new AkeylessPluginInstance(id); + + return { + id, + accessIdResolver: objArgs.accessId, + accessKeyResolver: objArgs.accessKey, + apiUrlResolver: objArgs.apiUrl, + pathPrefixResolver: objArgs.pathPrefix, + }; + }, + async execute({ + id, accessIdResolver, accessKeyResolver, apiUrlResolver, pathPrefixResolver, + }) { + const accessId = await accessIdResolver.resolve(); + const accessKey = await accessKeyResolver.resolve(); + const apiUrl = await apiUrlResolver?.resolve(); + const pathPrefix = await pathPrefixResolver?.resolve(); + pluginInstances[id].setAuth(accessId, accessKey, apiUrl, pathPrefix); + }, +}); + + +plugin.registerDataType({ + name: 'akeylessAccessId', + typeDescription: 'Akeyless Access ID for API Key authentication', + docs: [ + { + description: 'Akeyless API Key Authentication', + url: 'https://docs.akeyless.io/docs/api-key', + }, + ], + async validate(val): Promise { + if (typeof val !== 'string') { + throw new plugin.ERRORS.ValidationError('Must be a string'); + } + if (!val.startsWith('p-')) { + throw new plugin.ERRORS.ValidationError('Akeyless Access ID should start with "p-"', { + tip: 'API Key Access IDs start with "p-" (e.g., "p-abc123def456")', + }); + } + return true; + }, +}); + +plugin.registerDataType({ + name: 'akeylessAccessKey', + sensitive: true, + typeDescription: 'Akeyless Access Key for API Key authentication', + docs: [ + { + description: 'Akeyless API Key Authentication', + url: 'https://docs.akeyless.io/docs/api-key', + }, + ], +}); + + +plugin.registerResolverFunction({ + name: 'akeyless', + label: 'Fetch secret from Akeyless', + argsSchema: { + type: 'mixed', + arrayMinLength: 0, + arrayMaxLength: 2, + }, + process() { + let instanceId: string; + let secretNameResolver: Resolver | undefined; + let itemKey: string | undefined; + let keyResolver: Resolver | undefined; + let secretType: 'static' | 'dynamic' | 'rotated' = 'static'; + + // Check for named 'type' parameter to select secret type + if (this.objArgs?.type) { + if (!this.objArgs.type.isStatic) { + throw new SchemaError('Expected type to be a static value'); + } + const typeValue = String(this.objArgs.type.staticValue); + if (typeValue === 'static' || typeValue === 'dynamic' || typeValue === 'rotated') { + secretType = typeValue; + } else { + throw new SchemaError(`Invalid secret type "${typeValue}"`, { + tip: 'Valid types are: static, dynamic, rotated', + }); + } + } + + // Check for named 'key' parameter to extract a JSON key + if (this.objArgs?.key) { + keyResolver = this.objArgs.key; + } + + if (!this.arrArgs || this.arrArgs.length === 0) { + instanceId = '_default'; + // Use item key as secret name + const parent = (this as any).parent; + if (parent && typeof parent.key === 'string') { + itemKey = parent.key; + } else { + throw new SchemaError('When called without arguments, akeyless() must be used on a config item', { + tip: 'Either provide a secret name: akeyless("/path/to/secret") or use it on a config item', + }); + } + } else if (this.arrArgs.length === 1) { + instanceId = '_default'; + secretNameResolver = this.arrArgs[0]; + } else if (this.arrArgs.length === 2) { + if (!(this.arrArgs[0].isStatic)) { + throw new SchemaError('Expected instance id to be a static value'); + } + instanceId = String(this.arrArgs[0].staticValue); + secretNameResolver = this.arrArgs[1]; + } else { + throw new SchemaError('Expected 0, 1, or 2 args'); + } + + if (!Object.values(pluginInstances).length) { + throw new SchemaError('No Akeyless plugin instances found', { + tip: 'Initialize at least one Akeyless instance using the @initAkeyless() root decorator', + }); + } + + // Make sure instance id is valid + const selectedInstance = pluginInstances[instanceId]; + if (!selectedInstance) { + if (instanceId === '_default') { + throw new SchemaError('Akeyless plugin instance (without id) not found', { + tip: [ + 'Either remove the `id` param from your @initAkeyless call', + 'or use `akeyless(id, secretName)` to select an instance by id.', + `Available ids: ${Object.keys(pluginInstances).join(', ')}`, + ].join('\n'), + }); + } else { + throw new SchemaError(`Akeyless plugin instance id "${instanceId}" not found`, { + tip: `Available ids: ${Object.keys(pluginInstances).join(', ')}`, + }); + } + } + + return { + instanceId, secretNameResolver, itemKey, keyResolver, secretType, + }; + }, + async resolve({ + instanceId, secretNameResolver, itemKey, keyResolver, secretType, + }) { + const selectedInstance = pluginInstances[instanceId]; + + // Resolve secret name + let secretNameWithKey: string; + if (secretNameResolver) { + const resolved = await secretNameResolver.resolve(); + if (typeof resolved !== 'string') { + throw new SchemaError('Expected secret name to resolve to a string'); + } + secretNameWithKey = resolved; + } else if (itemKey) { + secretNameWithKey = itemKey; + } else { + throw new SchemaError('No secret name provided'); + } + + // Parse for explicit JSON key using # syntax + // e.g., "/MyApp/Secret#username" -> secretName="/MyApp/Secret", jsonKey="username" + let secretName: string; + let jsonKey: string | undefined; + const hashIndex = secretNameWithKey.indexOf('#'); + if (hashIndex !== -1) { + secretName = secretNameWithKey.substring(0, hashIndex); + jsonKey = secretNameWithKey.substring(hashIndex + 1); + } else { + secretName = secretNameWithKey; + } + + // Named 'key' parameter takes precedence over # syntax + if (keyResolver) { + const keyValue = await keyResolver.resolve(); + if (typeof keyValue !== 'string') { + throw new SchemaError('Expected key parameter to resolve to a string'); + } + jsonKey = keyValue; + } + + // Apply pathPrefix + const finalSecretName = selectedInstance.applyPathPrefix(secretName); + + if (secretType === 'dynamic') { + return await selectedInstance.getDynamicSecret(finalSecretName, jsonKey); + } + if (secretType === 'rotated') { + return await selectedInstance.getRotatedSecret(finalSecretName, jsonKey); + } + return await selectedInstance.getStaticSecret(finalSecretName, jsonKey); + }, +}); diff --git a/packages/plugins/akeyless/tsconfig.json b/packages/plugins/akeyless/tsconfig.json new file mode 100644 index 00000000..d69c25fa --- /dev/null +++ b/packages/plugins/akeyless/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@varlock/tsconfig/plugin.tsconfig.json", + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/akeyless/tsup.config.ts b/packages/plugins/akeyless/tsup.config.ts new file mode 100644 index 00000000..a4ffa83a --- /dev/null +++ b/packages/plugins/akeyless/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/plugin.ts'], + dts: true, + sourcemap: true, + treeshake: true, + clean: false, + outDir: 'dist', + format: ['cjs'], + splitting: false, + target: 'esnext', + external: ['varlock'], +}); diff --git a/packages/varlock-website/astro.config.ts b/packages/varlock-website/astro.config.ts index a7011174..705c0cbc 100644 --- a/packages/varlock-website/astro.config.ts +++ b/packages/varlock-website/astro.config.ts @@ -181,6 +181,7 @@ export default defineConfig({ items: [ { label: 'Overview', slug: 'plugins/overview' }, { label: '1Password', slug: 'plugins/1password' }, + { label: 'Akeyless', slug: 'plugins/akeyless' }, { label: 'AWS SSM/SM', slug: 'plugins/aws-secrets' }, { label: 'Azure Key Vault', slug: 'plugins/azure-key-vault' }, { label: 'Bitwarden', slug: 'plugins/bitwarden' }, diff --git a/packages/varlock-website/src/content/docs/plugins/akeyless.mdx b/packages/varlock-website/src/content/docs/plugins/akeyless.mdx new file mode 100644 index 00000000..a91e002a --- /dev/null +++ b/packages/varlock-website/src/content/docs/plugins/akeyless.mdx @@ -0,0 +1,322 @@ +--- +title: Akeyless Plugin +description: Using Akeyless Platform secrets with Varlock +--- + +import { Steps, Icon } from '@astrojs/starlight/components'; +import Badge from '@/components/Badge.astro'; + +
+ +
+ +Our Akeyless plugin enables secure loading of secrets from [Akeyless Platform](https://www.akeyless.io/) using declarative instructions within your `.env` files. + +The plugin uses Akeyless's REST API with API Key authentication (Access ID + Access Key) and supports static, dynamic, and rotated secret types. + +## Features + +- **API Key authentication** - Simple Access ID + Access Key authentication +- **Static secrets** - Fetch key/value secrets +- **Dynamic secrets** - Fetch on-demand generated credentials (database, cloud, etc.) +- **Rotated secrets** - Fetch auto-rotated credentials +- **JSON key extraction** from secrets using `#` syntax or named `key` parameter +- **Path prefixing** with `pathPrefix` option for organized secret management +- **Gateway support** - Use a self-hosted [Akeyless Gateway](https://docs.akeyless.io/docs/api-gateway) via custom API URL +- **Auto-infer secret name** from environment variable names +- Support for multiple Akeyless instances +- Automatic token caching and renewal +- Lightweight implementation using REST API (no SDK dependencies) + +## Installation and setup + +In a JS/TS project, you may install the `@varlock/akeyless-plugin` package as a normal dependency. +Otherwise you can just load it directly from your `.env.schema` file, as long as you add a version specifier. +See the [plugins guide](/guides/plugins/#installation) for more instructions on installing plugins. + +```env-spec title=".env.schema" +# 1. Load the plugin +# @plugin(@varlock/akeyless-plugin) +# +# 2. Initialize the plugin - see below for more details on options +# @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY) +``` + +### API Key authentication + +The plugin authenticates using an API Key consisting of an Access ID and Access Key: + + +1. **Create an API Key in Akeyless** (see Akeyless Setup section below) + +2. **Wire up the credentials in your config**. Add config items for the Access ID and Access Key, and reference them when initializing the plugin. + + ```env-spec title=".env.schema" + # @plugin(@varlock/akeyless-plugin) + # @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY) + # --- + + # @type=akeylessAccessId + AKEYLESS_ACCESS_ID= + # @type=akeylessAccessKey @sensitive + AKEYLESS_ACCESS_KEY= + ``` + +3. **Set your credentials in deployed environments**. Use your platform's env var management UI to securely inject these values. + + +### Using an Akeyless Gateway + +If you are running a self-hosted [Akeyless Gateway](https://docs.akeyless.io/docs/api-gateway), provide the gateway URL via the `apiUrl` parameter: + +```env-spec title=".env.schema" +# @initAkeyless( +# accessId=$AKEYLESS_ACCESS_ID, +# accessKey=$AKEYLESS_ACCESS_KEY, +# apiUrl="https://gateway.example.com:8080" +# ) +``` + +### Multiple instances + +If you need to connect to multiple Akeyless instances, register named instances: + +```env-spec title=".env.schema" +# @initAkeyless(id=prod, accessId=$PROD_ACCESS_ID, accessKey=$PROD_ACCESS_KEY) +# @initAkeyless(id=dev, accessId=$DEV_ACCESS_ID, accessKey=$DEV_ACCESS_KEY) +# --- + +PROD_SECRET=akeyless(prod, "/MyApp/Secret") +DEV_SECRET=akeyless(dev, "/MyApp/Secret") +``` + +## Loading secrets + +Once the plugin is installed and initialized, you can start adding config items that load values using the `akeyless()` resolver function. + +### Static secrets + +Static secrets are simple key/value pairs. This is the default secret type. + +```env-spec title=".env.schema" +# Fetch a static secret by its full path +DB_PASSWORD=akeyless("/MyApp/DB_PASSWORD") + +# Extract a JSON key from a static secret storing JSON +DB_HOST=akeyless("/MyApp/DBConfig#host") + +# Or use named key parameter +DB_PORT=akeyless("/MyApp/DBConfig", key="port") +``` + +### Path prefixing + +Use `pathPrefix` to automatically prefix all secret paths for better organization: + +```env-spec title=".env.schema" +# @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY, pathPrefix="/MyApp") +# --- + +# Fetches from "/MyApp/DB_PASSWORD" +DB_PASSWORD=akeyless("DB_PASSWORD") + +# Auto-infer also uses the prefix: fetches from "/MyApp/API_KEY" +API_KEY=akeyless() +``` + +### Dynamic secrets + +Dynamic secrets generate on-demand credentials (e.g., temporary database credentials, cloud access tokens). Use the `type=dynamic` parameter: + +```env-spec title=".env.schema" +# Fetch entire dynamic secret as JSON +DB_CREDENTIALS=akeyless("/MyApp/DynamicDBSecret", type=dynamic) + +# Extract a specific key from the dynamic secret response +DB_USER=akeyless("/MyApp/DynamicDBSecret#user", type=dynamic) +DB_PASS=akeyless("/MyApp/DynamicDBSecret#password", type=dynamic) +``` + +Multiple items that reference the same dynamic secret path are cached — only one API call is made, and each item extracts its key from the cached response. + +### Rotated secrets + +Rotated secrets are auto-rotated credentials managed by Akeyless. Use the `type=rotated` parameter: + +```env-spec title=".env.schema" +# Fetch entire rotated secret as JSON +DB_ROTATED_CREDS=akeyless("/MyApp/RotatedDBPassword", type=rotated) + +# Extract individual keys from the rotated secret +DB_USER=akeyless("/MyApp/RotatedDBPassword#user", type=rotated) +DB_PASS=akeyless("/MyApp/RotatedDBPassword#password", type=rotated) +``` + +--- + +## Akeyless Setup + +### Create an API Key + + +1. **Log in** to the [Akeyless Console](https://console.akeyless.io) + +2. **Create an Auth Method**: Go to **Auth Methods** → **New** → **API Key** + +3. **Save the credentials**: Copy the generated **Access ID** (starts with `p-`) and **Access Key** + + +### Create a static secret + +You can create secrets via the Akeyless CLI or Console: + +```bash +# Using the Akeyless CLI +akeyless create-secret --name "/MyApp/DB_PASSWORD" --value "supersecret" +``` + +Or in the Console: **Secrets & Keys** → **New** → **Static Secret** + +### Set up access permissions + + +1. Go to **Access Roles** in the Akeyless Console + +2. Create or edit a role and add rules to grant **read** access to the secrets your application needs + +3. Associate the role with your API Key auth method + + +:::tip[Least privilege principle] +Only grant access to the specific secret paths your application needs. Avoid granting broad access to all secrets. +::: + +--- + +## Reference + +### Root decorators +
+
+#### `@initAkeyless()` + +Initialize an Akeyless plugin instance. + +**Key/value args:** +- `accessId` (required): Akeyless Access ID (starts with `p-` for API Key auth) +- `accessKey` (required): Akeyless Access Key +- `apiUrl` (optional): Akeyless API URL (defaults to `https://api.akeyless.io`). Use this for self-hosted Akeyless Gateway. +- `pathPrefix` (optional): Prefix automatically prepended to all secret paths +- `id` (optional): Instance identifier for multiple instances + +```env-spec "@initAkeyless" +# @initAkeyless(accessId=$AKEYLESS_ACCESS_ID, accessKey=$AKEYLESS_ACCESS_KEY, pathPrefix="/MyApp") +``` +
+
+ +### Data types +
+
+#### `akeylessAccessId` + +Represents an Akeyless Access ID for API Key authentication. Validates that the value starts with `p-`. + +```env-spec "akeylessAccessId" +# @type=akeylessAccessId +AKEYLESS_ACCESS_ID= +``` +
+
+#### `akeylessAccessKey` + +Represents an Akeyless Access Key for API Key authentication. This type is marked as `@sensitive`. + +```env-spec "akeylessAccessKey" +# @type=akeylessAccessKey +AKEYLESS_ACCESS_KEY= +``` +
+
+ +### Resolver functions +
+
+#### `akeyless()` + +Fetch a secret from Akeyless Platform. + +**Array args:** +- `instanceId` (optional): instance identifier to use when multiple plugin instances are initialized +- `secretName` (optional): full path to the secret, optionally with `#KEY` to extract a JSON key (e.g., `"/MyApp/Secret#username"`). If omitted, uses the item key (variable name) as the secret name. + +**Named args:** +- `type` (optional): secret type — `static` (default), `dynamic`, or `rotated` +- `key` (optional): JSON key to extract from the secret value (overrides `#KEY` syntax) + +**Secret types:** +- `static` — Simple key/value secrets (default). If the value is JSON, use `#KEY` or `key=` to extract individual keys. +- `dynamic` — On-demand generated credentials (database, cloud, etc.). Returns JSON by default, or extract a specific key with `#KEY` or `key=`. +- `rotated` — Auto-rotated credentials managed by Akeyless. Returns JSON by default, or extract a specific key with `#KEY` or `key=`. + +**Caching:** Multiple items referencing the same secret path (and type) share a single API call. This is especially useful for dynamic and rotated secrets where you need to extract multiple keys from the same response. + +```env-spec /akeyless\\(.*\\)/ +# Uses item key as secret name (static) +DATABASE_URL=akeyless() + +# Explicit secret path (static) +DB_PASSWORD=akeyless("/MyApp/DB_PASSWORD") + +# Extract JSON key using # syntax +DB_HOST=akeyless("/MyApp/DBConfig#host") + +# Extract JSON key using key= parameter +DB_PORT=akeyless("/MyApp/DBConfig", key="port") + +# Dynamic secret - extract specific keys +DB_USER=akeyless("/MyApp/DynamicDB#user", type=dynamic) +DB_PASS=akeyless("/MyApp/DynamicDB#password", type=dynamic) + +# Rotated secret +API_KEY=akeyless("/MyApp/RotatedKey#api_key", type=rotated) + +# With instance ID +PROD_SECRET=akeyless(prod, "/MyApp/Secret") +``` +
+
+ +--- + +## Troubleshooting + +### Secret not found +- Verify the secret exists in the Akeyless Console +- Check the full secret path (e.g., `/MyFolder/MySecret`) +- Ensure the path starts with `/` +- If using `pathPrefix`, check the combined path is correct + +### JSON key not found +- Verify the key exists in the secret value: check the Akeyless Console for the secret's content +- Key names are case-sensitive +- For static secrets, ensure the value is valid JSON when using `#KEY` or `key=` + +### Permission denied +- Check the Access Role associated with your API Key auth method +- Ensure the role includes read permission for the secret path +- Verify the role is associated with the correct auth method + +### Authentication failed +- Verify the Access ID starts with `p-` (API Key auth) +- Ensure the Access Key matches the Access ID +- If using a Gateway, verify the `apiUrl` is correct and reachable +- Check if the auth method is active in the Akeyless Console + +## Resources + +- [Akeyless Platform](https://www.akeyless.io/) +- [Akeyless Documentation](https://docs.akeyless.io/) +- [Akeyless API Key Authentication](https://docs.akeyless.io/docs/api-key) +- [Akeyless REST API Reference](https://docs.akeyless.io/reference) +- [Akeyless Gateway](https://docs.akeyless.io/docs/api-gateway) diff --git a/packages/varlock-website/src/content/docs/plugins/overview.mdx b/packages/varlock-website/src/content/docs/plugins/overview.mdx index b26ab729..a8a1bd48 100644 --- a/packages/varlock-website/src/content/docs/plugins/overview.mdx +++ b/packages/varlock-website/src/content/docs/plugins/overview.mdx @@ -14,6 +14,7 @@ For now, only official Varlock plugins under the `@varlock` npm scope are suppor | Plugin | npm package | |--------|-------------| | [1Password](/plugins/1password/) | | +| [Akeyless](/plugins/akeyless/) | | | [AWS](/plugins/aws-secrets/)
_Secrets Manager & Parameter Store_ | | | [Azure Key Vault](/plugins/azure-key-vault/) | | | [Bitwarden](/plugins/bitwarden/) | |