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
6 changes: 6 additions & 0 deletions .changeset/adapter-s3-server-side-encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@baldim/core': minor
'@baldim/adapter-s3': minor
---

Add optional `serverSideEncryption` (`'AES256' | 'aws:kms'`) to `StoragePutObjectParams`. The S3 adapter forwards it as `ServerSideEncryption` on `PutObjectCommand`, restoring at-rest encryption requests without per-project middleware workarounds; writes without the param are unchanged.
3 changes: 2 additions & 1 deletion adapters/s3/src/s3-client.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ export class S3Client extends EventEmitter {
}

async putObject(params: StoragePutObjectParams): Promise<unknown> {
const { key, metadata, contentType, body, contentEncoding, contentLength, ifMatch, ifNoneMatch } = params;
const { key, metadata, contentType, body, contentEncoding, contentLength, ifMatch, ifNoneMatch, serverSideEncryption } = params;

return await this._executeOperation(async () => {
const keyPrefix = typeof this.config.keyPrefix === 'string' ? this.config.keyPrefix : '';
Expand All @@ -648,6 +648,7 @@ export class S3Client extends EventEmitter {
if (contentLength !== undefined) options.ContentLength = contentLength;
if (ifMatch !== undefined) options.IfMatch = ifMatch;
if (ifNoneMatch !== undefined) options.IfNoneMatch = ifNoneMatch;
if (serverSideEncryption !== undefined) options.ServerSideEncryption = serverSideEncryption;

const [ok, err, response] = await tryFn(() => this.sendCommand(new PutObjectCommand(options as unknown as ConstructorParameters<typeof PutObjectCommand>[0])));
this.emit('cl:PutObject', err || response, { key, metadata, contentType, body, contentEncoding, contentLength });
Expand Down
74 changes: 74 additions & 0 deletions adapters/s3/tests/server-side-encryption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Server } from 'node:http';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { S3Client } from '../src/index.js';

interface RecordedRequest {
method: string;
path: string;
encryptionHeader: string | undefined;
}

async function startFakeS3(): Promise<{ server: Server; requests: RecordedRequest[]; port: number }> {
const requests: RecordedRequest[] = [];
const server = createServer((req, res) => {
req.resume();
req.on('end', () => {
requests.push({
method: req.method ?? '',
path: req.url ?? '',
encryptionHeader: req.headers['x-amz-server-side-encryption'],
});
res.statusCode = 200;
res.setHeader('content-type', 'application/xml');
res.setHeader('etag', '"test-etag"');
res.end('<PutObjectResult><ETag>"test-etag"</ETag></PutObjectResult>');
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
return { server, requests, port };
}

function createClient(port: number): S3Client {
return new S3Client({
connectionString: `http://key:secret@127.0.0.1:${port}/sse-bucket?region=us-east-1`,
logLevel: 'silent',
httpClientOptions: { useReckerHandler: false },
}) as S3Client;
}

describe('@baldim/adapter-s3 server-side encryption', () => {
let fake: { server: Server; requests: RecordedRequest[]; port: number };

beforeAll(async () => {
fake = await startFakeS3();
});

afterAll(async () => {
await new Promise<void>((resolve) => fake.server.close(() => resolve()));
});

it('sends x-amz-server-side-encryption on PutObject when serverSideEncryption is set', async () => {
const client = createClient(fake.port);
await client.putObject({ key: 'encrypted/item', body: 'payload', serverSideEncryption: 'AES256' });
const put = fake.requests.find((r) => r.method === 'PUT' && r.path.includes('encrypted/item'));
expect(put).toBeDefined();
expect(put?.encryptionHeader).toBe('AES256');
await client.destroy();
});

it('omits the header by default and never sends it on reads', async () => {
const client = createClient(fake.port);
await client.putObject({ key: 'plain/item', body: 'payload' });
await client.getObject('plain/item');
const put = fake.requests.find((r) => r.method === 'PUT' && r.path.includes('plain/item'));
const get = fake.requests.find((r) => r.method === 'GET' && r.path.includes('plain/item'));
expect(put).toBeDefined();
expect(put?.encryptionHeader).toBeUndefined();
expect(get).toBeDefined();
expect(get?.encryptionHeader).toBeUndefined();
await client.destroy();
});
});
1 change: 1 addition & 0 deletions core/src/clients/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface StoragePutObjectParams {
contentLength?: number;
ifMatch?: string;
ifNoneMatch?: string;
serverSideEncryption?: 'AES256' | 'aws:kms';
}

export interface StorageCopyObjectParams {
Expand Down
Loading