One table, two analysts, two different answers, with PII hidden by policy instead of by copying data.
README
Format: AWS CDK (TypeScript) ยท Est. cost: < $3/month idle (KMS key + S3); Athena $5 per TB scanned; Macie optional
One table, two analysts, two different answers. Access is enforced by AWS Lake Formation, not by copying data.
| Persona | Rows | Columns |
|---|---|---|
datalake-analystfull |
all | all |
datalake-analystrestricted |
only segment = 'retail' |
everything except ssn and email |
S3 (KMS, versioned, access-logged) โโถ Lake Formation (registered location, LF-only permissions)
โโ Glue Data Catalog: secure_lake.customers
โโ grant: AnalystFull โ SELECT table
โโ data-cells filter retail_no_pii โ AnalystRestricted
Athena workgroup (KMS-encrypted results, engine v3) ยท Macie PII discovery (optional)
Deploy
npm install
npx cdk deploy -c adminArn=arn:aws:iam::123456789012:role/YourAdminRole
# add -c enableMacie=true if Macie is not yet enabled in this account/region
โ ๏ธ
AWS::LakeFormation::DataLakeSettingsreplaces the account's Lake Formation admin list and default permissions. Use a sandbox account, or add your existing admins via-c adminArn=....
The demo: same query, different results
QUERY="SELECT * FROM secure_lake.customers ORDER BY customer_id"
for ROLE in <AnalystFullRoleArn> <AnalystRestrictedRoleArn>; do
CREDS=$(aws sts assume-role --role-arn $ROLE --role-session-name demo --query Credentials --output json)
export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r .AccessKeyId) AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r .SecretAccessKey) AWS_SESSION_TOKEN=$(echo $CREDS | jq -r .SessionToken)
QID=$(aws athena start-query-execution --work-group secure-lake --query-string "$QUERY" --query QueryExecutionId --output text)
sleep 4; aws athena get-query-results --query-execution-id $QID --output text | head -15
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
done
The full analyst sees 10 rows ร 7 columns. The restricted analyst sees 5 retail rows with no ssn/email columns.
Find PII automatically (Macie)
aws macie2 create-classification-job --job-type ONE_TIME --name lake-pii-scan \
--s3-job-definition '{"bucketDefinitions":[{"accountId":"'$(aws sts get-caller-identity --query Account --output text)'","buckets":["<LakeBucketName>"]}]}'
Macie flags the ssn and email columns, which is the evidence for the filter you already enforce.
Clean up
npx cdk destroy
Files (7)
- README.md
- bin/app.ts
- lib/secure-data-lake-stack.ts
- sample-data/customers.csv
- cdk.json
- package.json
- tsconfig.json
# 09 ยท Secure Data Lake with Fine-Grained Access
**Format:** AWS CDK (TypeScript) ยท **Est. cost:** < $3/month idle (KMS key + S3); Athena $5 per TB scanned; Macie optional
**One table, two analysts, two different answers.** Access is enforced by **AWS Lake Formation**, not by copying data.
| Persona | Rows | Columns |
|---|---|---|
| `datalake-analystfull` | all | all |
| `datalake-analystrestricted` | only `segment = 'retail'` | everything **except `ssn` and `email`** |
```
S3 (KMS, versioned, access-logged) โโถ Lake Formation (registered location, LF-only permissions)
โโ Glue Data Catalog: secure_lake.customers
โโ grant: AnalystFull โ SELECT table
โโ data-cells filter retail_no_pii โ AnalystRestricted
Athena workgroup (KMS-encrypted results, engine v3) ยท Macie PII discovery (optional)
```
## Deploy
```bash
npm install
npx cdk deploy -c adminArn=arn:aws:iam::123456789012:role/YourAdminRole
# add -c enableMacie=true if Macie is not yet enabled in this account/region
```
> โ ๏ธ `AWS::LakeFormation::DataLakeSettings` **replaces** the account's Lake Formation admin list and default permissions.
> Use a sandbox account, or add your existing admins via `-c adminArn=...`.
## The demo: same query, different results
```bash
QUERY="SELECT * FROM secure_lake.customers ORDER BY customer_id"
for ROLE in <AnalystFullRoleArn> <AnalystRestrictedRoleArn>; do
CREDS=$(aws sts assume-role --role-arn $ROLE --role-session-name demo --query Credentials --output json)
export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r .AccessKeyId) AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r .SecretAccessKey) AWS_SESSION_TOKEN=$(echo $CREDS | jq -r .SessionToken)
QID=$(aws athena start-query-execution --work-group secure-lake --query-string "$QUERY" --query QueryExecutionId --output text)
sleep 4; aws athena get-query-results --query-execution-id $QID --output text | head -15
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
done
```
The full analyst sees 10 rows ร 7 columns. The restricted analyst sees **5 retail rows with no `ssn`/`email` columns**.
## Find PII automatically (Macie)
```bash
aws macie2 create-classification-job --job-type ONE_TIME --name lake-pii-scan \
--s3-job-definition '{"bucketDefinitions":[{"accountId":"'$(aws sts get-caller-identity --query Account --output text)'","buckets":["<LakeBucketName>"]}]}'
```
Macie flags the `ssn` and `email` columns, which is the evidence for the filter you already enforce.
## Clean up
```bash
npx cdk destroy
```
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { SecureDataLakeStack } from '../lib/secure-data-lake-stack';
const app = new cdk.App();
new SecureDataLakeStack(app, 'SecureDataLake', {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
// Your own admin role/user ARN so you can manage Lake Formation in the console.
adminArn: app.node.tryGetContext('adminArn'),
enableMacie: app.node.tryGetContext('enableMacie') === 'true',
});
import * as path from 'path';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as athena from 'aws-cdk-lib/aws-athena';
import * as glue from 'aws-cdk-lib/aws-glue';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as kms from 'aws-cdk-lib/aws-kms';
import * as lf from 'aws-cdk-lib/aws-lakeformation';
import * as macie from 'aws-cdk-lib/aws-macie';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
export interface SecureDataLakeStackProps extends cdk.StackProps {
adminArn?: string;
enableMacie: boolean;
}
const DB = 'secure_lake';
const TABLE = 'customers';
/**
* Lake Formation-governed data lake: one table, two analysts, two different answers.
* - analyst-full : every row and column
* - analyst-restricted : only segment='retail' rows, and never the ssn/email columns
*/
export class SecureDataLakeStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: SecureDataLakeStackProps) {
super(scope, id, props);
const key = new kms.Key(this, 'LakeKey', { enableKeyRotation: true, description: 'Data lake encryption' });
const accessLogs = new s3.Bucket(this, 'AccessLogs', {
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const lake = new s3.Bucket(this, 'LakeBucket', {
encryption: s3.BucketEncryption.KMS,
encryptionKey: key,
bucketKeyEnabled: true,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
serverAccessLogsBucket: accessLogs,
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const results = new s3.Bucket(this, 'AthenaResults', {
encryption: s3.BucketEncryption.KMS,
encryptionKey: key,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
lifecycleRules: [{ expiration: cdk.Duration.days(7) }],
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
new s3deploy.BucketDeployment(this, 'SampleData', {
sources: [s3deploy.Source.asset(path.join(__dirname, '..', 'sample-data'))],
destinationBucket: lake,
destinationKeyPrefix: `curated/${TABLE}/`,
});
// ---------------------------------------------------------------- Lake Formation is the authority
const cfnExecRole = `arn:${this.partition}:iam::${this.account}:role/cdk-hnb659fds-cfn-exec-role-${this.account}-${this.region}`;
const settings = new lf.CfnDataLakeSettings(this, 'LakeSettings', {
admins: [
{ dataLakePrincipalIdentifier: cfnExecRole },
...(props.adminArn ? [{ dataLakePrincipalIdentifier: props.adminArn }] : []),
],
// Turn OFF the legacy "IAMAllowedPrincipals" defaults so LF permissions are actually enforced.
createDatabaseDefaultPermissions: [],
createTableDefaultPermissions: [],
});
const lfRole = new iam.Role(this, 'LakeFormationDataAccessRole', {
assumedBy: new iam.ServicePrincipal('lakeformation.amazonaws.com'),
});
lake.grantRead(lfRole);
key.grantDecrypt(lfRole);
const location = new lf.CfnResource(this, 'LakeLocation', {
resourceArn: lake.arnForObjects('curated'),
useServiceLinkedRole: false,
roleArn: lfRole.roleArn,
});
location.node.addDependency(settings);
const database = new glue.CfnDatabase(this, 'Database', {
catalogId: this.account,
databaseInput: { name: DB, description: 'Governed by Lake Formation' },
});
database.node.addDependency(settings);
const table = new glue.CfnTable(this, 'CustomersTable', {
catalogId: this.account,
databaseName: DB,
tableInput: {
name: TABLE,
tableType: 'EXTERNAL_TABLE',
parameters: { classification: 'csv', 'skip.header.line.count': '1' },
storageDescriptor: {
location: `s3://${lake.bucketName}/curated/${TABLE}/`,
inputFormat: 'org.apache.hadoop.mapred.TextInputFormat',
outputFormat: 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat',
serdeInfo: {
serializationLibrary: 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe',
parameters: { 'field.delim': ',' },
},
columns: [
{ name: 'customer_id', type: 'string' },
{ name: 'full_name', type: 'string' },
{ name: 'email', type: 'string', comment: 'PII' },
{ name: 'ssn', type: 'string', comment: 'PII - restricted' },
{ name: 'state', type: 'string' },
{ name: 'segment', type: 'string' },
{ name: 'lifetime_value', type: 'double' },
],
},
},
});
table.node.addDependency(database);
// ---------------------------------------------------------------- personas
const analystRole = (name: string) => {
const role = new iam.Role(this, name, {
roleName: `datalake-${name.toLowerCase()}`,
assumedBy: new iam.AccountRootPrincipal(),
description: 'Assume this role to query the lake through Athena',
});
role.addToPolicy(
new iam.PolicyStatement({
actions: [
'lakeformation:GetDataAccess',
'glue:GetDatabase', 'glue:GetDatabases', 'glue:GetTable', 'glue:GetTables', 'glue:GetPartitions',
'athena:StartQueryExecution', 'athena:GetQueryExecution', 'athena:GetQueryResults',
'athena:GetWorkGroup', 'athena:StopQueryExecution', 'athena:ListWorkGroups',
],
resources: ['*'],
}),
);
results.grantReadWrite(role);
key.grantEncryptDecrypt(role);
return role;
};
const full = analystRole('AnalystFull');
const restricted = analystRole('AnalystRestricted');
const dbDescribe = (id: string, role: iam.Role) => {
const p = new lf.CfnPrincipalPermissions(this, id, {
principal: { dataLakePrincipalIdentifier: role.roleArn },
resource: { database: { catalogId: this.account, name: DB } },
permissions: ['DESCRIBE'],
permissionsWithGrantOption: [],
});
p.node.addDependency(database);
return p;
};
dbDescribe('FullDbDescribe', full);
dbDescribe('RestrictedDbDescribe', restricted);
const fullGrant = new lf.CfnPrincipalPermissions(this, 'FullTableSelect', {
principal: { dataLakePrincipalIdentifier: full.roleArn },
resource: { table: { catalogId: this.account, databaseName: DB, name: TABLE } },
permissions: ['SELECT', 'DESCRIBE'],
permissionsWithGrantOption: [],
});
fullGrant.node.addDependency(table);
const filter = new lf.CfnDataCellsFilter(this, 'RetailNoPiiFilter', {
tableCatalogId: this.account,
databaseName: DB,
tableName: TABLE,
name: 'retail_no_pii',
rowFilter: { filterExpression: "segment = 'retail'" },
columnWildcard: { excludedColumnNames: ['ssn', 'email'] },
});
filter.node.addDependency(table);
const restrictedGrant = new lf.CfnPrincipalPermissions(this, 'RestrictedFilteredSelect', {
principal: { dataLakePrincipalIdentifier: restricted.roleArn },
resource: {
dataCellsFilter: { tableCatalogId: this.account, databaseName: DB, tableName: TABLE, name: 'retail_no_pii' },
},
permissions: ['SELECT'],
permissionsWithGrantOption: [],
});
restrictedGrant.node.addDependency(filter);
const wg = new athena.CfnWorkGroup(this, 'Workgroup', {
name: 'secure-lake',
recursiveDeleteOption: true,
workGroupConfiguration: {
enforceWorkGroupConfiguration: true,
engineVersion: { selectedEngineVersion: 'Athena engine version 3' },
resultConfiguration: {
outputLocation: `s3://${results.bucketName}/`,
encryptionConfiguration: { encryptionOption: 'SSE_KMS', kmsKey: key.keyArn },
},
},
});
// ---------------------------------------------------------------- PII discovery
if (props.enableMacie) {
new macie.CfnSession(this, 'Macie', { status: 'ENABLED', findingPublishingFrequency: 'FIFTEEN_MINUTES' });
}
new cdk.CfnOutput(this, 'LakeBucketName', { value: lake.bucketName });
new cdk.CfnOutput(this, 'AnalystFullRoleArn', { value: full.roleArn });
new cdk.CfnOutput(this, 'AnalystRestrictedRoleArn', { value: restricted.roleArn });
new cdk.CfnOutput(this, 'AthenaWorkgroup', { value: wg.name });
}
}
customer_id,full_name,email,ssn,state,segment,lifetime_value
C-1001,Avery Johnson,avery.j@example.com,900-11-2001,TX,retail,1240.50
C-1002,Blake Rivera,blake.r@example.com,900-11-2002,CA,enterprise,98200.00
C-1003,Casey Nguyen,casey.n@example.com,900-11-2003,NY,retail,310.75
C-1004,Devon Patel,devon.p@example.com,900-11-2004,WA,government,45100.00
C-1005,Emerson Brooks,emerson.b@example.com,900-11-2005,FL,retail,875.20
C-1006,Finley Okafor,finley.o@example.com,900-11-2006,VA,government,61250.00
C-1007,Gray Martinez,gray.m@example.com,900-11-2007,TX,enterprise,120400.00
C-1008,Harper Kim,harper.k@example.com,900-11-2008,IL,retail,190.00
C-1009,Indigo Silva,indigo.s@example.com,900-11-2009,GA,retail,2215.40
C-1010,Jordan Lee,jordan.l@example.com,900-11-2010,CO,enterprise,73900.00
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": { "include": ["**"], "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules", "cdk.out"] },
"context": {}
}
{
"name": "09-secure-data-lake",
"version": "1.0.0",
"private": true,
"bin": { "app": "bin/app.js" },
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"deploy": "cdk deploy --all",
"destroy": "cdk destroy --all"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1000.0",
"ts-node": "^10.9.2",
"typescript": "~5.6.3"
},
"dependencies": {
"aws-cdk-lib": "^2.200.0",
"constructs": "^10.4.2"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["es2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"exclude": ["node_modules", "cdk.out"]
}
Build it yourself
Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.