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
8 changes: 4 additions & 4 deletions CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,11 +379,11 @@ reachability.
| `/api/cost/detail` | GET | 서비스별 비용 상세 (`?service=` 필수, ≤100자) | verifyUser |
| `/api/finops/findings` | GET | ADR-020 FinOps 기본 권장 엔진 — 미해결 findings + 최근 배치 실행(`finops_runs`) 조회, Aurora만 읽음(라이브 AWS 호출 없음). `finops_baseline_enabled=false`면 `{enabled:false, findings:[], lastRun:null}` | verifyUser |
| `/api/customization` | GET, POST, PUT | Admin skill/agent catalog CRUD; invalid tool policy → 400; unavailable validation/read → 503 (see below) | verifyUser |
| `/api/datasources` | GET | 데이터소스 인스턴스 목록 — 크리덴셜 미노출 | verifyUser |
| `/api/datasources` | GET | 데이터소스 인스턴스 목록 — 크리덴셜 미노출. 행마다 `configured`(인스턴스 자체의 설정 근거만, 연결 성공 주장 아님)·`connectionStatus`·`scope: 'shared'`(기본 인스턴스는 타입별 공유 연결) 포함 | verifyUser |
| `/api/datasources/generate` | POST | 자연어 → 쿼리 초안 생성 (리뷰용 — 절대 실행 안 함) | verifyUser |
| `/api/datasources/manage` | POST, PATCH | 인스턴스 생성/수정 + 크리덴셜 저장 (admin); `settings`(timeoutS 1–60[clickhouse 유효 최대 55]·clickhouse database)는 서버 측 sanitize 후 ds_settings JSONB에 저장 | verifyUser |
| `/api/datasources/manage` | POST, PATCH | 인스턴스 생성/수정 + 크리덴셜 저장 (admin); `settings`(timeoutS 1–60[모든 kind 유효 최대 55 — Prometheus/Mimir/ClickHouse는 서버 실행 제한, 그 외는 HTTP 응답 제한]·clickhouse `database`·mimir `apiPrefix`)는 서버 측 sanitize 후 ds_settings JSONB에 저장 | verifyUser |
| `/api/datasources/query` | POST | 인스턴스 대상 read-only 쿼리 실행 (admin 아님 — 탐색용) | verifyUser |
| `/api/datasources/test` | POST | 저장 전 연결 probe — SSRF 가드 (admin) | verifyUser |
| `/api/datasources/test` | POST | 저장 전 연결 테스트 — 실제 bounded 쿼리 1회 실행(readiness 엔드포인트 아님), `{ok, latencyMs, verified, error?}` 반환, SSRF 가드 (admin) | verifyUser |
| `/api/datasources/[id]` | DELETE | 인스턴스 삭제 — 스키마 캐시/크리덴셜 cascade, 기본값 재선정 (admin) | verifyUser |
| `/api/datasources/[id]/cards` | GET | 사전 생성 대시보드 카드 조회 (read-only, auth) | verifyUser |
| `/api/datasources/[id]/default` | POST | kind별 기본 인스턴스 지정 — 트랜잭션으로 기존 기본 해제 (admin) | verifyUser |
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ scoped registered-fleet coverage from incomplete wildcard discovery.

**E2E observability — [observability-e2e.md](observability-e2e.md).** The opt-in `/topology?view=e2e` page connects account/region/global-scoped configuration, host service snapshots and explicit NFM queries using source-quality and scoped-identity gates. The reference distinguishes implemented contracts from the broader observability roadmap.

**External connections — [external-connections.md](external-connections.md).** The eight datasource
HTTP adapters and the administrator-managed custom MCP path: shared datasource scope,
explicit instance identity, bounded responses, query-level connection tests, completion
states and pinned custom-MCP transport. Registration, verification and deployment are
distinct states.

**VPC connectivity — [vpc-connectivity.md](vpc-connectivity.md).** On-demand peering
and TGW attachment observations on `/inventory/vpc` and the
`/topology/infra?view=vpc` tab, scoped to an enabled collecting account, region and
Expand Down
135 changes: 135 additions & 0 deletions docs/reference/external-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# External observability connection contract

