Skip to content

Commit 25b0ee6

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(oci-logging): add native OCI Logging integration
1 parent 3fa59e7 commit 25b0ee6

44 files changed

Lines changed: 2159 additions & 8 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/ui/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
482482
notion: NotionIcon,
483483
notion_v2: NotionIcon,
484484
obsidian: ObsidianIcon,
485+
oci_logging: NetSuiteIcon,
485486
okta: OktaIcon,
486487
onedrive: MicrosoftOneDriveIcon,
487488
onepassword: OnePasswordIcon,

apps/docs/content/docs/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
"notion",
186186
"notion-service-account",
187187
"obsidian",
188+
"oci_logging",
188189
"okta",
189190
"onedrive",
190191
"onepassword",

apps/docs/content/docs/integrations/oci_logging.mdx

Lines changed: 471 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/oci_logging.ts

Lines changed: 231 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/blocks/registry-maps.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ import {
249249
NotionV2BlockMeta,
250250
} from '@/blocks/blocks/notion'
251251
import { ObsidianBlock, ObsidianBlockMeta } from '@/blocks/blocks/obsidian'
252+
import { OciLoggingBlock, OciLoggingBlockMeta } from '@/blocks/blocks/oci_logging'
252253
import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta'
253254
import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive'
254255
import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword'
@@ -595,6 +596,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
595596
notion: NotionBlock,
596597
notion_v2: NotionV2Block,
597598
obsidian: ObsidianBlock,
599+
oci_logging: OciLoggingBlock,
598600
okta: OktaBlock,
599601
onedrive: OneDriveBlock,
600602
onepassword: OnePasswordBlock,
@@ -920,6 +922,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
920922
notion: NotionBlockMeta,
921923
notion_v2: NotionV2BlockMeta,
922924
obsidian: ObsidianBlockMeta,
925+
oci_logging: OciLoggingBlockMeta,
923926
okta: OktaBlockMeta,
924927
onedrive: OneDriveBlockMeta,
925928
onepassword: OnePasswordBlockMeta,

