Infisical: the project ID is optional when using a service token - it's taken from the token
This commit is contained in:
3 files changed
+60
-20
No files matched your search
@@ -43,20 +43,42 @@ function baseUrl(host: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the /api/v3/secrets/raw URL for a workspace/environment/path. The
|
||||
* secret path defaults to `/` (Infisical's root) when nothing is configured.
|
||||
* A service token (`st.<id>.<secret>`) carries its own project, environment and
|
||||
* secretPath (single non-glob scope), so `/secrets/raw` infers them from the token
|
||||
* and workspaceId/environment are optional. Every other auth shape (Universal Auth
|
||||
* access token, static non-`st.` token) requires workspaceId.
|
||||
*/
|
||||
function isServiceToken(token: string | undefined): boolean {
|
||||
return !!token && token.trim().startsWith('st.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Infisical infers project + environment from a service token only when its scope is
|
||||
* single and non-glob. A multi-scope or glob-path token still needs an explicit
|
||||
* workspaceId, and Infisical answers a bare 400 - so when a service-token config with no
|
||||
* projectId gets a 4xx, point the user at the real cause.
|
||||
*/
|
||||
function multiScopeHint(statusCode: number, config: InfisicalConfig): string {
|
||||
if (statusCode === 400 && isServiceToken(config.token) && !config.projectId?.trim()) {
|
||||
return ' - a multi-scope or glob-path service token still needs a Project ID; set it in the provider config';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the /api/v3/secrets/raw URL. The secret path defaults to `/` (Infisical's
|
||||
* root). workspaceId/environment are omitted when empty so a service token can infer
|
||||
* them from its own scope.
|
||||
*/
|
||||
function rawSecretsUrl(
|
||||
host: string,
|
||||
workspaceId: string,
|
||||
environment: string,
|
||||
workspaceId: string | undefined,
|
||||
environment: string | undefined,
|
||||
secretPath: string
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath: secretPath || '/'
|
||||
});
|
||||
const params = new URLSearchParams({ secretPath: secretPath || '/' });
|
||||
if (workspaceId) params.set('workspaceId', workspaceId);
|
||||
if (environment) params.set('environment', environment);
|
||||
return `${baseUrl(host)}/api/v3/secrets/raw?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -218,10 +240,13 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
if (authError) {
|
||||
return { ok: false, error: authError };
|
||||
}
|
||||
if (!projectId) {
|
||||
// A service token carries its own project + environment, so they are optional
|
||||
// for it; every other auth shape still requires them.
|
||||
const serviceToken = isServiceToken(config.token);
|
||||
if (!projectId && !serviceToken) {
|
||||
return { ok: false, error: 'Project ID is empty' };
|
||||
}
|
||||
if (!environment) {
|
||||
if (!environment && !serviceToken) {
|
||||
return { ok: false, error: 'Environment is empty' };
|
||||
}
|
||||
|
||||
@@ -245,7 +270,7 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
}
|
||||
if (rawBody) console.warn(`[Infisical] testConnection ${statusCode}: ${rawBody}`);
|
||||
const safe = parseProviderError(rawBody);
|
||||
return { ok: false, error: `Infisical returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}` };
|
||||
return { ok: false, error: `Infisical returned HTTP ${statusCode}${safe ? `: ${safe}` : ''}${multiScopeHint(statusCode, config)}` };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message || 'Connection failed' };
|
||||
@@ -271,10 +296,13 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
if (authError) {
|
||||
throw new Error(`[Infisical] ${authError}`);
|
||||
}
|
||||
if (!projectId) {
|
||||
// A service token carries its own project + environment; every other auth shape
|
||||
// still requires them.
|
||||
const serviceToken = isServiceToken(config.token);
|
||||
if (!projectId && !serviceToken) {
|
||||
throw new Error('[Infisical] Project ID is required for a bulk pull');
|
||||
}
|
||||
if (!environment) {
|
||||
if (!environment && !serviceToken) {
|
||||
throw new Error('[Infisical] Environment is required for a bulk pull');
|
||||
}
|
||||
|
||||
@@ -291,7 +319,7 @@ export const infisicalProvider: SecretProvider<InfisicalConfig> = {
|
||||
// Drain the body, log it server-side, but never reflect it to the client.
|
||||
const detail = await body.text().catch(() => '');
|
||||
if (detail) console.warn(`[Infisical] bulk pull ${statusCode}: ${detail}`);
|
||||
throw new Error(`[Infisical] Bulk pull failed with HTTP ${statusCode}`);
|
||||
throw new Error(`[Infisical] Bulk pull failed with HTTP ${statusCode}${multiScopeHint(statusCode, config)}`);
|
||||
}
|
||||
|
||||
const payload = (await body.json()) as RawSecretsResponse;
|
||||
|
||||
@@ -144,7 +144,9 @@ export interface InfisicalConfig {
|
||||
clientId?: string;
|
||||
/** Universal Auth (Machine Identity) client secret. Paired with clientId. */
|
||||
clientSecret?: string;
|
||||
projectId: string;
|
||||
/** Required for Universal Auth / static non-`st.` tokens; optional for a service
|
||||
* token (`st.*`), which carries its own project. */
|
||||
projectId?: string;
|
||||
environment?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
label: string;
|
||||
type: 'text' | 'password';
|
||||
required: boolean;
|
||||
/** When present, overrides `required` based on the current form values (e.g. a
|
||||
* field that is only required for some auth shapes). */
|
||||
requiredWhen?: (config: Record<string, string>) => boolean;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
@@ -43,8 +46,11 @@
|
||||
{ key: 'token', label: 'Access token', type: 'password', required: false, placeholder: 'st...', hint: 'A static service/access token. Leave blank to use Universal Auth (client ID + secret) below instead.' },
|
||||
{ key: 'clientId', label: 'Universal Auth client ID', type: 'text', required: false, placeholder: 'machine identity client id', hint: 'A Machine Identity client ID. Pair with the client secret; leave blank if using a static token.' },
|
||||
{ key: 'clientSecret', label: 'Universal Auth client secret', type: 'password', required: false, placeholder: 'machine identity client secret', hint: 'The Machine Identity client secret. Exchanged for a short-lived token via Universal Auth.' },
|
||||
{ key: 'projectId', label: 'Project ID', type: 'text', required: true, placeholder: 'workspace / project id', hint: 'The workspace/project the secrets live in.' },
|
||||
{ key: 'environment', label: 'Environment', type: 'text', required: true, placeholder: 'prod', hint: 'Environment slug, e.g. prod / staging.' },
|
||||
// A single-scope service token (st.*) carries its own project + environment, so
|
||||
// both are optional for it. A multi-scope or glob-path service token, and every
|
||||
// other auth shape (Universal Auth, static non-st token), still need them.
|
||||
{ key: 'projectId', label: 'Project ID', type: 'text', required: true, requiredWhen: (c) => !(c.token ?? '').trim().startsWith('st.'), placeholder: 'workspace / project id', hint: 'The workspace/project the secrets live in. Optional for a single-scope service token (st.), which already targets one project; a multi-scope token still needs it.' },
|
||||
{ key: 'environment', label: 'Environment', type: 'text', required: true, requiredWhen: (c) => !(c.token ?? '').trim().startsWith('st.'), placeholder: 'prod', hint: 'Environment slug, e.g. prod / staging. Optional for a single-scope service token (st.).' },
|
||||
{ key: 'path', label: 'Secret path', type: 'text', required: false, placeholder: '/', hint: 'Folder path within the project. Defaults to /.' },
|
||||
],
|
||||
vault: [
|
||||
@@ -209,12 +215,16 @@
|
||||
return config;
|
||||
}
|
||||
|
||||
function fieldRequired(field: ProviderField, config: Record<string, string>): boolean {
|
||||
return field.requiredWhen ? field.requiredWhen(config) : field.required;
|
||||
}
|
||||
|
||||
function missingRequired(config: Record<string, string>, editing = false): string | null {
|
||||
for (const field of fields) {
|
||||
// On edit a blank secret (password) field keeps the stored value, so it is
|
||||
// allowed to be empty; non-secret required fields still must be present.
|
||||
if (editing && field.type === 'password') continue;
|
||||
if (field.required && !config[field.key]) {
|
||||
if (fieldRequired(field, config) && !config[field.key]) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
}
|
||||
@@ -404,7 +414,7 @@
|
||||
<div class="grid {stackConfigFields ? 'grid-cols-1' : 'grid-cols-2'} gap-x-4 gap-y-3 content-start" style="min-height: 21rem;">
|
||||
{#each fields as field (field.key)}
|
||||
<div class="space-y-1.5 self-start {fields.length === 1 ? 'col-span-full' : ''}">
|
||||
<FieldLabel label={field.label} forId={`provider-${field.key}`} required={field.required} />
|
||||
<FieldLabel label={field.label} forId={`provider-${field.key}`} required={fieldRequired(field, formConfig)} />
|
||||
<Input
|
||||
id={`provider-${field.key}`}
|
||||
type={field.type}
|
||||
|
||||
Reference in new issue
Block a user