This is the source-supported contract for the eight datasource HTTP adapters and
the separate administrator-managed MCP path. Registration, credential presence,
query verification, MCP provisioning and live deployment are different states.

## Datasource scope and identity

Datasource Explore is available to authenticated users. Create, edit, delete,
set-default, connection test and credential operations require administrator
authority. Built-in chat resolves one shared default per kind; selecting an AWS
account does not select a different datasource or tenant.

Explore resolves credentials by instance ID and retains the row's endpoint,
authentication method and settings as authoritative. Lambda/worker requests with
an explicit instance ID fail when that instance is not configured; they cannot
borrow another instance's kind-default credential. The no-ID legacy default path
remains available. Secrets Manager's bounded credential cache does not provide
immediate revocation of previously cached credentials.

Per-account custom integration selection and exposed-tool ceilings are enforced
separately. They do not restrict built-in shared datasource defaults. External
telemetry needs independent account/region evidence before identity correlation.

## Capabilities and query windows

| Kind | Query | Time window | Built-in chat | Private-network option |
|---|---|---|---|---|
| Prometheus | PromQL instant/range | Instant or up to 30 days | `external-obs` | `prometheus_vpc_enabled` |
| Mimir | PromQL instant/range | Instant or up to 30 days | `monitoring` | `mimir_vpc_enabled` |
| Loki | LogQL instant/range | Instant or up to seven days | `monitoring` | `loki_vpc_enabled` |
| Tempo | TraceQL search | One-hour default; up to seven days | `monitoring` | `tempo_vpc_enabled` |
| ClickHouse | Read-only SQL | Time predicates belong to SQL | `external-obs` Lambda | `clickhouse_vpc_enabled` |
| Jaeger | Service-anchored trace search | One-hour default; up to seven days | No built-in target | `jaeger_vpc_enabled` |
| Dynatrace | Environment Metrics API v2 metricSelector | One-hour default; up to 30 days | Separate official MCP preset | `dynatrace_vpc_enabled` |
| Datadog | Metrics REST API query | One-hour default; up to 30 days | Separate official MCP preset | Public endpoint |

`POST /api/datasources/query` accepts `range: false`, legacy `range: true`,
or `{window, step}` in seconds. Search tools receive `start`/`end` independently
of whether a distinct range tool exists. Unsupported SQL API ranges are rejected.
Metadata includes the effective query window and shared scope. Range density
remains bounded; selecting a window does not guarantee complete provider coverage.
Natural-language drafting does not itself change the selected request window.

Explore support does not establish service-graph support. Metrics-based graph
sources require suitable Prometheus/Mimir schema; trace sources require suitable
Tempo or ClickHouse data. Loki, Jaeger, Dynatrace and Datadog have no bundled
service-graph relationship adapter in the current catalog.

## Endpoints and credentials

Endpoints are HTTP(S) datasource bases, optionally including a reverse-proxy
mount. They are validated at registration and DNS-checked/pinned at connection.
Redirects and metadata/loopback/link-local targets are blocked. Datasource HTTP
permits private addresses; reachability still requires operator-managed networks.

Mimir's `settings.apiPrefix` defaults to `/prometheus`. An empty value selects
`/api/v1` at the endpoint base. A non-empty prefix uses slash-separated letters,
digits, underscores and hyphens, up to 128 characters. If the endpoint already
ends in that prefix it is not appended twice.

Generic datasource credentials support none/basic/bearer/custom headers and
an optional tenant header. Datadog's datasource API uses its API/application-key
pair; this differs from the official hosted MCP bearer PAT/SAT. Dynatrace's direct
environment adapter uses `Api-Token`; its official hosted MCP uses a bearer
Platform token. Official MCP credentials remain namespaced under `mcp:<kind>`.

Jaeger currently uses its internal `/api/*` JSON compatibility interface, rather
than the stable v3 interface. A provider version change requires query-envelope
and time-unit validation before claiming compatibility.

