Skip to content
Closed
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,17 @@ Native support for industry-standard security tools including:

- **Discovery**: `Subfinder`, `DNSX`, `Naabu`, `HTTPx`
- **Vulnerability**: `Nuclei`, `TruffleHog`
- **CSPM**: `Prowler` for AWS cloud security posture management
- **Utility**: `JSON Transform`, `Logic Scripts`, `HTTP Requests`

### Cloud & Platform Integrations

First-class integration support with encrypted credential management:

- **AWS**: IAM Role connections with STS AssumeRole, Organization account discovery, cross-account scanning
- **Slack**: Incoming Webhooks and OAuth App connections for notifications and alerts
- **Credential Resolution**: Workflow components that securely resolve connection credentials at runtime

### Advanced Orchestration

- **Human-in-the-Loop**: Pause workflows for approvals, form inputs, or manual validation before continuing.
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,12 @@ REDIS_URL=""

# Kafka / Redpanda configuration for node I/O, log, and event ingestion
LOG_KAFKA_BROKERS="localhost:19092"

# Extra CORS origins (comma-separated), e.g. for ngrok tunnels
# CORS_EXTRA_ORIGINS="https://your-ngrok-url.ngrok-free.app"

# Slack OAuth integration (required for Slack app installation into customer workspaces)
SLACK_OAUTH_CLIENT_ID=""
SLACK_OAUTH_CLIENT_SECRET=""
# Override default scopes (comma-separated); defaults: channels:read,chat:write,chat:write.public,commands,im:write,team:read
# SLACK_OAUTH_SCOPES=""
31 changes: 25 additions & 6 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ bun run test

#### Integrations Module

- **OAuth Provider**: Multi-provider OAuth orchestration
- **Token Vault**: Encrypted storage of access tokens
- **Connection Management**: OAuth lifecycle and refresh handling
- **Provider Catalog**: Static integration catalog (AWS, Slack) with auth methods, setup scenarios, and configuration
- **OAuth Provider**: Multi-provider OAuth orchestration (GitHub, Slack, Zoom) with PKCE support
- **Token Vault**: AES-256-GCM encrypted storage of access and refresh tokens
- **Connection Management**: Full lifecycle management -- create, validate, refresh, disconnect
- **AWS Service**: IAM Role connections with STS AssumeRole, Organization account discovery, trust policy generation
- **Slack Service**: Webhook connections with test messaging support
- **Credential Resolution**: Internal API for worker components to resolve typed credentials at runtime

#### Logging & Events Module

Expand Down Expand Up @@ -134,12 +138,27 @@ INTEGRATION_STORE_MASTER_KEY=your-32-character-integ-key!!!!!
- `GET /api/v1/files/{id}/download` - Download file
- `GET /api/v1/files/{id}/metadata` - Get file metadata

### Secrets & Integrations
### Secrets

- `GET /api/v1/secrets` - List secrets
- `POST /api/v1/secrets` - Create secret
- `GET /api/v1/integrations/providers` - List OAuth providers
- `POST /api/v1/integrations/{provider}/start` - Start OAuth flow

### Integrations

- `GET /integrations/catalog` - List integration provider catalog
- `GET /integrations/connections` - List user connections
- `GET /integrations/org/connections` - List organization connections
- `DELETE /integrations/connections/{id}` - Remove a connection
- `POST /integrations/connections/{id}/refresh` - Refresh connection tokens
- `GET /integrations/providers` - List OAuth providers
- `POST /integrations/{provider}/start` - Start OAuth flow
- `POST /integrations/{provider}/exchange` - Complete OAuth exchange
- `GET /integrations/aws/setup-info` - Get IAM trust policy and external ID
- `POST /integrations/aws/connections` - Create AWS IAM role connection
- `POST /integrations/aws/connections/{id}/validate` - Validate AWS connection
- `POST /integrations/aws/connections/{id}/discover-org` - Discover AWS Organization accounts
- `POST /integrations/slack/connections` - Create Slack webhook connection
- `POST /integrations/slack/connections/{id}/test` - Test Slack connection

## Project Structure

