diff --git a/src/lib/server/secretproviders/infisical.ts b/src/lib/server/secretproviders/infisical.ts index d553dd3..bd0fde5 100644 --- a/src/lib/server/secretproviders/infisical.ts +++ b/src/lib/server/secretproviders/infisical.ts @@ -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..`) 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 = { 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 = { } 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 = { 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 = { // 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; diff --git a/src/lib/server/secretproviders/shared.ts b/src/lib/server/secretproviders/shared.ts index d938fc2..36dc4c1 100644 --- a/src/lib/server/secretproviders/shared.ts +++ b/src/lib/server/secretproviders/shared.ts @@ -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; } diff --git a/src/routes/settings/secrets/ProviderModal.svelte b/src/routes/settings/secrets/ProviderModal.svelte index 46b07e0..7bc179f 100644 --- a/src/routes/settings/secrets/ProviderModal.svelte +++ b/src/routes/settings/secrets/ProviderModal.svelte @@ -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) => 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): boolean { + return field.requiredWhen ? field.requiredWhen(config) : field.required; + } + function missingRequired(config: Record, 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 @@
{#each fields as field (field.key)}
- +