## Deadlines and completion

Stored `timeoutS` remains an integer from 1 to 60 for compatibility. The effective
maximum is 55 seconds within the existing Lambda wall limit.

- Prometheus/Mimir/ClickHouse apply a provider execution ceiling on query paths.
Default is 10 seconds; callers can tighten the ceiling. Their query HTTP
deadline is aligned above the execution bound.
- Loki/Tempo/Jaeger/Dynatrace/Datadog apply the saved `timeoutS` as an HTTP response
timeout, defaulting to 12 seconds. This does not establish provider-side query
cancellation. Connect, headers and body share that one deadline.
- Schema discovery may use shorter bounded operation deadlines.
- Success bodies are limited to 4 MiB before parsing; error bodies to 64 KiB.
Overflow fails explicitly and is never represented as an empty dataset.

Connection tests use bounded reads against the actual supported query operation,
with the configured auth and tenant, and report `{ok, verified, latencyMs, error?}`
with fixed sentences (an SSRF policy rejection keeps its own classification).
Verified query access does not establish access to every metric, trace, dataset or
optional tool.

Query producers distinguish verified complete `ok`/`empty` from `partial`,
`unknown` and `error`. Malformed/unrecognized responses cannot establish absence;
usable valid siblings may be retained with uncertainty. Jaeger and Loki disclose
the requested limit and returned count; reaching the requested limit is
`limitSaturated` and potentially partial even when no additional record is proven,
while `truncated` is reserved for records the connector actually dropped.
Actual HTTP 206 evidence is retained independently of upstream JSON fields and
cannot certify an empty dataset.

## Private connectivity and MCP governance

Every VPC option defaults false. Jaeger/Dynatrace's new options require both
AgentCore and integrations. An operator must separately verify private DNS,
subnet routing, target ingress and required AWS API access. No SG ingress or
Kubernetes grant is created by registering a datasource.

Official MCP gates, exact read-tool allowlists and endpoint acknowledgements
remain governed by ADR-017. Dynatrace's empty official allowlist still authorizes
zero tools. ClickHouse's stdio official MCP remains frozen dark code.

Custom external MCP registration remains allowed. Its runtime pins a request-local
validated address set with original Host/SNI and certificate verification,
rejects changed origins and redirects, disables proxy routing, and bounds
address fallback without replaying requests after read/write failures.
HTTPX/HTTPcore versions implementing the tested SNI contract are pinned in the
runtime dependencies.

`oauth_3lo` is not executable: validation rejects it at registration, the form does not
offer it, enablement requires the default-off custom MCP governance gate (which admits
only `api_key`/`oauth_client_credentials`), and a historical row fails closed at connect.
The existing `oauth_client_credentials` transport consumes a previously stored
bearer token; it does not implement issuance or refresh. External write governance,
AWS-mutation freeze and per-account exposed-tool ceilings are unchanged.

## Rollout order

Before strict credential-blind producers roll out, complete administrator backfill
and verification for migrated defaults using
[Datasource credential identity and rollout](../runbooks/datasource-credential-isolation.md).
Deploy the helper and Lambda producers before the Web reader/UI. Roll out the
AgentCore image containing the pinned transport separately. Review a saved
Terraform plan before applying optional private-network settings. No source merge
alone proves activation or vendor readiness.
2 changes: 1 addition & 1 deletion web/app/api/datasources/authz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock('@/lib/datasources', () => ({
createDatasource: vi.fn(), updateDatasource: vi.fn(),
}));
vi.mock('@/lib/integration-credentials', () => ({
getConfiguredIds: vi.fn(async () => []),
getConfiguredDatasourceIds: vi.fn(async () => []),
setIntegrationCredentialById: vi.fn(), mirrorDefaultCredential: vi.fn(),
}));
vi.mock('@/lib/mcp-lambda-invoke', () => ({ invokeMcpLambdaTool: vi.fn(), KNOWN_MCP_LAMBDA_KINDS: ['prometheus'] }));
Expand Down
22 changes: 22 additions & 0 deletions web/app/api/datasources/manage/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ beforeEach(() => {
});