apps/sim/lib/copilot/generated/docs-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export const DOCS_MANIFEST: readonly string[] = [
243243
'integrations/notion-service-account.mdx',
244244
'integrations/notion.mdx',
245245
'integrations/obsidian.mdx',
246+
'integrations/oci_logging.mdx',
246247
'integrations/okta.mdx',
247248
'integrations/onedrive.mdx',
248249
'integrations/onepassword.mdx',

apps/sim/lib/integrations/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
465465
notion: NotionIcon,
466466
notion_v2: NotionIcon,
467467
obsidian: ObsidianIcon,
468+
oci_logging: NetSuiteIcon,
468469
okta: OktaIcon,
469470
onedrive: MicrosoftOneDriveIcon,
470471
onepassword: OnePasswordIcon,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({ authorize: vi.fn(), create: vi.fn(), prepare: vi.fn(), request: vi.fn() }))
5+
vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mocks.authorize }))
6+
vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.create }))
7+
8+
import { executeOciLoggingTool } from '@/lib/internal/oci-logging/execute-tool'
9+
import { OCI_LOGGING_INGESTION_POLICY, OCI_LOGGING_MANAGEMENT_POLICY } from '@/lib/internal/oci-logging/operations'
10+
import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types'
11+
12+
function call(overrides: Partial<InternalToolOperationCall> = {}): InternalToolOperationCall {
13+
return {
14+
toolId: 'oci_logging_list_log_groups', input: { ociCredential: 'supplied', compartmentId: 'compartment' },
15+
headers: new Headers(), context: { workflowId: 'workflow', workspaceId: 'workspace', userId: 'actor' },
16+
requestId: 'request', ...overrides,
17+
}
18+
}
19+
20+
describe('OCI Logging tool authorization and execution', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
mocks.authorize.mockResolvedValue({ ok: true, credentialType: 'service_account', resolvedCredentialId: 'resolved', workspaceId: 'workspace' })
24+
mocks.create.mockResolvedValue({ prepareStaticEndpoint: mocks.prepare, request: mocks.request })
25+
mocks.prepare.mockResolvedValue({ origin: 'https://logging.us-phoenix-1.oci.oraclecloud.com' })
26+
mocks.request.mockResolvedValue({ status: 200, headers: {}, body: new TextEncoder().encode('[]') })
27+
})
28+
29+
it('binds the authorized resolved ID and trusted scope before provider work', async () => {
30+
const response = await executeOciLoggingTool(call({ input: { ociCredential: 'supplied', compartmentId: 'compartment', region: 'us-phoenix-1', workspaceId: 'forged', userId: 'forged' } }))
31+
expect(response.status).toBe(200)
32+
expect(mocks.authorize).toHaveBeenCalledWith(expect.objectContaining({ userId: 'actor' }), {
33+
credentialId: 'supplied', workspaceId: 'workspace', workflowId: 'workflow', callerUserId: 'actor',
34+
})
35+
expect(mocks.create).toHaveBeenCalledWith({ credentialId: 'resolved', workspaceId: 'workspace', serviceId: 'oci-logging', region: 'us-phoenix-1' })
36+
expect(mocks.prepare).toHaveBeenCalledWith(OCI_LOGGING_MANAGEMENT_POLICY)
37+
expect(mocks.authorize.mock.invocationCallOrder[0]).toBeLessThan(mocks.create.mock.invocationCallOrder[0]!)
38+
})
39+
40+
it.each([
41+
{ ok: false },
42+
{ ok: true, credentialType: 'oauth', resolvedCredentialId: 'resolved', workspaceId: 'workspace' },
43+
{ ok: true, credentialType: 'service_account', workspaceId: 'workspace' },
44+
{ ok: true, credentialType: 'service_account', resolvedCredentialId: 'resolved', workspaceId: 'other' },
45+
])('rejects unavailable or unbound credentials before creating a client: %j', async (access) => {
46+
mocks.authorize.mockResolvedValue(access)
47+
expect((await executeOciLoggingTool(call())).status).toBe(403)
48+
expect(mocks.create).not.toHaveBeenCalled()
49+
})
50+
51+
it.each([{ workflowId: 'workflow' }, { workflowId: '', userId: 'actor' }])('requires trusted user and workspace context', async (context) => {
52+
expect((await executeOciLoggingTool(call({ context }))).status).toBe(401)
53+
expect(mocks.authorize).not.toHaveBeenCalled()
54+
})
55+
56+
it('rejects invalid ingestion before transmission and uses the ingestion policy on success', async () => {
57+
const input = { ociCredential: 'supplied', logId: 'custom', logEntryBatches: [{ source: 'app', type: 'events', defaultlogentrytime: '2026-09-01T00:00:00.000Z', entries: [{ id: 'stable', data: 'event' }] }] }
58+
mocks.request.mockResolvedValue({ status: 200, headers: {}, body: new Uint8Array() })
59+
const invalid = await executeOciLoggingTool(call({ toolId: 'oci_logging_put_logs', input: { ...input, logEntryBatches: [] } }))
60+
expect(invalid.status).toBe(400)
61+
expect(mocks.create).not.toHaveBeenCalled()
62+
const response = await executeOciLoggingTool(call({ toolId: 'oci_logging_put_logs', input }))
63+
expect(await response.json()).toEqual({ success: true, output: { accepted: true } })
64+
expect(mocks.prepare).toHaveBeenCalledWith(OCI_LOGGING_INGESTION_POLICY)
65+
})
66+
67+
it('does not expose unexpected errors or invite replay after a write failure', async () => {
68+
mocks.request.mockRejectedValue(new Error('private-key-canary'))
69+
const response = await executeOciLoggingTool(call({ toolId: 'oci_logging_delete_log_group', input: { ociCredential: 'supplied', logGroupId: 'group' } }))
70+
expect(await response.json()).toEqual({ success: false, error: 'OCI Logging operation failed', retryable: false })
71+
})
72+
73+
it('stops after authorization when canceled, and forwards active cancellation to the client', async () => {
74+
const controller = new AbortController()
75+
mocks.authorize.mockImplementationOnce(async () => { controller.abort(); return { ok: true } })
76+
await expect(executeOciLoggingTool(call({ signal: controller.signal }))).rejects.toThrow()
77+
expect(mocks.create).not.toHaveBeenCalled()
78+
const active = new AbortController()
79+
await executeOciLoggingTool(call({ signal: active.signal }))
80+
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ signal: active.signal }))
81+
})
82+
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { getValidationErrorMessage } from '@/lib/api/server'
2+
import { DEFAULT_MAX_JSON_BODY_BYTES } from '@/lib/api/server/validation'
3+
import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access'
4+
import { AuthType } from '@/lib/auth/hybrid'
5+
import { createOciClient } from '@/lib/internal/oci/client.server'
6+
import { OciClientError } from '@/lib/internal/oci/errors'
7+
import {
8+
executeOciLoggingOperation, OCI_LOGGING_INGESTION_POLICY,
9+
OCI_LOGGING_MANAGEMENT_POLICY, OCI_LOGGING_SERVICE_ID,
10+
} from '@/lib/internal/oci-logging/operations'
11+
import { ociLoggingCredentialSchema, ociLoggingInputSchemas } from '@/lib/internal/oci-logging/schema'
12+
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
13+
import type { OciLoggingOperation } from '@/tools/oci_logging/types'
14+
15+
export const executeOciLoggingTool: InternalToolOperationHandler = async (request) => {
16+
request.signal?.throwIfAborted()
17+
const operation = request.toolId.slice('oci_logging_'.length)
18+
if (!request.toolId.startsWith('oci_logging_') || !Object.hasOwn(ociLoggingInputSchemas, operation)) {
19+
return Response.json({ success: false, error: 'Unsupported OCI Logging operation' }, { status: 400 })
20+
}
21+
const { userId, workspaceId, workflowId } = request.context
22+
if (!userId || !workspaceId) {
23+
return Response.json({ success: false, error: 'Authenticated user and workspace context are required' }, { status: 401 })
24+
}
25+
try {
26+
if (Buffer.byteLength(JSON.stringify(request.input) ?? '') > DEFAULT_MAX_JSON_BODY_BYTES) {
27+
return Response.json({ success: false, error: 'OCI Logging input exceeds the request size limit' }, { status: 413 })
28+
}
29+
} catch {
30+
return Response.json({ success: false, error: 'Invalid OCI Logging input' }, { status: 400 })
31+
}
32+
const credential = ociLoggingCredentialSchema.safeParse(request.input)
33+
const input = ociLoggingInputSchemas[operation as OciLoggingOperation].safeParse(request.input)
34+
if (!credential.success || !input.success) {
35+
const error = !credential.success ? credential.error : !input.success ? input.error : undefined
36+
return Response.json({ success: false, error: error ? getValidationErrorMessage(error, 'Invalid OCI Logging input') : 'Invalid OCI Logging input' }, { status: 400 })
37+
}
38+
const replaySensitive = !operation.startsWith('list_') && !operation.startsWith('get_') && operation !== 'search_logs'
39+
try {
40+
const access = await authorizeCredentialUseForAuth(
41+
{ success: true, userId, authType: AuthType.INTERNAL_JWT },
42+
{ credentialId: credential.data.ociCredential, workspaceId, workflowId: workflowId || undefined, callerUserId: userId }
43+
)
44+
request.signal?.throwIfAborted()
45+
if (!access.ok || access.credentialType !== 'service_account' || !access.resolvedCredentialId || access.workspaceId !== workspaceId) {
46+
return Response.json({ success: false, error: 'OCI credential is unavailable in this workspace' }, { status: 403 })
47+
}
48+
const client = await createOciClient({
49+
credentialId: access.resolvedCredentialId, workspaceId, serviceId: OCI_LOGGING_SERVICE_ID, region: credential.data.region,
50+
})
51+
request.signal?.throwIfAborted()
52+
const endpoint = await client.prepareStaticEndpoint(operation === 'put_logs' ? OCI_LOGGING_INGESTION_POLICY : OCI_LOGGING_MANAGEMENT_POLICY)
53+
request.signal?.throwIfAborted()
54+
const output = await executeOciLoggingOperation(operation as OciLoggingOperation, input.data, { client, endpoint }, request.signal)
55+
return Response.json({ success: true, output })
56+
} catch (error) {
57+
request.signal?.throwIfAborted()
58+
return Response.json({
59+
success: false,
60+
error: error instanceof OciClientError ? error.message : 'OCI Logging operation failed',
61+
...(error instanceof OciClientError && error.opcRequestId ? { opcRequestId: error.opcRequestId } : {}),
62+
...(replaySensitive ? { retryable: false } : {}),
63+
}, { status: error instanceof OciClientError && error.status === 429 ? 429 : 502 })
64+
}
65+
}

0 commit comments

Comments
 (0)