azure key vault as a secret provider (bulk pull or inline azurekv:// references)
This commit is contained in:
6 files changed
+242
-2
No files matched your search
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<!-- Azure Key Vault, monochrome (currentColor). Ring + key, toned via opacity. -->
|
||||
<svg
|
||||
viewBox="0 0 18 18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={className}
|
||||
fill="currentColor"
|
||||
aria-label="Azure Key Vault"
|
||||
>
|
||||
<path
|
||||
d="M9,.5A8.5,8.5,0,1,0,17.5,9,8.51,8.51,0,0,0,9,.5ZM9,16.34A7.34,7.34,0,1,1,16.34,9,7.34,7.34,0,0,1,9,16.34Z"
|
||||
style="opacity:.55"
|
||||
/>
|
||||
<path
|
||||
d="M13.44,7.33a1.84,1.84,0,0,0,0-2.59h0L10.29,1.58a1.83,1.83,0,0,0-2.58,0h0L4.56,4.74a1.84,1.84,0,0,0,0,2.59L7.18,10a.51.51,0,0,1,.15.36v4.88a.63.63,0,0,0,.18.44l1.2,1.2a.41.41,0,0,0,.58,0l1.16-1.16h0l.68-.68a.25.25,0,0,0,0-.34l-.49-.49a.27.27,0,0,1,0-.37l.49-.49a.25.25,0,0,0,0-.34l-.49-.49a.27.27,0,0,1,0-.37l.49-.49a.25.25,0,0,0,0-.34l-.68-.69v-.25ZM9,2.35A1,1,0,0,1,9,4.42,1,1,0,1,1,9,2.35Z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -6,6 +6,7 @@ import HashiCorpVault from './HashiCorpVault.svelte';
|
||||
import Doppler from './Doppler.svelte';
|
||||
import Bitwarden from './Bitwarden.svelte';
|
||||
import ProtonPass from './ProtonPass.svelte';
|
||||
import AzureKeyVault from './AzureKeyVault.svelte';
|
||||
|
||||
/** Brand icon for a secret-provider type. Falls back to a generic key icon. */
|
||||
export function getProviderIcon(type: string): Component {
|
||||
@@ -23,6 +24,8 @@ export function getProviderIcon(type: string): Component {
|
||||
return Bitwarden;
|
||||
case 'proton':
|
||||
return ProtonPass;
|
||||
case 'azure-kv':
|
||||
return AzureKeyVault;
|
||||
default:
|
||||
return KeyRound; // any future provider until it has a brand icon
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Azure Key Vault provider.
|
||||
*
|
||||
* Authenticates a service principal (app registration) via OAuth2
|
||||
* client-credentials against Azure AD, then reads secrets over the Key Vault
|
||||
* REST API. Two resolution modes:
|
||||
* - Bulk pull: every secret in the vault, keyed by secret name.
|
||||
* - Inline references: `azurekv://<secret-name>`, one secret at a time.
|
||||
*
|
||||
* Pure HTTP (undici) - no Azure SDK. The client secret stays in the (encrypted)
|
||||
* config; the short-lived bearer token is cached in memory per service principal.
|
||||
*/
|
||||
|
||||
import { request } from 'undici';
|
||||
import type { AzureKvConfig, SecretProvider, TestConnectionResult } from './shared';
|
||||
import { assertSafeProviderHost, isJsonResponse } from './shared';
|
||||
|
||||
const API_VERSION = '7.4';
|
||||
const AZURE_REF_PREFIX = 'azurekv://';
|
||||
// Key Vault secret names are 1-127 chars, alphanumeric and dashes only.
|
||||
const AZURE_REF_RE = /^azurekv:\/\/[A-Za-z0-9-]{1,127}$/;
|
||||
|
||||
/** Strip a trailing slash from the vault URI so path joins are clean. */
|
||||
function baseUrl(config: AzureKvConfig): string {
|
||||
return config.vaultUri.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** The secret name from an `azurekv://<name>` reference. */
|
||||
function refName(ref: string): string {
|
||||
return ref.slice(AZURE_REF_PREFIX.length);
|
||||
}
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
/** Epoch ms after which the cached token is treated as expired. */
|
||||
expiresAt: number;
|
||||
}
|
||||
// Keyed by tenant + client + vault so two providers never share a token.
|
||||
const tokenCache = new Map<string, CachedToken>();
|
||||
const TOKEN_REFRESH_MARGIN_MS = 60_000;
|
||||
|
||||
function tokenCacheKey(config: AzureKvConfig): string {
|
||||
return `${config.tenantId}::${config.clientId}::${baseUrl(config)}`;
|
||||
}
|
||||
|
||||
/** Clear the AD token cache (called on provider config change / deletion). */
|
||||
export function clearAzureKvTokenCache(): void {
|
||||
tokenCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an AD access token for the Key Vault scope via client-credentials.
|
||||
* Cached until shortly before expiry. Throws on auth failure.
|
||||
*/
|
||||
async function getAccessToken(config: AzureKvConfig): Promise<string> {
|
||||
const key = tokenCacheKey(config);
|
||||
const now = Date.now();
|
||||
const hit = tokenCache.get(key);
|
||||
if (hit && hit.expiresAt > now) return hit.token;
|
||||
|
||||
// Azure AD lives on login.microsoftonline.com - a fixed, public host. The
|
||||
// vaultUri IS user-controlled, so it is SSRF-guarded before any request.
|
||||
const tokenUrl = `https://login.microsoftonline.com/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token`;
|
||||
const form = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
scope: 'https://vault.azure.net/.default'
|
||||
});
|
||||
|
||||
const { statusCode, body } = await request(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString()
|
||||
});
|
||||
const text = await body.text();
|
||||
if (statusCode < 200 || statusCode >= 300 || !isJsonResponse(text)) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = ((JSON.parse(text) as { error_description?: string }).error_description ?? '')
|
||||
.split('\n')[0]
|
||||
?.trim() ?? '';
|
||||
} catch {
|
||||
// non-JSON body; leave detail empty
|
||||
}
|
||||
throw new Error(
|
||||
`Azure Key Vault: authentication failed (HTTP ${statusCode}${detail ? `: ${detail}` : ''})`
|
||||
);
|
||||
}
|
||||
const data = JSON.parse(text) as { access_token?: string; expires_in?: number };
|
||||
if (!data.access_token) throw new Error('Azure Key Vault: no access token returned');
|
||||
|
||||
const ttlMs = (typeof data.expires_in === 'number' ? data.expires_in : 3600) * 1000;
|
||||
tokenCache.set(key, { token: data.access_token, expiresAt: now + ttlMs - TOKEN_REFRESH_MARGIN_MS });
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** Read one secret's value by name. Returns null when the secret is absent. */
|
||||
async function readSecret(config: AzureKvConfig, token: string, name: string): Promise<string | null> {
|
||||
const { statusCode, body } = await request(
|
||||
`${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`,
|
||||
{ method: 'GET', headers: { authorization: `Bearer ${token}` } }
|
||||
);
|
||||
const text = await body.text();
|
||||
if (statusCode === 404) return null;
|
||||
if (statusCode < 200 || statusCode >= 300 || !isJsonResponse(text)) {
|
||||
throw new Error(`Azure Key Vault: failed to read secret "${name}" (HTTP ${statusCode})`);
|
||||
}
|
||||
const data = JSON.parse(text) as { value?: string };
|
||||
return data.value ?? null;
|
||||
}
|
||||
|
||||
/** List every secret name in the vault (paginated), capped for safety. */
|
||||
async function listSecretNames(config: AzureKvConfig, token: string): Promise<string[]> {
|
||||
const names: string[] = [];
|
||||
let url: string | null = `${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=25`;
|
||||
while (url && names.length < 500) {
|
||||
const { statusCode, body } = await request(url, {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
const text = await body.text();
|
||||
if (statusCode < 200 || statusCode >= 300 || !isJsonResponse(text)) {
|
||||
throw new Error(`Azure Key Vault: cannot list secrets (HTTP ${statusCode})`);
|
||||
}
|
||||
const data = JSON.parse(text) as { value?: { id: string }[]; nextLink?: string | null };
|
||||
for (const item of data.value ?? []) {
|
||||
const name = item.id.split('/').pop();
|
||||
if (name) names.push(name);
|
||||
}
|
||||
url = data.nextLink ?? null;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export const azureKvProvider: SecretProvider<AzureKvConfig> = {
|
||||
type: 'azure-kv',
|
||||
label: 'Azure Key Vault',
|
||||
supportsReferences: true,
|
||||
supportsBulk: true,
|
||||
|
||||
isReference(value: unknown): value is string {
|
||||
return typeof value === 'string' && AZURE_REF_RE.test(value.trim());
|
||||
},
|
||||
|
||||
async testConnection(config: AzureKvConfig): Promise<TestConnectionResult> {
|
||||
try {
|
||||
assertSafeProviderHost(config.vaultUri, 'Azure Key Vault');
|
||||
const token = await getAccessToken(config);
|
||||
// A list call proves both auth AND that the vault URI is reachable/authorized.
|
||||
await listSecretNames(config, token);
|
||||
return { ok: true };
|
||||
} catch (e: unknown) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
},
|
||||
|
||||
async resolveSecretReferences(
|
||||
config: AzureKvConfig,
|
||||
refs: string[],
|
||||
logPrefix = '[Azure Key Vault]'
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
if (refs.length === 0) return result;
|
||||
assertSafeProviderHost(config.vaultUri, 'Azure Key Vault');
|
||||
const token = await getAccessToken(config);
|
||||
// Unique names only - two refs to the same secret cost one call.
|
||||
const names = new Set(refs.map(refName));
|
||||
const values = new Map<string, string>();
|
||||
await Promise.all(
|
||||
[...names].map(async (name) => {
|
||||
try {
|
||||
const v = await readSecret(config, token, name);
|
||||
if (v !== null) values.set(name, v);
|
||||
else console.warn(`${logPrefix} Skipping azurekv://${name}: secret not found`);
|
||||
} catch (e) {
|
||||
console.warn(`${logPrefix} Skipping azurekv://${name}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
for (const ref of refs) {
|
||||
const v = values.get(refName(ref));
|
||||
if (v !== undefined) result.set(ref, v);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// The selector is ignored: a Key Vault has no sub-paths, so a bulk pull is
|
||||
// always "every secret in the vault".
|
||||
async resolveBulk(config: AzureKvConfig, _selector: string): Promise<Record<string, string>> {
|
||||
assertSafeProviderHost(config.vaultUri, 'Azure Key Vault');
|
||||
const token = await getAccessToken(config);
|
||||
const names = await listSecretNames(config, token);
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const v = await readSecret(config, token, name);
|
||||
if (v !== null) result[name] = v;
|
||||
})
|
||||
);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import { vaultProvider } from './vault';
|
||||
import { dopplerProvider } from './doppler';
|
||||
import { bitwardenProvider } from './bitwarden';
|
||||
import { protonProvider } from './proton';
|
||||
import { azureKvProvider } from './azure-kv';
|
||||
|
||||
// Registered providers. Adding a new backend means dropping a file in this
|
||||
// directory and registering it here; each implements the SecretProvider
|
||||
@@ -31,7 +32,8 @@ const providers: Record<string, SecretProvider> = {
|
||||
[vaultProvider.type]: vaultProvider as SecretProvider,
|
||||
[dopplerProvider.type]: dopplerProvider as SecretProvider,
|
||||
[bitwardenProvider.type]: bitwardenProvider as SecretProvider,
|
||||
[protonProvider.type]: protonProvider as SecretProvider
|
||||
[protonProvider.type]: protonProvider as SecretProvider,
|
||||
[azureKvProvider.type]: azureKvProvider as SecretProvider
|
||||
};
|
||||
|
||||
/** Returns the provider for a stored type, or undefined if unknown. */
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ export interface NetworkInfo {
|
||||
export interface StackInfo {
|
||||
name: string;
|
||||
services: string[];
|
||||
status: 'running' | 'partial' | 'stopped';
|
||||
status: 'running' | 'partial' | 'restarting' | 'stopped';
|
||||
containers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
{ value: 'doppler', label: 'Doppler' },
|
||||
{ value: 'bitwarden', label: 'Bitwarden Secrets Manager' },
|
||||
{ value: 'proton', label: 'Proton Pass' },
|
||||
{ value: 'azure-kv', label: 'Azure Key Vault' },
|
||||
];
|
||||
|
||||
// Config fields per provider type, matching the config shapes in
|
||||
@@ -71,6 +72,12 @@
|
||||
proton: [
|
||||
{ key: 'token', label: 'Personal access token', type: 'password', required: true, placeholder: 'pst_...::...', hint: 'A Proton Pass personal access token (pst_...) used by the operator-installed pass-cli.' },
|
||||
],
|
||||
'azure-kv': [
|
||||
{ key: 'vaultUri', label: 'Vault URI', type: 'text', required: true, placeholder: 'https://my-vault.vault.azure.net', hint: 'The Key Vault URI (from the vault Overview page).' },
|
||||
{ key: 'tenantId', label: 'Tenant ID', type: 'text', required: true, placeholder: 'directory (tenant) ID', hint: 'Azure AD tenant (directory) ID of the app registration.' },
|
||||
{ key: 'clientId', label: 'Client ID', type: 'text', required: true, placeholder: 'application (client) ID', hint: 'The service-principal (app registration) client ID.' },
|
||||
{ key: 'clientSecret', label: 'Client secret', type: 'password', required: true, placeholder: 'app registration client secret', hint: 'A client secret for the app registration, with Get/List secret permission on the vault.' },
|
||||
],
|
||||
};
|
||||
|
||||
export function providerTypeLabel(type: string): string {
|
||||
@@ -107,6 +114,11 @@
|
||||
label: 'Vault',
|
||||
placeholder: 'Proton Pass vault name',
|
||||
hint: 'Bulk-load every item from this Proton Pass vault. Leave blank to inject only inline pass:// references.'
|
||||
},
|
||||
'azure-kv': {
|
||||
label: 'Key Vault',
|
||||
placeholder: 'any value enables bulk pull',
|
||||
hint: 'Bulk-load every secret in the vault. Set any value to enable it; leave blank to inject only inline azurekv:// references.'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in new issue
Block a user