describe('POST create', () => {
it('legacy Datadog credentials cannot follow a changed endpoint origin', async () => {
getDatasource.mockResolvedValue({ id: 7, name: 'dd', kind: 'datadog', endpoint: 'https://old.example.test',
authType: 'custom_header', isDefault: true, settings: {} });
getCredentialById.mockResolvedValue({ endpoint: 'https://old.example.test', authType: 'custom_header',
apiKey: 'synthetic-api', appKey: 'synthetic-app' });
const { PATCH } = await import('./route');
expect((await PATCH(req({ id: 7, endpoint: 'https://new.example.test' }, 'PATCH'))).status).toBe(200);
const blob = setIntegrationCredentialById.mock.calls.at(-1)![1];
expect(blob.endpoint).toBe('https://new.example.test');
expect(blob).not.toHaveProperty('apiKey');
expect(blob).not.toHaveProperty('appKey');
});
it('a settings-only PATCH keeps a Datadog legacy key pair whatever the row authType says', async () => {
getDatasource.mockResolvedValue({ id: 7, name: 'dd', kind: 'datadog', endpoint: 'https://api.datadoghq.com',
authType: 'none', isDefault: false, settings: {} });
getCredentialById.mockResolvedValue({ endpoint: 'https://api.datadoghq.com', authType: 'none',
apiKey: 'synthetic-api', appKey: 'synthetic-app' });
const { PATCH } = await import('./route');
expect((await PATCH(req({ id: 7, settings: { timeoutS: 20 } }, 'PATCH'))).status).toBe(200);
const blob = setIntegrationCredentialById.mock.calls.at(-1)![1];
expect(blob).toMatchObject({ apiKey: 'synthetic-api', appKey: 'synthetic-app', timeoutS: 20 });
});
it('403 non-admin, no writes', async () => {
isAdmin.mockResolvedValue(false);
const { POST } = await import('./route');
Expand Down
19 changes: 14 additions & 5 deletions web/app/api/datasources/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const AUTH_TYPES = ['none', 'basic', 'bearer', 'custom_header'];
// through the creds spread (round-5: creds.endpoint would otherwise override the validated
// endpoint in the blob without tripping the host-change guard).
const CRED_KEYS = ['username', 'password', 'token', 'headerName', 'headerValue', 'headerName2', 'headerValue2', 'org_id'] as const;
// Legacy server-side Datadog blobs can contain a dedicated key pair. They are not client
// writable here, but must participate in the same origin/auth-change scrub.
const STORED_AUTH_KEYS = [...CRED_KEYS, 'apiKey', 'appKey'] as const;
/** True when a present `settings` value is not a plain object — 400, never a silent clear
* (round-8: null/array/string/number shapes bypassed the round-6 empty-sanitize guard). */
function settingsShapeInvalid(body: Record<string, unknown>): boolean {
Expand Down Expand Up @@ -87,6 +90,7 @@ export async function POST(request: Request) {
// database is ClickHouse-only config — never persist it on the row for other kinds either
// (round-12: the blob-side delete alone left stale, admin-visible row config).
if (kind !== 'clickhouse') delete settings.database;
if (kind !== 'mimir') delete settings.apiPrefix;
if (body.settings && typeof body.settings === 'object' && !Array.isArray(body.settings)
&& Object.keys(body.settings as object).length > 0 && Object.keys(settings).length === 0) {
return json({ error: 'invalid settings (timeoutS 1–60 integer; database identifier, non-system)' }, 400);
Expand Down Expand Up @@ -139,6 +143,7 @@ export async function PATCH(request: Request) {
if (settingsShapeInvalid(body)) return json({ error: 'settings must be an object' }, 400);
const settings = body.settings !== undefined ? sanitizeDsSettings(body.settings) : undefined;
if (settings !== undefined && ds.kind !== 'clickhouse') delete settings.database;
if (settings !== undefined && ds.kind !== 'mimir') delete settings.apiPrefix;
if (body.settings && typeof body.settings === 'object' && !Array.isArray(body.settings)
&& Object.keys(body.settings as object).length > 0 && settings !== undefined && Object.keys(settings).length === 0) {
return json({ error: 'invalid settings (timeoutS 1–60 integer; database identifier, non-system)' }, 400);
Expand Down Expand Up @@ -185,28 +190,32 @@ export async function PATCH(request: Request) {
delete existing.timeoutS;
delete existing.database;
delete existing.instanceId;
delete existing.apiPrefix;
// ORIGIN compare (scheme+host+port — an https→http downgrade must count as a change, or
// Basic material would transmit in cleartext); a malformed URL counts as changed.
const originOf = (u: string | null | undefined): string | null => { try { return new URL(u ?? '').origin; } catch { return null; } };
// Defense in depth (round-9): the kind mirror could in principle hold ANOTHER same-kind
// instance's blob — trust it as a merge base only when its endpoint origin matches THIS
// row's; otherwise drop the auth keys rather than bind foreign creds to this endpoint.
if (ds.isDefault && existing.endpoint && originOf(String(existing.endpoint)) !== originOf(ds.endpoint)) {
for (const k of CRED_KEYS) delete existing[k];
for (const k of STORED_AUTH_KEYS) delete existing[k];
}
const hostChanged = endpoint !== undefined && originOf(endpoint) !== originOf(ds.endpoint);
// On a host change, stored auth material is dropped UNCONDITIONALLY (round-5: a partial
// creds object like {username} must not carry the stored password to the new origin) —
// whatever the client genuinely re-supplied is reinstated by the creds spread below.
if (hostChanged) {
for (const k of CRED_KEYS) delete existing[k]; // org_id included — tenant id is host-scoped
for (const k of STORED_AUTH_KEYS) delete existing[k]; // tenant and legacy keys are origin-scoped
}
const effAuth = authType ?? ds.authType ?? 'none';
const KEEP_BY_AUTH: Record<string, readonly string[]> = {
none: [], basic: ['username', 'password'], bearer: ['token'],
custom_header: ['headerName', 'headerValue', 'headerName2', 'headerValue2'],
custom_header: ['headerName', 'headerValue', 'headerName2', 'headerValue2', 'apiKey', 'appKey'],
};
for (const k of CRED_KEYS) if (k !== 'org_id' && !(KEEP_BY_AUTH[effAuth] ?? []).includes(k)) delete existing[k];
// A Datadog instance's legacy key pair is its credential whatever the row's authType says, so an
// unrelated settings-only PATCH never deletes it (only an origin change or an explicit re-auth does).
const keep = ds.kind === 'datadog' ? [...(KEEP_BY_AUTH[effAuth] ?? []), 'apiKey', 'appKey'] : (KEEP_BY_AUTH[effAuth] ?? []);
for (const k of STORED_AUTH_KEYS) if (k !== 'org_id' && !keep.includes(k)) delete existing[k];
// creds is key-allowlisted (pickCredKeys) so nothing here can override the validated
// endpoint/authType/settings fields regardless of spread order.
const blob: Record<string, unknown> = {
Expand All @@ -218,7 +227,7 @@ export async function PATCH(request: Request) {
};
// Prune the FINAL blob too (round-10: {authType:'none', creds:{password}} re-added the
// key AFTER the merge-base pruning) — keys outside the effective authType never persist.
for (const k of CRED_KEYS) if (k !== 'org_id' && !(KEEP_BY_AUTH[effAuth] ?? []).includes(k)) delete blob[k];
for (const k of STORED_AUTH_KEYS) if (k !== 'org_id' && !keep.includes(k)) delete blob[k];
// database is ClickHouse-only config — never persist it for other kinds (inert but stale).
if (ds.kind !== 'clickhouse') delete blob.database;
await setIntegrationCredentialById(id, blob, client);
Expand Down
Loading