feat(auth,policy): add AWS SigV4 authentication and S3 bucket policy support
This commit is contained in:
301
test/test.auth.node.ts
Normal file
301
test/test.auth.node.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
ListBucketsCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
GetBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as smarts3 from '../ts/index.js';
|
||||
|
||||
let testSmarts3Instance: smarts3.Smarts3;
|
||||
let authClient: S3Client;
|
||||
let wrongClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3344;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
|
||||
async function streamToString(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('should start S3 server with auth enabled', async () => {
|
||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||
server: {
|
||||
port: TEST_PORT,
|
||||
silent: true,
|
||||
region: 'us-east-1',
|
||||
},
|
||||
storage: {
|
||||
cleanSlate: true,
|
||||
},
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [
|
||||
{
|
||||
accessKeyId: ACCESS_KEY,
|
||||
secretAccessKey: SECRET_KEY,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Authenticated client with correct credentials
|
||||
authClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: ACCESS_KEY,
|
||||
secretAccessKey: SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
// Client with wrong credentials
|
||||
wrongClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: 'WRONGKEY',
|
||||
secretAccessKey: 'WRONGSECRET',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Authenticated CRUD
|
||||
// ============================
|
||||
|
||||
tap.test('authenticated: should list buckets', async () => {
|
||||
const response = await authClient.send(new ListBucketsCommand({}));
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
expect(Array.isArray(response.Buckets)).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should create a bucket', async () => {
|
||||
const response = await authClient.send(new CreateBucketCommand({ Bucket: 'auth-test-bucket' }));
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should upload an object', async () => {
|
||||
const response = await authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
Body: 'Hello authenticated world!',
|
||||
ContentType: 'text/plain',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should download the object', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const content = await streamToString(response.Body as Readable);
|
||||
expect(content).toEqual('Hello authenticated world!');
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Wrong credentials → 403
|
||||
// ============================
|
||||
|
||||
tap.test('wrong credentials: should fail to list buckets', async () => {
|
||||
await expect(wrongClient.send(new ListBucketsCommand({}))).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('wrong credentials: should fail to get object', async () => {
|
||||
await expect(
|
||||
wrongClient.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Anonymous → 403 (no policy yet)
|
||||
// ============================
|
||||
|
||||
tap.test('anonymous: should fail to list buckets', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
tap.test('anonymous: should fail to get object (no policy)', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/hello.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Bucket policy: public read
|
||||
// ============================
|
||||
|
||||
tap.test('should PUT a public-read bucket policy', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Policy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should GET the bucket policy', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const policy = JSON.parse(response.Policy!);
|
||||
expect(policy.Statement[0].Sid).toEqual('PublicRead');
|
||||
});
|
||||
|
||||
tap.test('anonymous: should GET object after public-read policy', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/hello.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
const content = await resp.text();
|
||||
expect(content).toEqual('Hello authenticated world!');
|
||||
});
|
||||
|
||||
tap.test('anonymous: should still fail to PUT object (policy only allows GET)', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/anon-file.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'should fail',
|
||||
});
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Deny policy
|
||||
// ============================
|
||||
|
||||
tap.test('should PUT a deny policy that blocks authenticated delete', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
{
|
||||
Sid: 'DenyDelete',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: ['s3:DeleteObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Policy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should be denied delete by policy', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// DELETE bucket policy
|
||||
// ============================
|
||||
|
||||
tap.test('should DELETE the bucket policy', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should GET policy → 404 after deletion', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new GetBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Cleanup
|
||||
// ============================
|
||||
|
||||
tap.test('authenticated: delete object after policy removed', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('authenticated: delete the bucket', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketCommand({ Bucket: 'auth-test-bucket' }),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should stop the S3 server', async () => {
|
||||
await testSmarts3Instance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
335
test/test.policy-actions.node.ts
Normal file
335
test/test.policy-actions.node.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
CopyObjectCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
GetBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import * as smarts3 from '../ts/index.js';
|
||||
|
||||
let testSmarts3Instance: smarts3.Smarts3;
|
||||
let authClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3347;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
const BUCKET = 'actions-bucket';
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
async function putPolicy(statements: any[]) {
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({ Version: '2012-10-17', Statement: statements }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function clearPolicy() {
|
||||
await authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }));
|
||||
}
|
||||
|
||||
function denyStatement(action: string) {
|
||||
return {
|
||||
Sid: `Deny_${action.replace(':', '_')}`,
|
||||
Effect: 'Deny' as const,
|
||||
Principal: '*',
|
||||
Action: action,
|
||||
Resource: [
|
||||
`arn:aws:s3:::${BUCKET}`,
|
||||
`arn:aws:s3:::${BUCKET}/*`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('setup: start server, create bucket, upload object', async () => {
|
||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||
server: { port: TEST_PORT, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [{ accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }],
|
||||
},
|
||||
});
|
||||
|
||||
authClient = new S3Client({
|
||||
endpoint: BASE_URL,
|
||||
region: 'us-east-1',
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
await authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'obj.txt',
|
||||
Body: 'test content for actions',
|
||||
ContentType: 'text/plain',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Per-action deny enforcement
|
||||
// ============================
|
||||
|
||||
tap.test('Deny s3:ListBucket → authenticated ListObjects fails', async () => {
|
||||
await putPolicy([denyStatement('s3:ListBucket')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new ListObjectsV2Command({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:CreateBucket → authenticated CreateBucket on new bucket fails', async () => {
|
||||
// We need to create a policy on the target bucket, but the target doesn't exist yet.
|
||||
// Instead, we use a different approach: deny on existing bucket and test HeadBucket works
|
||||
// but for CreateBucket, use fetch to target a new bucket name with the deny check.
|
||||
// Actually, CreateBucket has no bucket policy to evaluate against (the bucket doesn't exist yet).
|
||||
// The deny would need to be on the bucket being created.
|
||||
// Since the bucket doesn't exist, there's no policy to load — so CreateBucket can't be denied via policy.
|
||||
// This is expected AWS behavior. Skip this test and note it.
|
||||
|
||||
// Verify CreateBucket still works (no policy can deny it since bucket doesn't exist yet)
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: 'new-test-bucket' }));
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: 'new-test-bucket' }));
|
||||
});
|
||||
|
||||
tap.test('Deny s3:DeleteBucket → authenticated DeleteBucket fails', async () => {
|
||||
await putPolicy([denyStatement('s3:DeleteBucket')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:GetObject → authenticated GetObject fails', async () => {
|
||||
await putPolicy([denyStatement('s3:GetObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:PutObject → authenticated PutObject fails', async () => {
|
||||
await putPolicy([denyStatement('s3:PutObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'new-obj.txt',
|
||||
Body: 'should fail',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:DeleteObject → authenticated DeleteObject fails', async () => {
|
||||
await putPolicy([denyStatement('s3:DeleteObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:PutObject → authenticated CopyObject fails (maps to s3:PutObject)', async () => {
|
||||
await putPolicy([denyStatement('s3:PutObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'obj-copy.txt',
|
||||
CopySource: `${BUCKET}/obj.txt`,
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:GetBucketPolicy → authenticated GetBucketPolicy fails', async () => {
|
||||
// First put a policy that denies GetBucketPolicy
|
||||
// We need to be careful: put the deny policy, then try to get it
|
||||
await putPolicy([denyStatement('s3:GetBucketPolicy')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
// Clear using direct delete (which isn't denied)
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:PutBucketPolicy → authenticated PutBucketPolicy fails (for second policy)', async () => {
|
||||
// First put a policy that denies PutBucketPolicy
|
||||
await putPolicy([denyStatement('s3:PutBucketPolicy')]);
|
||||
|
||||
// Now try to put another policy — should fail
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'SomeOtherPolicy',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:DeleteBucketPolicy → authenticated DeleteBucketPolicy fails', async () => {
|
||||
await putPolicy([denyStatement('s3:DeleteBucketPolicy')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
// We need another way to clean up — use fetch with auth to bypass? No, the deny is on all principals.
|
||||
// Actually, we can't clear the policy via SDK since delete is denied.
|
||||
// The server still denies it. We need to stop and restart or use a different mechanism.
|
||||
// For test cleanup, just stop the server at end and it will be wiped with cleanSlate on next start.
|
||||
});
|
||||
|
||||
tap.test('Recovery: remove deny policy → authenticated operations resume working', async () => {
|
||||
// The previous test left a deny policy on DeleteBucketPolicy.
|
||||
// But we can work around it by stopping/restarting or if the deny is still in place.
|
||||
// Actually, we denied s3:DeleteBucketPolicy but NOT s3:PutBucketPolicy.
|
||||
// So we can overwrite the policy with an empty-ish one, then delete.
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'AllowAll',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:*',
|
||||
Resource: [`arn:aws:s3:::${BUCKET}`, `arn:aws:s3:::${BUCKET}/*`],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Now all operations should work again
|
||||
const getResp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' })
|
||||
);
|
||||
expect(getResp.$metadata.httpStatusCode).toEqual(200);
|
||||
|
||||
const listResp = await authClient.send(
|
||||
new ListObjectsV2Command({ Bucket: BUCKET })
|
||||
);
|
||||
expect(listResp.$metadata.httpStatusCode).toEqual(200);
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Special cases
|
||||
// ============================
|
||||
|
||||
tap.test('ListAllMyBuckets always requires auth → anonymous fetch to / returns 403', async () => {
|
||||
const resp = await fetch(`${BASE_URL}/`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
tap.test('Auth disabled mode → anonymous full access works', async () => {
|
||||
// Start a second server with auth disabled
|
||||
const noAuthInstance = await smarts3.Smarts3.createAndStart({
|
||||
server: { port: 3348, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: { enabled: false, credentials: [] },
|
||||
});
|
||||
|
||||
// Anonymous operations should all work
|
||||
const listResp = await fetch('http://localhost:3348/');
|
||||
expect(listResp.status).toEqual(200);
|
||||
|
||||
// Create bucket via fetch
|
||||
const createResp = await fetch('http://localhost:3348/anon-bucket', { method: 'PUT' });
|
||||
expect(createResp.status).toEqual(200);
|
||||
|
||||
// Put object
|
||||
const putResp = await fetch('http://localhost:3348/anon-bucket/file.txt', {
|
||||
method: 'PUT',
|
||||
body: 'hello anon',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
// Get object
|
||||
const getResp = await fetch('http://localhost:3348/anon-bucket/file.txt');
|
||||
expect(getResp.status).toEqual(200);
|
||||
const text = await getResp.text();
|
||||
expect(text).toEqual('hello anon');
|
||||
|
||||
// Delete object
|
||||
const delObjResp = await fetch('http://localhost:3348/anon-bucket/file.txt', { method: 'DELETE' });
|
||||
expect(delObjResp.status).toEqual(204);
|
||||
|
||||
// Delete bucket
|
||||
const delBucketResp = await fetch('http://localhost:3348/anon-bucket', { method: 'DELETE' });
|
||||
expect(delBucketResp.status).toEqual(204);
|
||||
|
||||
await noAuthInstance.stop();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Teardown
|
||||
// ============================
|
||||
|
||||
tap.test('teardown: clean up and stop server', async () => {
|
||||
// Clean up any remaining objects
|
||||
try {
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }));
|
||||
} catch {
|
||||
// May already be deleted
|
||||
}
|
||||
try {
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
} catch {
|
||||
// May already be deleted
|
||||
}
|
||||
await testSmarts3Instance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
252
test/test.policy-crud.node.ts
Normal file
252
test/test.policy-crud.node.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
GetBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import * as smarts3 from '../ts/index.js';
|
||||
|
||||
let testSmarts3Instance: smarts3.Smarts3;
|
||||
let authClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3345;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
const BUCKET = 'policy-crud-bucket';
|
||||
|
||||
function makePolicy(statements: any[]) {
|
||||
return JSON.stringify({ Version: '2012-10-17', Statement: statements });
|
||||
}
|
||||
|
||||
const validStatement = {
|
||||
Sid: 'Test1',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
||||
};
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('setup: start S3 server with auth enabled', async () => {
|
||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||
server: { port: TEST_PORT, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [{ accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }],
|
||||
},
|
||||
});
|
||||
|
||||
authClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('setup: create bucket', async () => {
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
});
|
||||
|
||||
// ============================
|
||||
// CRUD tests
|
||||
// ============================
|
||||
|
||||
tap.test('GET policy on bucket with no policy → throws (NoSuchBucketPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT valid policy → 204', async () => {
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([validStatement]),
|
||||
})
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('GET policy back → returns matching JSON', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const policy = JSON.parse(response.Policy!);
|
||||
expect(policy.Version).toEqual('2012-10-17');
|
||||
expect(policy.Statement[0].Sid).toEqual('Test1');
|
||||
expect(policy.Statement[0].Effect).toEqual('Allow');
|
||||
});
|
||||
|
||||
tap.test('PUT updated policy (overwrite) → 204, GET returns new version', async () => {
|
||||
const updatedStatement = {
|
||||
Sid: 'Updated',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: ['s3:DeleteObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
||||
};
|
||||
|
||||
const putResp = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([updatedStatement]),
|
||||
})
|
||||
);
|
||||
expect(putResp.$metadata.httpStatusCode).toEqual(204);
|
||||
|
||||
const getResp = await authClient.send(
|
||||
new GetBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
const policy = JSON.parse(getResp.Policy!);
|
||||
expect(policy.Statement[0].Sid).toEqual('Updated');
|
||||
expect(policy.Statement[0].Effect).toEqual('Deny');
|
||||
});
|
||||
|
||||
tap.test('DELETE policy → 204', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('DELETE policy again (idempotent) → 204', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('GET policy after delete → throws', async () => {
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy on non-existent bucket → throws (NoSuchBucket)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'nonexistent-bucket-xyz',
|
||||
Policy: makePolicy([validStatement]),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT invalid JSON → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: '{not valid json!!!',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with wrong version → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2023-01-01',
|
||||
Statement: [validStatement],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with empty statements array → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with action missing s3: prefix → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([
|
||||
{
|
||||
Sid: 'BadAction',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['GetObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
||||
},
|
||||
]),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with resource missing arn:aws:s3::: prefix → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([
|
||||
{
|
||||
Sid: 'BadResource',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: ['policy-crud-bucket/*'],
|
||||
},
|
||||
]),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('Bucket deletion cleans up associated policy', async () => {
|
||||
// PUT a policy
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([validStatement]),
|
||||
})
|
||||
);
|
||||
|
||||
// Delete the bucket
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
|
||||
// Re-create the bucket
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
|
||||
// GET policy should now be gone
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Teardown
|
||||
// ============================
|
||||
|
||||
tap.test('teardown: delete bucket and stop server', async () => {
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
await testSmarts3Instance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
517
test/test.policy-eval.node.ts
Normal file
517
test/test.policy-eval.node.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
PutBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as smarts3 from '../ts/index.js';
|
||||
|
||||
let testSmarts3Instance: smarts3.Smarts3;
|
||||
let authClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3346;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
const BUCKET = 'eval-bucket';
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
async function streamToString(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
}
|
||||
|
||||
async function putPolicy(statements: any[]) {
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({ Version: '2012-10-17', Statement: statements }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function clearPolicy() {
|
||||
await authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }));
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('setup: start server, create bucket, upload object', async () => {
|
||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||
server: { port: TEST_PORT, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [{ accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }],
|
||||
},
|
||||
});
|
||||
|
||||
authClient = new S3Client({
|
||||
endpoint: BASE_URL,
|
||||
region: 'us-east-1',
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
await authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'test-obj.txt',
|
||||
Body: 'hello policy eval',
|
||||
ContentType: 'text/plain',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Principal matching
|
||||
// ============================
|
||||
|
||||
tap.test('Principal: "*" → anonymous fetch GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'PrincipalWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
const text = await resp.text();
|
||||
expect(text).toEqual('hello policy eval');
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Principal: {"AWS": "*"} → anonymous GET fails, authenticated GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AwsWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: '*' },
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Anonymous → no identity → Principal AWS:* doesn't match anonymous → NoOpinion → denied
|
||||
const anonResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(anonResp.status).toEqual(403);
|
||||
|
||||
// Authenticated → has identity → Principal AWS:* matches → Allow
|
||||
const authResp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(authResp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Principal: {"AWS": "arn:aws:iam::TESTAKID"} → authenticated GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'SpecificPrincipal',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: `arn:aws:iam::${ACCESS_KEY}` },
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(resp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Principal: {"AWS": "arn:aws:iam::WRONGKEY"} → authenticated GET still succeeds (default allow)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'WrongPrincipal',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::WRONGKEY' },
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Principal doesn't match our key → NoOpinion → default allow for authenticated
|
||||
const resp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(resp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Action matching
|
||||
// ============================
|
||||
|
||||
tap.test('Action: "s3:*" → anonymous can GET and PUT (wildcard matches all)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'S3Wildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-wildcard.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'wildcard put',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
// Clean up the object we created
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-wildcard.txt' }));
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Action: "*" → global wildcard matches all actions', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'GlobalWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: '*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-global.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'global wildcard',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-global.txt' }));
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Action: "s3:Get*" → anonymous can GET but not PUT (prefix wildcard)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'PrefixWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:Get*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-prefix.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'should fail',
|
||||
});
|
||||
expect(putResp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Action: ["s3:GetObject", "s3:PutObject"] → anonymous can GET and PUT but not DELETE', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'MultiAction',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject', 's3:PutObject'],
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-multi.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'multi action',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
const delResp = await fetch(`${BASE_URL}/${BUCKET}/anon-multi.txt`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(delResp.status).toEqual(403);
|
||||
|
||||
// Clean up
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-multi.txt' }));
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Resource ARN matching
|
||||
// ============================
|
||||
|
||||
tap.test('Resource: "arn:aws:s3:::eval-bucket/*" → anonymous GET of object succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'ResourceWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Resource: exact key → anonymous GET of that key succeeds, other key fails', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'ExactResource',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/test-obj.txt`,
|
||||
},
|
||||
]);
|
||||
|
||||
const goodResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(goodResp.status).toEqual(200);
|
||||
|
||||
// Other key → resource doesn't match → NoOpinion → denied for anonymous
|
||||
const badResp = await fetch(`${BASE_URL}/${BUCKET}/nonexistent.txt`);
|
||||
expect(badResp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Resource: wrong bucket ARN → NoOpinion → anonymous GET denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'WrongBucket',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: 'arn:aws:s3:::other-bucket/*',
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Resource: "*" → matches everything, anonymous GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'StarResource',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: '*',
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Deny-over-Allow priority
|
||||
// ============================
|
||||
|
||||
tap.test('Allow + Deny same action → anonymous GET denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowGet',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
{
|
||||
Sid: 'DenyGet',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Allow s3:* + Deny s3:DeleteObject → anonymous GET succeeds, DELETE denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowAll',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
{
|
||||
Sid: 'DenyDelete',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: 's3:DeleteObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const delResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`, { method: 'DELETE' });
|
||||
expect(delResp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Statement order does not matter: Deny first, Allow second → still denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'DenyFirst',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
{
|
||||
Sid: 'AllowSecond',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// NoOpinion fallback
|
||||
// ============================
|
||||
|
||||
tap.test('NoOpinion: policy allows PutObject only → authenticated GET falls through (default allow)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowPutOnly',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:PutObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Authenticated → NoOpinion → default allow
|
||||
const resp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(resp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('NoOpinion: same policy → anonymous GET falls through → default deny (403)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowPutOnly',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:PutObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Anonymous → NoOpinion for GetObject → default deny
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// IAM action mapping
|
||||
// ============================
|
||||
|
||||
tap.test('Policy allows s3:GetObject → anonymous HEAD object succeeds (HeadObject maps to s3:GetObject)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowGet',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`, { method: 'HEAD' });
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Policy allows s3:ListBucket → anonymous HEAD bucket succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowList',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:ListBucket',
|
||||
Resource: `arn:aws:s3:::${BUCKET}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}`, { method: 'HEAD' });
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Policy allows s3:ListBucket → anonymous GET bucket (list objects) succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowList',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:ListBucket',
|
||||
Resource: `arn:aws:s3:::${BUCKET}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}`);
|
||||
expect(resp.status).toEqual(200);
|
||||
const text = await resp.text();
|
||||
expect(text).toInclude('ListBucketResult');
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Teardown
|
||||
// ============================
|
||||
|
||||
tap.test('teardown: clean up and stop server', async () => {
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' }));
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
await testSmarts3Instance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user