Expand Down
62 changes: 62 additions & 0 deletions backend/drizzle/0020_expand-integration-tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-- Step 1: Add new columns (all nullable initially for safe migration)
-- organization_id is varchar(255), NOT varchar(191), because the backfill
-- produces 'workspace-' || user_id (10 + up to 191 chars = 201), which
-- would overflow varchar(191). (R4 Finding #1)
-- This wider column is specific to integration_tokens — other tables use
-- varchar(191) for org IDs because they store real Clerk org IDs directly.
-- See D14 for rationale.
ALTER TABLE "integration_tokens"
ADD COLUMN IF NOT EXISTS "credential_type" varchar(32) NOT NULL DEFAULT 'oauth',
ADD COLUMN IF NOT EXISTS "display_name" varchar(191),
ADD COLUMN IF NOT EXISTS "organization_id" varchar(255),
ADD COLUMN IF NOT EXISTS "last_validated_at" timestamptz,
ADD COLUMN IF NOT EXISTS "last_validation_status" varchar(16),
ADD COLUMN IF NOT EXISTS "last_validation_error" text,
ADD COLUMN IF NOT EXISTS "last_used_at" timestamptz;

-- Step 2: Backfill organization_id = 'workspace-' || user_id for ALL rows.
-- Why per-user workspace (not cross-table join):
-- - `workflows` table has NO `created_by` column (R5 Finding #1), so we can't
-- map user_id → org_id from existing DB data.
-- - Per-user workspace guarantees ZERO collisions (R5 Finding #2): each user
-- gets their own namespace, so (workspace-userA, slack, oauth, slack) and
-- (workspace-userB, slack, oauth, slack) never conflict — even if both users are
-- in the same real Clerk org. The unique index creation (Step 6) always succeeds.
-- - Clerk auth resolves personal workspace as 'workspace-' || userId
-- (backend/src/auth/providers/clerk-auth.provider.ts).
-- Tradeoff: local-dev tokens land in 'workspace-admin' instead of 'local-dev'.
-- Local dev users reconnect once after migration (acceptable for dev environments).
UPDATE "integration_tokens"
SET "organization_id" = 'workspace-' || "user_id"
WHERE "organization_id" IS NULL;

-- Step 3: Backfill display_name = provider for existing OAuth rows
UPDATE "integration_tokens"
SET "display_name" = "provider"
WHERE "display_name" IS NULL;

-- Step 4: Make both columns NOT NULL now that all rows are backfilled.
-- organization_id MUST be NOT NULL — PostgreSQL unique indexes treat NULLs as
-- distinct, so nullable org_id would bypass uniqueness entirely (R3 Finding #4).
ALTER TABLE "integration_tokens" ALTER COLUMN "organization_id" SET NOT NULL;
ALTER TABLE "integration_tokens" ALTER COLUMN "display_name" SET NOT NULL;

-- Step 5: Drop old unique index on (user_id, provider)
DROP INDEX IF EXISTS "integration_tokens_user_provider_uidx";

-- Step 6: New unique index — org-scoped, includes credential_type (D15).
-- Prevents Slack OAuth/webhook collision: (org, slack, oauth, slack) and
-- (org, slack, webhook, slack) are distinct rows.
-- All columns are NOT NULL, so uniqueness is always enforced.
CREATE UNIQUE INDEX "integration_tokens_org_provider_type_name_uidx"
ON "integration_tokens" ("organization_id", "provider", "credential_type", "display_name");

-- Step 7: Add organization_id to OAuth state table (R5 Finding #3).
-- Without this, completeOAuthSession has no way to retrieve the org that was
-- active when the user initiated the OAuth flow.
ALTER TABLE "integration_oauth_states"
ADD COLUMN IF NOT EXISTS "organization_id" varchar(255);

-- Step 8: Supporting indexes
CREATE INDEX IF NOT EXISTS "integration_tokens_org_idx"
ON "integration_tokens" ("organization_id");
6 changes: 5 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
"migration:push": "bun x drizzle-kit push",
"migration:smoke": "bun scripts/migration-smoke.ts",
"delete:runs": "bun scripts/delete-all-workflow-runs.ts",
"setup:opensearch": "bun scripts/setup-opensearch.ts"
"setup:opensearch": "bun scripts/setup-opensearch.ts",
"seed:aws-workflow": "bun scripts/seed-aws-cspm-workflow.ts"
},
"dependencies": {
"@aws-sdk/client-organizations": "^3.750.0",
"@aws-sdk/client-sts": "^3.750.0",
"@clerk/backend": "^2.29.5",
"@clerk/types": "^4.101.13",
"@grpc/grpc-js": "^1.14.3",
Expand Down Expand Up @@ -45,6 +48,7 @@
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
Expand Down
61 changes: 61 additions & 0 deletions backend/scripts/seed-aws-cspm-workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Seed script: imports the AWS CSPM Org Discovery workflow via the backend API.
*
* Usage:
* bun backend/scripts/seed-aws-cspm-workflow.ts
*
* The backend must be running on BACKEND_URL (default http://localhost:3001).
* Set ADMIN_USERNAME / ADMIN_PASSWORD env vars if admin auth is required,
* or CLERK_SESSION_TOKEN for Clerk-based auth.
*/

import { readFileSync } from 'fs';
import { resolve } from 'path';

const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:3001';
const ADMIN_USER = process.env.ADMIN_USERNAME ?? 'admin';
const ADMIN_PASS = process.env.ADMIN_PASSWORD ?? 'admin';

async function main() {
const workflowPath = resolve(import.meta.dir, '../../docs/sample/aws-cspm-org-discovery.json');

const workflowJson = JSON.parse(readFileSync(workflowPath, 'utf-8'));
console.log(`Importing workflow: ${workflowJson.name}`);
console.log(` Nodes: ${workflowJson.nodes.length}`);
console.log(` Edges: ${workflowJson.edges.length}`);

// Build auth headers — try Clerk token first, then fall back to basic auth
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};

if (process.env.CLERK_SESSION_TOKEN) {
headers['Authorization'] = `Bearer ${process.env.CLERK_SESSION_TOKEN}`;
} else {
headers['Authorization'] = `Basic ${btoa(`${ADMIN_USER}:${ADMIN_PASS}`)}`;
}

const res = await fetch(`${BACKEND_URL}/api/v1/workflows`, {
method: 'POST',
headers,
body: JSON.stringify(workflowJson),
});

if (!res.ok) {
const body = await res.text();
console.error(`Failed to create workflow (${res.status}): ${body}`);
process.exit(1);
}

const created = await res.json();
console.log(`\nWorkflow created successfully!`);
console.log(` ID: ${created.id}`);
console.log(` Name: ${created.name}`);
console.log(` Version: ${created.currentVersion}`);
console.log(`\nOpen in dashboard: http://localhost:5173/workflows/${created.id}`);
}

main().catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
});
77 changes: 77 additions & 0 deletions backend/src/common/guards/internal-only.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
SetMetadata,
UseGuards,
applyDecorators,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';

const INTERNAL_ONLY_KEY = 'internal_only';

/**
* NestJS guard that restricts access to internal service calls only.
*
* Authentication is via the `X-Internal-Token` header matched against
* the `INTERNAL_SERVICE_TOKEN` environment variable.
*
* If `INTERNAL_SERVICE_TOKEN` is NOT set:
* - If `ALLOW_INSECURE_INTERNAL_ENDPOINTS=true` → allow with warning
* - Otherwise → reject with 403
*
* Note: Internal endpoints have no per-org scoping. Workers authenticate
* via a shared service secret, not per-user/per-org credentials. Tenant
* isolation is enforced at the workflow layer (workflows are org-scoped).
*/
@Injectable()
export class InternalOnlyGuard implements CanActivate {
private readonly logger = new Logger(InternalOnlyGuard.name);

constructor(private readonly reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const isInternalOnly = this.reflector.getAllAndOverride<boolean>(INTERNAL_ONLY_KEY, [
context.getHandler(),
context.getClass(),
]);

if (!isInternalOnly) {
return true;
}

const request = context.switchToHttp().getRequest<Request>();
const providedToken = request.header('x-internal-token');
const expectedToken = process.env.INTERNAL_SERVICE_TOKEN;

if (!expectedToken) {
if (process.env.ALLOW_INSECURE_INTERNAL_ENDPOINTS === 'true') {
this.logger.warn(
`INTERNAL_SERVICE_TOKEN is not set. Allowing insecure access to ${request.method} ${request.path} because ALLOW_INSECURE_INTERNAL_ENDPOINTS=true.`,
);
return true;
}

throw new ForbiddenException(
'INTERNAL_SERVICE_TOKEN must be configured or ALLOW_INSECURE_INTERNAL_ENDPOINTS=true must be set',
);
}

if (providedToken !== expectedToken) {
throw new ForbiddenException('Invalid internal access token');
}

return true;
}
}

/**
* Decorator that restricts an endpoint to internal service calls only.
* Validates the `X-Internal-Token` header against `INTERNAL_SERVICE_TOKEN`.
*/
export function InternalOnly(): MethodDecorator & ClassDecorator {
return applyDecorators(SetMetadata(INTERNAL_ONLY_KEY, true), UseGuards(InternalOnlyGuard));
}
24 changes: 24 additions & 0 deletions backend/src/components/utils/categorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const SUPPORTED_CATEGORIES: readonly ComponentCategory[] = [
'notification',
'manual_action',
'output',
'process',
'cloud',
'core',
];

const COMPONENT_CATEGORY_CONFIG: Record<ComponentCategory, ComponentCategoryConfig> = {
Expand Down Expand Up @@ -84,6 +87,27 @@ const COMPONENT_CATEGORY_CONFIG: Record<ComponentCategory, ComponentCategoryConf
emoji: '📤',
icon: 'Upload',
},
process: {
label: 'Process',
color: 'text-slate-600',
description: 'Data processing and transformation steps',
emoji: '⚙️',
icon: 'Cog',
},
cloud: {
label: 'Cloud',
color: 'text-sky-600',
description: 'Cloud provider integrations and services',
emoji: '☁️',
icon: 'Cloud',
},
core: {
label: 'Core',
color: 'text-gray-600',
description: 'Core platform utilities and credential management',
emoji: '🔧',
icon: 'Wrench',
},
};

function normalizeCategory(category?: string | null): ComponentCategory | null {
Expand Down
19 changes: 15 additions & 4 deletions backend/src/database/schema/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import {
index,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
text,
} from 'drizzle-orm/pg-core';

export const integrationTokens = pgTable(
Expand All @@ -15,6 +15,9 @@ export const integrationTokens = pgTable(
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('user_id', { length: 191 }).notNull(),
provider: varchar('provider', { length: 64 }).notNull(),
credentialType: varchar('credential_type', { length: 32 }).notNull().default('oauth'),
displayName: varchar('display_name', { length: 191 }).notNull(),
organizationId: varchar('organization_id', { length: 255 }).notNull(),
scopes: jsonb('scopes').$type<string[]>().notNull().default([]),
accessToken: jsonb('access_token')
.$type<{
Expand All @@ -34,15 +37,22 @@ export const integrationTokens = pgTable(
.default(null),
tokenType: varchar('token_type', { length: 32 }).default('Bearer'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
lastValidatedAt: timestamp('last_validated_at', { withTimezone: true }),
lastValidationStatus: varchar('last_validation_status', { length: 16 }),
lastValidationError: text('last_validation_error'),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
metadata: jsonb('metadata').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userProviderIdx: index('integration_tokens_user_idx').on(table.userId),
userProviderUnique: uniqueIndex('integration_tokens_user_provider_uidx').on(
table.userId,
userIdx: index('integration_tokens_user_idx').on(table.userId),
orgIdx: index('integration_tokens_org_idx').on(table.organizationId),
orgProviderTypeNameUnique: uniqueIndex('integration_tokens_org_provider_type_name_uidx').on(
table.organizationId,
table.provider,
table.credentialType,
table.displayName,
),
}),
);
Expand All @@ -54,6 +64,7 @@ export const integrationOAuthStates = pgTable(
state: text('state').notNull(),
userId: varchar('user_id', { length: 191 }).notNull(),
provider: varchar('provider', { length: 64 }).notNull(),
organizationId: varchar('organization_id', { length: 255 }),
codeVerifier: text('code_verifier'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
Expand Down
Loading