Skip to content

[DRAFT] feat: Phase 2 - implement comprehensive access control system for storage-construct #2871

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
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
12 changes: 12 additions & 0 deletions .changeset/grumpy-icons-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@aws-amplify/storage-construct': major
---

Initial release of standalone AmplifyStorage construct package

- Create new `@aws-amplify/storage-construct` as standalone CDK L3 construct
- Migrate AmplifyStorage implementation with CDK-native triggers
- Add `grantAccess(auth, accessDefinition)` method for access control
- Add `StorageAccessDefinition` type for structured access configuration
- Support all S3 bucket features (CORS, versioning, SSL enforcement, auto-delete)
- Provide comprehensive TypeScript declarations and API documentation
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions packages/storage-construct/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Be very careful editing this file. It is crafted to work around [this issue](https://github.com/npm/npm/issues/4479)

# First ignore everything
**/*

# Then add back in transpiled js and ts declaration files
!lib/**/*.js
!lib/**/*.d.ts

# Then ignore test js and ts declaration files
*.test.js
*.test.d.ts

# This leaves us with including only js and ts declaration files of functional code
110 changes: 110 additions & 0 deletions packages/storage-construct/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
## API Report File for "@aws-amplify/storage-construct"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import { CfnBucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import { EventType } from 'aws-cdk-lib/aws-s3';
import { IBucket } from 'aws-cdk-lib/aws-s3';
import { IFunction } from 'aws-cdk-lib/aws-lambda';
import { IRole } from 'aws-cdk-lib/aws-iam';
import { Policy } from 'aws-cdk-lib/aws-iam';
import { Stack } from 'aws-cdk-lib';

// @public
export class AmplifyStorage extends Construct {
constructor(scope: Construct, id: string, props: AmplifyStorageProps);
addTrigger: (events: EventType[], handler: IFunction) => void;
grantAccess: (auth: unknown, access: StorageAccessConfig) => void;
// (undocumented)
readonly isDefault: boolean;
// (undocumented)
readonly name: string;
// (undocumented)
readonly resources: StorageResources;
// (undocumented)
readonly stack: Stack;
}

// @public (undocumented)
export type AmplifyStorageProps = {
isDefault?: boolean;
name: string;
versioned?: boolean;
triggers?: Partial<Record<AmplifyStorageTriggerEvent, IFunction>>;
};

// @public (undocumented)
export type AmplifyStorageTriggerEvent = 'onDelete' | 'onUpload';

// @public
export class AuthRoleResolver {
getRoleForAccessType: (accessType: string, roles: AuthRoles, groups?: string[]) => IRole | undefined;
resolveRoles: () => AuthRoles;
validateAuthConstruct: (authConstruct: unknown) => boolean;
}

// @public (undocumented)
export type AuthRoles = {
authenticatedRole?: IRole;
unauthenticatedRole?: IRole;
groupRoles?: Record<string, IRole>;
};

// @public (undocumented)
export type InternalStorageAction = 'get' | 'list' | 'write' | 'delete';

// @public (undocumented)
export type StorageAccessConfig = {
[path: string]: StorageAccessRule[];
};

// @public (undocumented)
export type StorageAccessDefinition = {
role: IRole;
actions: StorageAction[];
idSubstitution: string;
};

// @public
export class StorageAccessOrchestrator {
constructor(policyFactory: StorageAccessPolicyFactory);
orchestrateStorageAccess: (accessDefinitions: Record<StoragePath, StorageAccessDefinition[]>) => void;
}

// @public
export class StorageAccessPolicyFactory {
constructor(bucket: IBucket);
// (undocumented)
createPolicy: (permissions: Map<InternalStorageAction, {
allow: Set<StoragePath>;
deny: Set<StoragePath>;
}>) => Policy;
}

// @public (undocumented)
export type StorageAccessRule = {
type: 'authenticated' | 'guest' | 'owner' | 'groups';
actions: Array<'read' | 'write' | 'delete'>;
groups?: string[];
};

// @public (undocumented)
export type StorageAction = 'read' | 'get' | 'list' | 'write' | 'delete';

// @public (undocumented)
export type StoragePath = `${string}/*`;

// @public (undocumented)
export type StorageResources = {
bucket: IBucket;
cfnResources: {
cfnBucket: CfnBucket;
};
};

// (No @packageDocumentation comment for this package)

```
3 changes: 3 additions & 0 deletions packages/storage-construct/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Description

Replace with a description of this package
4 changes: 4 additions & 0 deletions packages/storage-construct/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../api-extractor.base.json",
"mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts"
}
31 changes: 31 additions & 0 deletions packages/storage-construct/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@aws-amplify/storage-construct",
"version": "0.1.0",
"type": "module",
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./lib/index.d.ts",
"import": "./lib/index.js",
"require": "./lib/index.js"
}
},
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
"build": "tsc",
"clean": "rimraf lib",
"test": "node --test lib/construct.test.js",
"update:api": "api-extractor run --local"
},
"license": "Apache-2.0",
"dependencies": {
"@aws-amplify/backend-output-storage": "^1.3.1"
},
"peerDependencies": {
"aws-cdk-lib": "^2.189.1",
"constructs": "^10.0.0"
}
}
76 changes: 76 additions & 0 deletions packages/storage-construct/src/auth_role_resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it } from 'node:test';
import { AuthRoleResolver } from './auth_role_resolver.js';
import { App, Stack } from 'aws-cdk-lib';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import assert from 'node:assert';

void describe('AuthRoleResolver', () => {
void it('validates auth construct', () => {
const resolver = new AuthRoleResolver();

// Should return false for null/undefined
assert.equal(resolver.validateAuthConstruct(null), false);
assert.equal(resolver.validateAuthConstruct(undefined), false);

// Should return true for valid objects
assert.equal(resolver.validateAuthConstruct({}), true);
assert.equal(resolver.validateAuthConstruct({ mockAuth: true }), true);
});

void it('resolves roles with warning', () => {
const resolver = new AuthRoleResolver();

const roles = resolver.resolveRoles();

// Should return empty roles structure
assert.equal(roles.authenticatedRole, undefined);
assert.equal(roles.unauthenticatedRole, undefined);
assert.deepEqual(roles.groupRoles, {});
});

void it('gets role for access type', () => {
const app = new App();
const stack = new Stack(app);
const resolver = new AuthRoleResolver();

const authRole = new Role(stack, 'AuthRole', {
assumedBy: new ServicePrincipal('cognito-identity.amazonaws.com'),
});
const unauthRole = new Role(stack, 'UnauthRole', {
assumedBy: new ServicePrincipal('cognito-identity.amazonaws.com'),
});
const adminRole = new Role(stack, 'AdminRole', {
assumedBy: new ServicePrincipal('cognito-identity.amazonaws.com'),
});

const roles = {
authenticatedRole: authRole,
unauthenticatedRole: unauthRole,
groupRoles: { admin: adminRole },
};

// Test authenticated access
assert.equal(
resolver.getRoleForAccessType('authenticated', roles),
authRole,
);

// Test guest access
assert.equal(resolver.getRoleForAccessType('guest', roles), unauthRole);

// Test owner access (should use authenticated role)
assert.equal(resolver.getRoleForAccessType('owner', roles), authRole);

// Test group access
assert.equal(
resolver.getRoleForAccessType('groups', roles, ['admin']),
adminRole,
);

// Test unknown access type
assert.equal(resolver.getRoleForAccessType('unknown', roles), undefined);

// Test group access without groups
assert.equal(resolver.getRoleForAccessType('groups', roles), undefined);
});
});
66 changes: 66 additions & 0 deletions packages/storage-construct/src/auth_role_resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { IRole } from 'aws-cdk-lib/aws-iam';

export type AuthRoles = {
authenticatedRole?: IRole;
unauthenticatedRole?: IRole;
groupRoles?: Record<string, IRole>;
};

/**
* Resolves IAM roles from auth construct
*/
export class AuthRoleResolver {
/**
* Extract roles from auth construct
* This is a simplified implementation - in a real scenario, this would
* inspect the auth construct and extract the actual IAM roles
*/
resolveRoles = (): AuthRoles => {
// For now, return empty roles with warning
// In actual implementation, this would:
// 1. Check if authConstruct is an AmplifyAuth instance
// 2. Extract the Cognito Identity Pool roles
// 3. Extract any User Pool group roles

// AuthRoleResolver.resolveRoles is not fully implemented - returning empty roles

return {
authenticatedRole: undefined,
unauthenticatedRole: undefined,
groupRoles: {},
};
};

/**
* Validate auth construct
*/
validateAuthConstruct = (authConstruct: unknown): boolean => {
// Basic validation - in real implementation would check for proper auth construct
return authConstruct !== null && authConstruct !== undefined;
};

/**
* Get role for specific access type
*/
getRoleForAccessType = (
accessType: string,
roles: AuthRoles,
groups?: string[],
): IRole | undefined => {
switch (accessType) {
case 'authenticated':
return roles.authenticatedRole;
case 'guest':
return roles.unauthenticatedRole;
case 'groups':
if (groups && groups.length > 0 && roles.groupRoles) {
return roles.groupRoles[groups[0]]; // Return first group role for simplicity
}
return undefined;
case 'owner':
return roles.authenticatedRole; // Owner access uses authenticated role
default:
return undefined;
}
};
}
Loading
Loading