mirror of
https://github.com/hanzoai/helper.git
synced 2026-09-30 22:42:13 +00:00
kms: use the canonical live KMS API, drop invented endpoints
KMS already serves the one-way secret surface (hanzoai/kms embed.go): the dev's
IAM login token goes straight to /v1/kms/orgs/{org}/secrets — KMS verifies it
against hanzo.id JWKS (authorize + canActOnOrg). No /v1/kms/oidc/login or
/v1/kms/get-secrets exchange (those were invented; they 404). One token, one way.
- lib/kms.ts: listSecrets + getSecret + pullSecrets over the real endpoints.
Respects KMS's security boundary — list returns KEYS ONLY; values are read
one-by-one (bounded concurrency), no bulk-value dump.
- commands/kms.ts: org comes from the login session (--org override); IAM token
from config.accessToken. pull/list unchanged in UX.
- actions/kms-secrets: same canonical list-then-get; token input is an
IAM-issued token KMS trusts (GitHub OIDC only if KMS trusts that issuer).
- npm-release.yml: pass org=hanzo + KMS_IAM_TOKEN.
Verified: GET /v1/kms/orgs/hanzo/secrets?env=devnet is LIVE (401, gated) on
api.hanzo.ai — endpoint exists, needs a real IAM token to list.
This commit is contained in:
@@ -1,27 +1,28 @@
|
||||
name: Load KMS secrets
|
||||
description: >
|
||||
Load environment secrets from KMS into the job, via the one OIDC mechanism
|
||||
shared with local dev (the `kms` CLI). GitHub mints an OIDC token; KMS
|
||||
exchanges it at /v1/kms/oidc/login and returns the env's secrets from
|
||||
/v1/kms/get-secrets. No long-lived KMS credential in GitHub.
|
||||
Load an org/env's secrets from KMS into the job, using the canonical live KMS
|
||||
API (/v1/kms/orgs/{org}/secrets). The token is an IAM-issued OIDC token KMS
|
||||
verifies against hanzo.id's JWKS — the same trust the `kms` CLI uses for local
|
||||
dev. KMS lists keys, then reads each value (no bulk-value endpoint by design).
|
||||
|
||||
inputs:
|
||||
org:
|
||||
description: Organization slug (KMS scopes secrets by org).
|
||||
required: true
|
||||
env:
|
||||
description: "Environment slug: devnet | testnet | mainnet | production."
|
||||
required: false
|
||||
default: production
|
||||
path:
|
||||
description: Secret path.
|
||||
required: false
|
||||
default: /
|
||||
token:
|
||||
description: >
|
||||
IAM-issued OIDC/access token KMS trusts (hanzo.id issuer). Required —
|
||||
the GitHub Actions OIDC token is only accepted if KMS is configured to
|
||||
trust the GitHub issuer; otherwise pass an IAM machine-identity token.
|
||||
required: true
|
||||
base-url:
|
||||
description: Gateway base that serves /v1/kms (KMS owns this namespace).
|
||||
required: false
|
||||
default: https://api.hanzo.ai
|
||||
audience:
|
||||
description: OIDC audience the GitHub token is minted for.
|
||||
required: false
|
||||
default: kms.hanzo.ai
|
||||
export:
|
||||
description: "Where to put secrets: 'env' (job environment) or 'file'."
|
||||
required: false
|
||||
@@ -37,42 +38,34 @@ runs:
|
||||
- name: Load secrets from KMS
|
||||
shell: bash
|
||||
env:
|
||||
KMS_ORG: ${{ inputs.org }}
|
||||
KMS_ENV: ${{ inputs.env }}
|
||||
KMS_PATH: ${{ inputs.path }}
|
||||
KMS_TOKEN: ${{ inputs.token }}
|
||||
KMS_BASE: ${{ inputs.base-url }}
|
||||
KMS_AUDIENCE: ${{ inputs.audience }}
|
||||
KMS_EXPORT: ${{ inputs.export }}
|
||||
KMS_FILE: ${{ inputs.file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
secrets_url="${KMS_BASE}/v1/kms/orgs/${KMS_ORG}/secrets"
|
||||
auth=(-H "Authorization: Bearer ${KMS_TOKEN}" -H "Accept: application/json")
|
||||
|
||||
# 1. GitHub OIDC token (requires permissions: id-token: write).
|
||||
oidc=$(curl -sf "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${KMS_AUDIENCE}" \
|
||||
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" | jq -r '.value')
|
||||
[ -n "$oidc" ] && [ "$oidc" != "null" ] || { echo "::error::no GitHub OIDC token (need id-token: write)"; exit 1; }
|
||||
# 1. List the env's secret keys (metadata only — no values).
|
||||
names=$(curl -sf "${secrets_url}?env=${KMS_ENV}" "${auth[@]}" \
|
||||
| jq -r '.secrets[] | [.path, .name] | @tsv')
|
||||
|
||||
# 2. Exchange it for a KMS token — KMS owns /v1/kms.
|
||||
kms_token=$(curl -sf -X POST "${KMS_BASE}/v1/kms/oidc/login" \
|
||||
-H "Authorization: Bearer ${oidc}" -H "Accept: application/json" \
|
||||
| jq -r '.token // .accessToken')
|
||||
[ -n "$kms_token" ] && [ "$kms_token" != "null" ] || { echo "::error::KMS login failed"; exit 1; }
|
||||
|
||||
# 3. Read the env's secrets.
|
||||
resp=$(curl -sf "${KMS_BASE}/v1/kms/get-secrets?env=${KMS_ENV}&path=${KMS_PATH}" \
|
||||
-H "Authorization: Bearer ${kms_token}" -H "Accept: application/json")
|
||||
|
||||
# Flatten {KEY:val} or [{secretKey,secretValue}] → KEY=val lines.
|
||||
flatten='if (.secrets|type)=="array" then (.secrets[]|"\(.secretKey // .key)=\(.secretValue // .value)") else (.secrets|to_entries[]|"\(.key)=\(.value)") end'
|
||||
|
||||
if [ "${KMS_EXPORT}" = "file" ]; then
|
||||
echo "$resp" | jq -r "$flatten" > "${KMS_FILE}"
|
||||
echo "Wrote $(wc -l < "${KMS_FILE}") secrets to ${KMS_FILE} (${KMS_ENV})"
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
key=${line%%=*}; val=${line#*=}
|
||||
[ "${KMS_EXPORT}" = "file" ] && : > "${KMS_FILE}"
|
||||
count=0
|
||||
# 2. Read each value; export or write it.
|
||||
while IFS=$'\t' read -r path name; do
|
||||
[ -z "$name" ] && continue
|
||||
rest="$name"; [ -n "$path" ] && rest="${path}/${name}"
|
||||
val=$(curl -sf "${secrets_url}/${rest}?env=${KMS_ENV}" "${auth[@]}" | jq -r '.secret.value')
|
||||
if [ "${KMS_EXPORT}" = "file" ]; then
|
||||
echo "${name}=${val}" >> "${KMS_FILE}"
|
||||
else
|
||||
echo "::add-mask::${val}"
|
||||
echo "${key}=${val}" >> "$GITHUB_ENV"
|
||||
done < <(echo "$resp" | jq -r "$flatten")
|
||||
echo "Loaded $(echo "$resp" | jq -r "$flatten" | grep -c '=') secrets into job env (${KMS_ENV})"
|
||||
fi
|
||||
echo "${name}=${val}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
count=$((count+1))
|
||||
done <<< "$names"
|
||||
echo "Loaded ${count} secrets (${KMS_ORG}/${KMS_ENV})"
|
||||
|
||||
@@ -56,11 +56,13 @@ jobs:
|
||||
- if: ${{ inputs.build }}
|
||||
run: pnpm typecheck && pnpm lint && pnpm build
|
||||
|
||||
# NPM_TOKEN → job env, via KMS over the shared OIDC mechanism.
|
||||
# NPM_TOKEN → job env, from KMS via the canonical /v1/kms API. The token
|
||||
# is an IAM machine-identity token KMS trusts (KMS_IAM_TOKEN secret).
|
||||
- uses: hanzoai/helper/.github/actions/kms-secrets@main
|
||||
with:
|
||||
org: hanzo
|
||||
env: ${{ inputs.kms-env }}
|
||||
path: /npm
|
||||
token: ${{ secrets.KMS_IAM_TOKEN }}
|
||||
|
||||
- name: Publish
|
||||
run: npm publish --access public
|
||||
|
||||
+33
-20
@@ -2,11 +2,12 @@
|
||||
* `hanzo kms` (also installed as the standalone `kms` command) — pull
|
||||
* environment secrets from KMS for local dev, using your existing IAM login.
|
||||
*
|
||||
* kms pull [--env devnet] [--path /] [--out .env] write a dotenv file
|
||||
* kms list [--env devnet] [--path /] names only (no values)
|
||||
* kms pull [--env devnet] [--org <org>] [--out .env] write a dotenv file
|
||||
* kms list [--env devnet] [--org <org>] names only (no values)
|
||||
*
|
||||
* Auth is your `hanzo login` session — one login, one token store. KMS decides
|
||||
* what you may read per env; this is just the client.
|
||||
* Auth is your `hanzo login` session — one login, one token store. Your org
|
||||
* comes from that login (override with --org). KMS decides what you may read
|
||||
* per env; this is just the client.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
@@ -14,7 +15,7 @@ import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import fs from 'node:fs';
|
||||
import { getConfig } from '../lib/config';
|
||||
import { pullSecrets, KMS_ENVS, KMS_PROD_ENVS, KmsError } from '../lib/kms';
|
||||
import { listSecrets, pullSecrets, KMS_ENVS, KMS_PROD_ENVS, KmsError } from '../lib/kms';
|
||||
|
||||
const DEFAULT_ENV = 'devnet';
|
||||
|
||||
@@ -25,10 +26,11 @@ kmsCmd
|
||||
.command('pull')
|
||||
.description('Fetch secrets for an environment and write a dotenv file')
|
||||
.option('--env <env>', `Environment: ${KMS_ENVS.join(', ')}`, DEFAULT_ENV)
|
||||
.option('--path <path>', 'Secret path', '/')
|
||||
.option('--org <org>', 'Organization (defaults to your signed-in org)')
|
||||
.option('--out <file>', 'Output dotenv file (use "-" for stdout)', '.env')
|
||||
.action(async (opts: { env: string; path: string; out: string }) => {
|
||||
const secrets = await fetchOrExit(opts.env, opts.path);
|
||||
.action(async (opts: { env: string; org?: string; out: string }) => {
|
||||
const { token, org } = await session(opts.org);
|
||||
const secrets = await run(`Reading ${opts.env} secrets…`, () => pullSecrets(token, org, opts.env));
|
||||
const body = toDotenv(secrets);
|
||||
|
||||
if (opts.out === '-') {
|
||||
@@ -39,31 +41,42 @@ kmsCmd
|
||||
console.log(chalk.yellow(` ! ${opts.env} secrets — handle with care; do not commit ${opts.out}.`));
|
||||
}
|
||||
fs.writeFileSync(opts.out, body, { mode: 0o600 });
|
||||
console.log(chalk.green(` ✓ Wrote ${Object.keys(secrets).length} secrets to ${opts.out} (${opts.env})`));
|
||||
console.log(chalk.green(` ✓ Wrote ${Object.keys(secrets).length} secrets to ${opts.out} (${org}/${opts.env})`));
|
||||
});
|
||||
|
||||
kmsCmd
|
||||
.command('list')
|
||||
.description('List secret names for an environment (no values)')
|
||||
.option('--env <env>', `Environment: ${KMS_ENVS.join(', ')}`, DEFAULT_ENV)
|
||||
.option('--path <path>', 'Secret path', '/')
|
||||
.action(async (opts: { env: string; path: string }) => {
|
||||
const secrets = await fetchOrExit(opts.env, opts.path);
|
||||
for (const k of Object.keys(secrets).sort()) console.log(` ${k}`);
|
||||
console.log(chalk.dim(`\n ${Object.keys(secrets).length} secrets in ${opts.env}`));
|
||||
.option('--org <org>', 'Organization (defaults to your signed-in org)')
|
||||
.action(async (opts: { env: string; org?: string }) => {
|
||||
const { token, org } = await session(opts.org);
|
||||
const metas = await run(`Listing ${opts.env} secrets…`, () => listSecrets(token, org, opts.env));
|
||||
for (const m of metas.map((s) => s.name).sort()) console.log(` ${m}`);
|
||||
console.log(chalk.dim(`\n ${metas.length} secrets in ${org}/${opts.env}`));
|
||||
});
|
||||
|
||||
async function fetchOrExit(env: string, path: string): Promise<Record<string, string>> {
|
||||
const { accessToken } = await getConfig();
|
||||
if (!accessToken) {
|
||||
/** Resolve the IAM token + org from the login session (org overridable). */
|
||||
async function session(orgOverride?: string): Promise<{ token: string; org: string }> {
|
||||
const cfg = await getConfig();
|
||||
if (!cfg.accessToken) {
|
||||
console.error(chalk.red('Not signed in. Run `hanzo login` first.'));
|
||||
process.exit(1);
|
||||
}
|
||||
const spinner = ora(`Reading ${env} secrets…`).start();
|
||||
const org = orgOverride ?? cfg.user?.org;
|
||||
if (!org) {
|
||||
console.error(chalk.red('No organization found. Pass --org <org> or run `hanzo login` again.'));
|
||||
process.exit(1);
|
||||
}
|
||||
return { token: cfg.accessToken, org };
|
||||
}
|
||||
|
||||
async function run<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
const spinner = ora(label).start();
|
||||
try {
|
||||
const secrets = await pullSecrets(accessToken, env, path);
|
||||
const result = await fn();
|
||||
spinner.stop();
|
||||
return secrets;
|
||||
return result;
|
||||
} catch (err) {
|
||||
spinner.fail(chalk.red(err instanceof KmsError || err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
|
||||
+68
-76
@@ -1,96 +1,88 @@
|
||||
/**
|
||||
* KMS client — pull environment secrets for local dev.
|
||||
*
|
||||
* One mechanism, shared with CI: an OIDC token (the dev's IAM login here; a
|
||||
* GitHub OIDC token in Actions) is exchanged at /v1/kms/oidc/login for a
|
||||
* short-lived KMS token, which reads an environment's secrets from
|
||||
* /v1/kms/get-secrets. KMS owns secrets, envs, and authz; IAM only issues the
|
||||
* token. They compose through the token — neither imports the other.
|
||||
* Uses the canonical, already-live KMS API (KMS owns /v1/kms/*): the dev's IAM
|
||||
* login token is presented directly — KMS verifies it against hanzo.id's JWKS
|
||||
* (authorize + canActOnOrg), no separate exchange. One token, one way.
|
||||
*
|
||||
* Concerns stay in their own path namespace: KMS lives under `/v1/kms/*`, IAM
|
||||
* under `/v1/iam/*`. The helper never crosses them.
|
||||
* list GET /v1/kms/orgs/{org}/secrets?env=X → key metadata (no values)
|
||||
* get GET /v1/kms/orgs/{org}/secrets/{path}/{name}?env=X → one value
|
||||
*
|
||||
* By KMS's design the bulk list returns KEYS ONLY; values are read one at a
|
||||
* time. `pullSecrets` composes the two: list the env, then read each value.
|
||||
* IAM lives under /v1/iam/*, KMS under /v1/kms/* — the helper never crosses them.
|
||||
*/
|
||||
|
||||
import { endpoints } from './endpoints';
|
||||
|
||||
/** KMS surface — KMS only, under its own `/v1/kms` namespace. Verb-noun names
|
||||
* match the IAM house style (`/v1/iam/get-account`, `/v1/iam/get-users`). */
|
||||
const KMS_PATHS = {
|
||||
oidcLogin: '/v1/kms/oidc/login',
|
||||
getSecrets: '/v1/kms/get-secrets',
|
||||
} as const;
|
||||
|
||||
/** Environments secrets are scoped by — the network/deploy axis. */
|
||||
export const KMS_ENVS = ['devnet', 'testnet', 'mainnet', 'production'] as const;
|
||||
export type KmsEnv = (typeof KMS_ENVS)[number];
|
||||
|
||||
/** Environments that may hold production material — gated server-side; named so
|
||||
* the CLI can warn before writing them to a local file. */
|
||||
/** Environments that may hold production material — named so the CLI can warn
|
||||
* before writing them to a local file (KMS still gates access server-side). */
|
||||
export const KMS_PROD_ENVS: ReadonlySet<string> = new Set(['mainnet', 'production']);
|
||||
|
||||
/**
|
||||
* Exchange a trusted OIDC token (the dev's IAM access token) for a short-lived
|
||||
* KMS token. KMS validates it against the issuer's JWKS and applies its own
|
||||
* env/path authorization.
|
||||
*/
|
||||
export async function kmsLogin(oidcToken: string): Promise<string> {
|
||||
const res = await fetch(`${endpoints.api}${KMS_PATHS.oidcLogin}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${oidcToken}`, Accept: 'application/json' },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new KmsError('KMS rejected your login. Run `hanzo login` to refresh, then retry.');
|
||||
}
|
||||
if (!res.ok) throw new KmsError(`KMS login failed (${res.status} ${res.statusText})`);
|
||||
const body = (await res.json().catch(() => null)) as { token?: string; accessToken?: string } | null;
|
||||
const token = body?.token ?? body?.accessToken;
|
||||
if (!token) throw new KmsError('KMS did not return a token');
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Fetch every secret for one environment as a flat key→value map. */
|
||||
export async function kmsSecrets(kmsToken: string, env: string, path = '/'): Promise<Record<string, string>> {
|
||||
const url = new URL(`${endpoints.api}${KMS_PATHS.getSecrets}`);
|
||||
url.searchParams.set('env', env);
|
||||
if (path && path !== '/') url.searchParams.set('path', path);
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${kmsToken}`, Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new KmsError(`KMS secrets fetch failed (${res.status} ${res.statusText})`);
|
||||
const body = (await res.json().catch(() => null)) as { secrets?: unknown } | null;
|
||||
return normalizeSecrets(body?.secrets);
|
||||
}
|
||||
|
||||
/** Login + fetch in one step — the common path. */
|
||||
export async function pullSecrets(
|
||||
oidcToken: string,
|
||||
env: string,
|
||||
path = '/'
|
||||
): Promise<Record<string, string>> {
|
||||
return kmsSecrets(await kmsLogin(oidcToken), env, path);
|
||||
}
|
||||
|
||||
export class KmsError extends Error {}
|
||||
|
||||
/** One metadata row from the list endpoint — keys only, never a value. */
|
||||
interface SecretMeta {
|
||||
path: string;
|
||||
name: string;
|
||||
env: string;
|
||||
}
|
||||
|
||||
const base = (org: string) => `${endpoints.api}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`;
|
||||
|
||||
const authFail = (res: Response, what: string): KmsError =>
|
||||
res.status === 401 || res.status === 403
|
||||
? new KmsError(`KMS denied ${what} (${res.status}). Run \`hanzo login\` to refresh, or check your org access.`)
|
||||
: new KmsError(`KMS ${what} failed (${res.status} ${res.statusText})`);
|
||||
|
||||
/** List an env's secret keys (no values) for the org. */
|
||||
export async function listSecrets(iamToken: string, org: string, env: string): Promise<SecretMeta[]> {
|
||||
const url = new URL(base(org));
|
||||
url.searchParams.set('env', env);
|
||||
const res = await fetch(url, { headers: auth(iamToken) });
|
||||
if (!res.ok) throw authFail(res, 'secret listing');
|
||||
const body = (await res.json().catch(() => null)) as { secrets?: SecretMeta[] } | null;
|
||||
return body?.secrets ?? [];
|
||||
}
|
||||
|
||||
/** Read one secret's value. `path` may be empty; `name` is required. */
|
||||
export async function getSecret(
|
||||
iamToken: string,
|
||||
org: string,
|
||||
meta: SecretMeta,
|
||||
env: string
|
||||
): Promise<string> {
|
||||
const rest = meta.path ? `${meta.path}/${meta.name}` : meta.name;
|
||||
const url = new URL(`${base(org)}/${rest}`);
|
||||
url.searchParams.set('env', env);
|
||||
const res = await fetch(url, { headers: auth(iamToken) });
|
||||
if (!res.ok) throw authFail(res, `read ${meta.name}`);
|
||||
const body = (await res.json().catch(() => null)) as { secret?: { value?: string } } | null;
|
||||
return body?.secret?.value ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept either shape KMS may return — a flat `{KEY: value}` map or an array of
|
||||
* `{secretKey, secretValue}` (Infisical-style) — and flatten to `{KEY: value}`.
|
||||
* Pull every secret for one environment as a flat name→value map. Lists the
|
||||
* env's keys, then reads each value (KMS has no bulk-value endpoint by design).
|
||||
* Reads run with bounded concurrency so a large env stays quick without
|
||||
* hammering the service.
|
||||
*/
|
||||
function normalizeSecrets(secrets: unknown): Record<string, string> {
|
||||
if (!secrets) return {};
|
||||
if (Array.isArray(secrets)) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const s of secrets as Array<Record<string, unknown>>) {
|
||||
const k = (s.secretKey ?? s.key ?? s.name) as string | undefined;
|
||||
const v = (s.secretValue ?? s.value) as string | undefined;
|
||||
if (k != null && v != null) out[k] = String(v);
|
||||
}
|
||||
return out;
|
||||
export async function pullSecrets(iamToken: string, org: string, env: string): Promise<Record<string, string>> {
|
||||
const metas = await listSecrets(iamToken, org, env);
|
||||
const out: Record<string, string> = {};
|
||||
const CONCURRENCY = 8;
|
||||
for (let i = 0; i < metas.length; i += CONCURRENCY) {
|
||||
const batch = metas.slice(i, i + CONCURRENCY);
|
||||
const values = await Promise.all(batch.map((m) => getSecret(iamToken, org, m, env)));
|
||||
batch.forEach((m, j) => (out[m.name] = values[j]!));
|
||||
}
|
||||
if (typeof secrets === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(secrets as Record<string, unknown>).map(([k, v]) => [k, String(v)])
|
||||
);
|
||||
}
|
||||
return {};
|
||||
return out;
|
||||
}
|
||||
|
||||
function auth(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}`, Accept: 'application/json' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user