09 ยท A Secure Data Lake with Row- and Column-Level Security

Code Repository / projects / 09-secure-data-lake
Project 09 ยท AWS CDK (TypeScript)

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::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

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 ยท 53 lines
# 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
```
bin/app.ts ยท 12 lines
#!/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',
});
lib/secure-data-lake-stack.ts ยท 215 lines
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 });
  }
}
sample-data/customers.csv ยท 12 lines
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
cdk.json ยท 6 lines
{
  "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": {}
}
package.json ยท 23 lines
{
  "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"
  }
}
tsconfig.json ยท 14 lines
{
  "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.