Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { ConnectionService } from '../services/connection.service';
import { CredentialVaultService } from '../services/credential-vault.service';
import { ProviderRepository } from '../repositories/provider.repository';
import { CheckRunRepository } from '../repositories/check-run.repository';
import { getStringValue, toStringCredentials } from '../utils/credential-utils';
import { getStringValue } from '../utils/credential-utils';

// Class (not interface) so @nestjs/swagger emits a body schema, plus a
// class-validator decorator so the ValidationPipe whitelist accepts the field.
Expand Down Expand Up @@ -257,11 +257,13 @@ export class ChecksController {
try {
// Run checks
const accessToken = getStringValue(credentials.access_token);
const stringCredentials = toStringCredentials(credentials);
const result = await runAllChecks({
manifest,
accessToken,
credentials: stringCredentials,
// Pass decrypted credentials through unchanged. Collapsing array fields
// here (e.g. AWS `regions`) made custom-auth checks see no regions and
// skip with "connection not configured".
credentials,
variables,
connectionId,
organizationId: connection.organizationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { CheckRunRepository } from '../repositories/check-run.repository';
import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { TaskIntegrationChecksService } from '../services/task-integration-checks.service';
import { getStringValue, toStringCredentials } from '../utils/credential-utils';
import { getStringValue } from '../utils/credential-utils';
import { isCheckDisabledForTask } from '../utils/disabled-task-checks';
import { db } from '@db';
import type { Prisma } from '@db';
Expand Down Expand Up @@ -429,11 +429,13 @@ export class TaskIntegrationsController {
try {
// Run the specific check
const accessToken = getStringValue(credentials.access_token);
const stringCredentials = toStringCredentials(credentials);
const result = await runAllChecks({
manifest,
accessToken,
credentials: stringCredentials,
// Pass decrypted credentials through unchanged. Collapsing array fields
// here (e.g. AWS `regions`) made custom-auth checks see no regions and
// skip with "connection not configured".
credentials,
variables,
connectionId,
organizationId,
Expand Down
19 changes: 0 additions & 19 deletions apps/api/src/integration-platform/utils/credential-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,3 @@ export function getStringValue(value?: string | string[]): string | undefined {
}
return value;
}

/**
* Normalizes credentials from Record<string, string | string[]> to Record<string, string>
* by extracting the first value from arrays
* @param credentials - The credentials object with potential array values
* @returns A normalized credentials object with only string values
*/
export function toStringCredentials(
credentials: Record<string, string | string[]>,
): Record<string, string> {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(credentials)) {
const stringValue = getStringValue(value);
if (typeof stringValue === 'string' && stringValue.length > 0) {
normalized[key] = stringValue;
}
}
return normalized;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,48 @@ import {
evaluateRdsEncryption,
} from '../rds';
import { evaluateS3Encryption, evaluateS3PublicAccess } from '../s3';
import { resolveAwsCredentialInputs } from '../shared';

const kinds = (os: { kind: string }[]) => os.map((o) => o.kind);

describe('AWS credential resolution (regions shape)', () => {
const base = { roleArn: 'arn:aws:iam::123456789012:role/x', externalId: 'eid' };

it('honors a multi-element regions array (the normal stored shape)', () => {
const r = resolveAwsCredentialInputs({
...base,
regions: ['us-east-1', 'us-west-2'],
});
expect(r).not.toBeNull();
expect(r!.regions).toEqual(['us-east-1', 'us-west-2']);
});

it('accepts a single region string (resilient to an upstream collapse)', () => {
const r = resolveAwsCredentialInputs({ ...base, regions: 'us-east-1' });
expect(r).not.toBeNull();
expect(r!.regions).toEqual(['us-east-1']);
});

it('accepts the legacy singular `region` key', () => {
const r = resolveAwsCredentialInputs({ ...base, region: 'eu-west-1' });
expect(r!.regions).toEqual(['eu-west-1']);
});

it('returns null when regions resolve to empty (not configured)', () => {
expect(resolveAwsCredentialInputs({ ...base, regions: [] })).toBeNull();
expect(resolveAwsCredentialInputs({ ...base, regions: [' '] })).toBeNull();
});

it('returns null when roleArn or externalId is missing', () => {
expect(
resolveAwsCredentialInputs({ externalId: 'eid', regions: ['us-east-1'] }),
).toBeNull();
expect(
resolveAwsCredentialInputs({ roleArn: base.roleArn, regions: ['us-east-1'] }),
).toBeNull();
});
});

describe('AWS IAM account evaluator', () => {
it('fails on missing policy, root MFA off, and root keys present', () => {
const out = evaluateIamAccount({
Expand Down
54 changes: 42 additions & 12 deletions packages/integration-platform/src/manifests/aws/checks/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,43 @@ export interface AwsSession {
regions: string[];
}

export interface AwsCredentialInputs {
roleArn: string;
externalId: string;
regions: string[];
}

/**
* Resolve role ARN, external ID, and regions from raw connection credentials.
* Returns null when any is missing (treated as "connection not configured").
*
* `regions` is normally a string[]; a single region string (or the legacy
* singular `region` key) is also accepted, so a value that was collapsed to a
* scalar upstream still yields a usable region instead of silently resolving to
* "not configured".
*/
export function resolveAwsCredentialInputs(
credentials: Record<string, unknown>,
): AwsCredentialInputs | null {
const roleArn =
typeof credentials.roleArn === 'string' ? credentials.roleArn : '';
const externalId =
typeof credentials.externalId === 'string' ? credentials.externalId : '';
const rawRegions = credentials.regions;
const regions = (
Array.isArray(rawRegions)
? rawRegions.filter((r): r is string => typeof r === 'string')
: typeof rawRegions === 'string'
? [rawRegions]
: typeof credentials.region === 'string'
? [credentials.region]
: []
).filter((r) => r.trim().length > 0);

if (!roleArn || !externalId || regions.length === 0) return null;
return { roleArn, externalId, regions };
}

/**
* Assume the customer's cross-account IAM role (role ARN + external ID from the
* connection credentials) and return temporary credentials + the selected
Expand All @@ -19,18 +56,11 @@ export interface AwsSession {
export async function assumeAwsSession(
ctx: CheckContext,
): Promise<AwsSession | null> {
const raw = ctx.credentials as Record<string, unknown>;
const roleArn = typeof raw.roleArn === 'string' ? raw.roleArn : '';
const externalId = typeof raw.externalId === 'string' ? raw.externalId : '';
const regions = (
Array.isArray(raw.regions)
? raw.regions.filter((r): r is string => typeof r === 'string')
: typeof raw.region === 'string'
? [raw.region]
: []
).filter((r) => r.trim().length > 0);

if (!roleArn || !externalId || regions.length === 0) return null;
const inputs = resolveAwsCredentialInputs(
ctx.credentials as Record<string, unknown>,
);
if (!inputs) return null;
const { roleArn, externalId, regions } = inputs;

const sts = new STSClient({ region: regions[0] });
const res = await sts.send(
Expand Down
27 changes: 22 additions & 5 deletions packages/integration-platform/src/runtime/check-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ export interface CheckContextOptions {
manifest: IntegrationManifest;
/** Access token for OAuth integrations. Optional for custom auth types (e.g., AWS IAM). */
accessToken?: string;
credentials: Record<string, string>;
/**
* Credential values. Custom-auth providers can legitimately hold array
* fields (e.g. AWS `regions: string[]`), so values are `string | string[]`.
* Do NOT collapse arrays to a single value before passing them in — checks
* like the AWS ones read `regions` as an array.
*/
credentials: Record<string, string | string[]>;
variables?: CheckVariableValues;
connectionId: string;
organizationId: string;
Expand Down Expand Up @@ -92,6 +98,17 @@ export function createCheckContext(options: CheckContextOptions): {
} = options;

let currentAccessToken = initialAccessToken ?? '';

// Read a credential as a single string. Custom-auth credentials can be
// arrays (e.g. AWS regions); the string-based auth schemes (api key, basic)
// need a scalar, so collapse to the first element here — never upstream,
// where it would destroy multi-value fields for the checks that need them.
const credString = (key: string): string => {
const v = credentials[key];
if (Array.isArray(v)) return v[0] ?? '';
return v ?? '';
};

const findings: CheckResult['findings'] = [];
const passingResults: CheckResult['passingResults'] = [];
const logs: CheckResult['logs'] = [];
Expand Down Expand Up @@ -135,7 +152,7 @@ export function createCheckContext(options: CheckContextOptions): {

// API Key: Add to header if configured
if (manifest.auth.type === 'api_key' && manifest.auth.config.in === 'header') {
const apiKey = credentials[manifest.auth.config.name] || credentials.api_key || '';
const apiKey = credString(manifest.auth.config.name) || credString('api_key');
const value = manifest.auth.config.prefix
? `${manifest.auth.config.prefix}${apiKey}`
: apiKey;
Expand All @@ -144,8 +161,8 @@ export function createCheckContext(options: CheckContextOptions): {

// Basic Auth: Encode username:password
if (manifest.auth.type === 'basic') {
const username = credentials[manifest.auth.config.usernameField || 'username'] || '';
const password = credentials[manifest.auth.config.passwordField || 'password'] || '';
const username = credString(manifest.auth.config.usernameField || 'username');
const password = credString(manifest.auth.config.passwordField || 'password');
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
}
Expand Down Expand Up @@ -227,7 +244,7 @@ export function createCheckContext(options: CheckContextOptions): {

// API Key in query param
if (manifest.auth.type === 'api_key' && manifest.auth.config.in === 'query') {
const apiKey = credentials[manifest.auth.config.name] || credentials.api_key || '';
const apiKey = credString(manifest.auth.config.name) || credString('api_key');
const value = manifest.auth.config.prefix
? `${manifest.auth.config.prefix}${apiKey}`
: apiKey;
Expand Down
9 changes: 7 additions & 2 deletions packages/integration-platform/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,13 @@ export interface CheckContext {
/** The OAuth access token (for oauth2 auth). Empty for custom auth types like AWS. */
accessToken: string;

/** All credentials as key-value pairs (form fields for custom auth, or token data for OAuth) */
credentials: Record<string, string>;
/**
* All credentials as key-value pairs (form fields for custom auth, or token
* data for OAuth). Custom-auth fields can be arrays (e.g. AWS `regions`), so
* values are `string | string[]` — read array fields directly and use a
* scalar coercion only where a single string is required.
*/
credentials: Record<string, string | string[]>;

/** User-configured variables for this integration */
variables: CheckVariableValues;
Expand Down
Loading