Skip to content
Draft
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
24 changes: 24 additions & 0 deletions .changeset/jolly-chefs-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'hive': minor
---

Added opt-in AWS IAM authentication for S3 connections. When IAM is enabled, services authenticate
to S3 using short-lived SigV4 pre-signed tokens instead of static passwords, since S3 connections
are HTTP requests a new token will be generate for each call.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Grammar typo: "will be generate" should be "will be generated".

Suggested change
are HTTP requests a new token will be generate for each call.
are HTTP requests a new token will be generated for each call.


### New environment variables

| Variable | Service | Description |
| ----------------------------------- | ------- | --------------------------------------------------------- |
| `S3_AWS_IAM_AUTH_ENABLED` | server | Set to `1` to enable IAM authentication for S3. |
| `S3_MIRROR_AWS_IAM_AUTH_ENABLED` | server | Set to `1` to enable IAM authentication for S3 Mirror. |
| `S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED` | server | Set to `1` to enable IAM authentication for S3 Audit Log. |

### To enable

- `S3_*_AWS_IAM_AUTH_ENABLED=1`.
- `S3_BUCKET_NAME` set to the AWS S3 bucket.
- `S3_ENDPOINT` set with the S3 endpoint with the AWS Region (i.e. https://s3.us-east-1.amazonaws.com)

When `CDN_API=1` is set on the server, the CDN artifact handler also uses IAM-authenticated S3 clients.
Adds support for S3 Audit Logs exported to AWS S3.
2 changes: 2 additions & 0 deletions packages/services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"exports": {
"./modules/auth/lib/supertokens-at-home/crypto": "./src/modules/auth/lib/supertokens-at-home/crypto.ts",
"./modules/auth/providers/supertokens-store": "./src/modules/auth/providers/supertokens-store.ts",
"./modules/cdn/providers/aws": "./src/modules/cdn/providers/aws.ts",
"./modules/shared/providers/logger": "./src/modules/shared/providers/logger.ts",
"./modules/shared/providers/redis": "./src/modules/shared/providers/redis.ts",
"./modules/schema/providers/schema-version-store": "./src/modules/schema/providers/schema-version-store.ts"
Expand All @@ -19,6 +20,7 @@
},
"devDependencies": {
"@aws-sdk/client-s3": "3.1035.0",
"@aws-sdk/credential-providers": "3.1035.0",
"@aws-sdk/s3-request-presigner": "3.1035.0",
"@bentocache/plugin-prometheus": "0.2.0",
"@date-fns/utc": "2.1.1",
Expand Down
29 changes: 9 additions & 20 deletions packages/services/api/src/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { authModule } from './modules/auth';
import { Session } from './modules/auth/lib/authz';
import { cdnModule } from './modules/cdn';
import { AwsClient } from './modules/cdn/providers/aws';
import type { S3CredentialProvider } from './modules/cdn/providers/aws';
import { CDN_CONFIG, CDNConfig } from './modules/cdn/providers/tokens';
import { collectionModule } from './modules/collection';
import { commerceModule } from './modules/commerce';
Expand Down Expand Up @@ -137,23 +138,17 @@ export function createRegistry({
s3: {
bucketName: string;
endpoint: string;
accessKeyId: string;
secretAccessKeyId: string;
sessionToken?: string;
credentialProvider: S3CredentialProvider;
};
s3Mirror: {
bucketName: string;
endpoint: string;
accessKeyId: string;
secretAccessKeyId: string;
sessionToken?: string;
credentialProvider: S3CredentialProvider;
} | null;
s3AuditLogs: {
bucketName: string;
endpoint: string;
accessKeyId: string;
secretAccessKeyId: string;
sessionToken?: string;
credentialProvider: S3CredentialProvider;
} | null;
encryptionSecret: string;
app: {
Expand All @@ -177,9 +172,7 @@ export function createRegistry({
const s3Config: S3Config = [
{
client: new AwsClient({
accessKeyId: s3.accessKeyId,
secretAccessKey: s3.secretAccessKeyId,
sessionToken: s3.sessionToken,
credentialProvider: s3.credentialProvider,
service: 's3',
}),
bucket: s3.bucketName,
Expand All @@ -190,9 +183,7 @@ export function createRegistry({
if (s3Mirror) {
s3Config.push({
client: new AwsClient({
accessKeyId: s3Mirror.accessKeyId,
secretAccessKey: s3Mirror.secretAccessKeyId,
sessionToken: s3Mirror.sessionToken,
credentialProvider: s3Mirror.credentialProvider,
service: 's3',
}),
bucket: s3Mirror.bucketName,
Expand All @@ -205,13 +196,11 @@ export function createRegistry({
const auditLogS3Config = s3AuditLogs
? new AuditLogS3Config(
new AwsClient({
accessKeyId: s3AuditLogs.accessKeyId,
secretAccessKey: s3AuditLogs.secretAccessKeyId,
sessionToken: s3AuditLogs.sessionToken,
credentialProvider: s3AuditLogs.credentialProvider,
service: 's3',
}),
s3.endpoint,
s3.bucketName,
s3AuditLogs.endpoint,
s3AuditLogs.bucketName,
)
: new AuditLogS3Config(s3Config[0].client, s3Config[0].endpoint, s3Config[0].bucket);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type MessagePort } from 'node:worker_threads';
import { AwsClient } from '../../cdn/providers/aws';
import type { S3CredentialProvider } from '../../cdn/providers/aws';
import { ClickHouse } from '../../operations/providers/clickhouse-client';
import { HttpClient } from '../../shared/providers/http-client';
import { Logger } from '../../shared/providers/logger';
Expand All @@ -21,20 +22,12 @@ export function createWorker(
s3: {
readonly bucketName: string;
readonly endpoint: string;
readonly credentials: {
readonly accessKeyId: string;
readonly secretAccessKey: string;
readonly sessionToken: string | undefined;
};
readonly credentialProvider: S3CredentialProvider;
};
s3Mirror: {
readonly bucketName: string;
readonly endpoint: string;
readonly credentials: {
readonly accessKeyId: string;
readonly secretAccessKey: string;
readonly sessionToken: string | undefined;
};
readonly credentialProvider: S3CredentialProvider;
} | null;
clickhouse: {
readonly host: string;
Expand All @@ -48,9 +41,7 @@ export function createWorker(
const s3Config: S3Config = [
{
client: new AwsClient({
accessKeyId: env.s3.credentials.accessKeyId,
secretAccessKey: env.s3.credentials.secretAccessKey,
sessionToken: env.s3.credentials.sessionToken,
credentialProvider: env.s3.credentialProvider,
service: 's3',
}),
bucket: env.s3.bucketName,
Expand All @@ -61,9 +52,7 @@ export function createWorker(
if (env.s3Mirror) {
s3Config.push({
client: new AwsClient({
accessKeyId: env.s3Mirror.credentials.accessKeyId,
secretAccessKey: env.s3Mirror.credentials.secretAccessKey,
sessionToken: env.s3Mirror.credentials.sessionToken,
credentialProvider: env.s3Mirror.credentialProvider,
service: 's3',
}),
bucket: env.s3Mirror.bucketName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from 'vitest';
import { AwsClient, type S3CredentialProvider } from '../../cdn/providers/aws';
import { AuditLogManager, AuditLogS3Config } from './audit-logs-manager';

vi.mock('@hive/workflows/kit', () => ({ TaskScheduler: class {} }));
vi.mock('@hive/workflows/tasks/audit-log-export', () => ({ AuditLogExportTask: {} }));
vi.mock('@sentry/node', () => ({ captureException: vi.fn() }));
vi.mock('../../operations/providers/clickhouse-client', () => ({
ClickHouse: class {},
sql: (strings: TemplateStringsArray, ...values: any[]) => ({ strings, values }),
}));
vi.mock('./audit-log-recorder', () => ({
formatToClickhouseDateTime: (d: Date) => d.toISOString(),
}));
vi.mock('./audit-logs-types', () => ({
AuditLogClickhouseArrayModel: { parse: (v: any) => v },
}));

function fakeCredentialProvider(keyId: string): S3CredentialProvider {
return {
getCredentials: async () => ({ accessKeyId: keyId, secretAccessKey: `${keyId}-secret` }),
};
}

function createAuditLogManager(fetchImpl: (...args: any[]) => any) {
const credentialProvider = fakeCredentialProvider('test-key');
const client = new AwsClient({ credentialProvider, service: 's3' });
const fetchSpy = vi.spyOn(client, 'fetch').mockImplementation(fetchImpl as any);

const s3Config = new AuditLogS3Config(client, 'https://s3.example.com', 'audit-bucket');

const mockSession = {
assertPerformAction: vi.fn().mockResolvedValue(undefined),
getViewer: vi.fn().mockResolvedValue({ email: 'user@example.com' }),
};
const mockClickHouse = {
query: vi.fn().mockResolvedValue({
data: [
{
id: '1',
timestamp: '2026-01-15',
organizationId: 'org-1',
eventAction: 'login',
userId: 'u1',
userEmail: 'u@x.com',
accessTokenId: null,
metadata: '{}',
},
],
}),
};
const mockLogger = { child: () => mockLogger, info: vi.fn(), error: vi.fn() } as any;
const mockTaskScheduler = { scheduleTask: vi.fn().mockResolvedValue(undefined) };
const mockStorage = {
getOrganization: vi.fn().mockResolvedValue({ name: 'TestOrg', id: 'org-1' }),
};

return {
manager: new AuditLogManager(
mockLogger,
mockClickHouse as any,
s3Config,
mockTaskScheduler as any,
mockSession as any,
mockStorage as any,
),
fetchSpy,
};
}

/**
* Guards against regression where the S3 PUT upload was missing
* `aws: { signQuery: true }`, causing the `got` library to receive
* auth headers it can't handle - resulting in silent upload failures.
*/
describe('AuditLogManager S3 upload uses signQuery', () => {
it('PUT upload includes aws.signQuery: true', async () => {
const { manager, fetchSpy } = createAuditLogManager(
vi.fn().mockResolvedValue({ ok: true, url: 'https://s3.example.com/key' }),
);

await manager.exportAndSendEmail('org-1', {
startDate: new Date('2026-01-01'),
endDate: new Date('2026-01-31'),
});

const putCall = fetchSpy.mock.calls[0];
expect(putCall[1]).toEqual(
expect.objectContaining({
method: 'PUT',
aws: expect.objectContaining({ signQuery: true }),
}),
);
});

it('GET presigned URL includes aws.signQuery: true', async () => {
const { manager, fetchSpy } = createAuditLogManager(
vi.fn().mockResolvedValue({ ok: true, url: 'https://s3.example.com/key' }),
);

await manager.exportAndSendEmail('org-1', {
startDate: new Date('2026-01-01'),
endDate: new Date('2026-01-31'),
});

const getCall = fetchSpy.mock.calls[1];
expect(getCall[1]).toEqual(
expect.objectContaining({
method: 'GET',
aws: expect.objectContaining({ signQuery: true }),
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ export class AuditLogManager {
'Content-Type': 'text/csv',
},
body: csvData,
aws: {
signQuery: true,
},
});

if (!uploadResult.ok) {
Expand Down
Loading
Loading