CDK で Glue Data Catalog 上のテーブルに Lake Formation による行やカラムレベルでのアクセス制限をかける

databaseaws

Lake Formation は Glue Data Catalog のメタデータと S3 上のデータに対して、RDBMS のような権限モデルでアクセス制御を提供するサービス。メタデータをエンジンに返す際の透過的な権限の確認と、S3 にアクセスする一時的なクレデンシャルの発行を担う。オブジェクト自体は取得できてしまうため行やカラムレベルでのフィルタはエンジン側の責務となる。EMR のような AWS 管理外の環境で参照する場合は注意が必要。

Lake Formation で制御するデータレイクのロケーションを設定し、サービス用のロールを作成する。

new lakeformation.CfnResource(this, 'DataLakeLocation', {
  resourceArn: `arn:aws:s3:::${dataBucket.bucketName}/employee_db/`,
  useServiceLinkedRole: true,
});

const lakeFormationServiceRole = new iam.Role(this, 'LakeFormationServiceRole', {
  assumedBy: new iam.ServicePrincipal('lakeformation.amazonaws.com'),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName('AWSLakeFormationDataAdmin'),
  ],
});

dataBucket.grantReadWrite(lakeFormationServiceRole);

このロールを Lake Formation の Admin にする。なお IAM の AdministratorAccess であっても Lake Formation の権限を持っていなければ DESCRIBE 含めて何もできない。次の設定を行うと、これから作るテーブルに関してはデフォルトで何の PrincipalPermissions もつかないため IAM 権限でアクセスできなくなるが、それ以前に作られていたデータベースのテーブルに関しては、IAM Policy を持つ全てのユーザーやロールを含む IAMAllowedPrincipals グループに Super 権限が付与されるので、Use only IAM access control ~ のチェックを外して Revoke しないと実質 Lake Formation によるアクセス制御が効かない。ただし、ロケーションのハイブリッドアクセスモードが有効だとデータベースやテーブルにオプトインしたプリンシパルのみ LakeFormation による制御の対象にすることができる。

const synthesizer = this.synthesizer as cdk.DefaultStackSynthesizer;
const cdkExecRoleArn = cdk.Fn.sub(synthesizer.cloudFormationExecutionRoleArn);

const dataLakeSettings = new lakeformation.CfnDataLakeSettings(this, 'DataLakeSettings', {
  admins: [
    { dataLakePrincipalIdentifier: lakeFormationServiceRole.roleArn },
    { dataLakePrincipalIdentifier: grantPermissionsFunction.role!.roleArn },
    { dataLakePrincipalIdentifier: cdkExecRoleArn },
  ],
  createDatabaseDefaultPermissions: [],
  createTableDefaultPermissions: [],
});

database.addDependency(dataLakeSettings);

テーブルの行やカラムを指定した DataCellsFilter を作成する。

const salesFilter = new lakeformation.CfnDataCellsFilter(this, 'SalesFilter', {
  tableCatalogId: this.account,
  databaseName: database.ref,
  tableName: table.ref,
  name: 'sales_department_filter',
  rowFilter: {
    filterExpression: "department = '営業部'",
  },
  columnNames: ['employee_id', 'name', 'department', 'position', 'hire_date'],
});

salesFilter.addDependency(table);

この DataCellsFilter を通して SELECT するための PrincipalPermissions を作成しようとしたところ、DataCellsFilter が stabilize しておらずスタックの更新に失敗してしまった。CustomResource でリトライをかけるとうまくいった。なお Lake Formation の API はスロットリングするのでまとめて更新する際は時間がかかる。

CDKのAwsCustomResourceでAWSのAPIを呼ぶ - sambaiz-net

// ../lambda/grant-lf-permissions/index.ts
import { LakeFormation } from '@aws-sdk/client-lakeformation';

const lakeformation = new LakeFormation({});

interface ResourceProperties {
  RoleArn: string;
  CatalogId: string;
  DatabaseName: string;
  TableName: string;
  FilterName: string;
}

export const handler = async (event: any): Promise<any> => {
  console.log('Event:', JSON.stringify(event, null, 2));

  const props = event.ResourceProperties as ResourceProperties;
  const { RoleArn, CatalogId, DatabaseName, TableName, FilterName } = props;

  const resource = {
    DataCellsFilter: {
      TableCatalogId: CatalogId,
      DatabaseName: DatabaseName,
      TableName: TableName,
      Name: FilterName,
    },
  };

  if (event.RequestType === 'Delete') {
    await lakeformation.revokePermissions({
      Principal: { DataLakePrincipalIdentifier: RoleArn },
      Resource: resource,
      Permissions: ['SELECT'],
    });

    return {
      PhysicalResourceId: event.PhysicalResourceId || 'SalesPermissions',
    };
  }

  // Create or Update
  await sleep(30000);

  await lakeformation.grantPermissions({
    Principal: { DataLakePrincipalIdentifier: RoleArn },
    Resource: resource,
    Permissions: ['SELECT'],
  });

  console.log('Successfully granted permissions');

  return {
    PhysicalResourceId: 'SalesPermissions',
  };
};

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

salesRole に DataCellsFilter sales_department_filter を紐づける。

const grantPermissionsFunction = new NodejsFunction(this, 'GrantPermissionsFunction', {
  entry: path.join(__dirname, '../lambda/grant-lf-permissions/index.ts'),
  handler: 'handler',
  runtime: lambda.Runtime.NODEJS_22_X,
  timeout: cdk.Duration.minutes(2),
});

grantPermissionsFunction.addToRolePolicy(new iam.PolicyStatement({
  actions: [
    'lakeformation:GrantPermissions',
    'lakeformation:RevokePermissions',
    'glue:GetDatabase',
    'glue:GetTable',
  ],
  resources: ['*'],
}));

const salesPermissionsProvider = new customResources.Provider(this, 'SalesPermissionsProvider', {
  onEventHandler: grantPermissionsFunction,
});

const salesPermissions = new cdk.CustomResource(this, 'SalesPermissions', {
  serviceToken: salesPermissionsProvider.serviceToken,
  properties: {
    RoleArn: salesRole.roleArn,
    CatalogId: this.account,
    DatabaseName: database.ref,
    TableName: table.ref,
    FilterName: 'sales_department_filter',
  },
});

salesPermissions.node.addDependency(salesFilter);

DataCellsFilter を経由しない直接のデータベースやテーブルへの権限付与は CfnPrincipalPermissions で行うことができた。

new lakeformation.CfnPrincipalPermissions(this, 'SalesDatabasePermissions', {
  principal: {
    dataLakePrincipalIdentifier: salesRole.roleArn,
  },
  resource: {
    database: {
      catalogId: this.account,
      name: database.ref,
    },
  },
  permissions: ['DESCRIBE'],
  permissionsWithGrantOption: [],
});

salesRole でクエリすると行とカラムがフィルタされて返ってくることが確認できる。

$ QUERY="SELECT * FROM employee_db.employees"

$ QUERY_ID=$(aws athena start-query-execution \
    --query-string "$QUERY" \
    --profile admin \
    --query QueryExecutionId \
    --output text) && \
  sleep 5 && \
  aws athena get-query-results \
    --query-execution-id "$QUERY_ID" \
    --profile admin \
    --query "ResultSet.Rows[*].Data[*].VarCharValue" \
    --output table
+-------------+-------------+-------------+-----------+----------+-------------------------+--------------+
|  employee_id|  name       |  department |  position |  salary  |  email                  |  hire_date   |
|  1001       |  山田太郎   |  営業部     |  部長     |  8000000 |  [email protected]     |  2015-04-01  |
|  1002       |  佐藤花子   |  営業部     |  課長     |  6500000 |  [email protected]       |  2017-06-15  |
|  1003       |  鈴木一郎   |  営業部     |  主任     |  5500000 |  [email protected]     |  2019-08-20  |
|  1004       |  田中美咲   |  営業部     |  一般     |  4500000 |  [email protected]     |  2021-04-01  |
|  1005       |  高橋健太   |  営業部     |  一般     |  4200000 |  [email protected]  |  2022-03-15  |
|  1006       |  伊藤由美   |  人事部     |  部長     |  7800000 |  [email protected]        |  2016-05-10  |
|  1007       |  渡辺直樹   |  人事部     |  課長     |  6200000 |  [email protected]   |  2018-07-01  |
|  1008       |  中村恵     |  人事部     |  主任     |  5300000 |  [email protected]   |  2020-03-15  |
|  1009       |  小林雅人   |  人事部     |  一般     |  4700000 |  [email protected]  |  2021-09-01  |
|  1010       |  加藤さくら |  人事部     |  一般     |  4500000 |  [email protected]       |  2022-06-01  |
+-------------+-------------+-------------+-----------+----------+-------------------------+--------------+

$ QUERY_ID=$(aws athena start-query-execution \
    --query-string "$QUERY" \
    --profile sales \
    --query QueryExecutionId \
    --output text) && \
  sleep 5 && \
  aws athena get-query-results \
    --query-execution-id "$QUERY_ID" \
    --profile sales \
    --query "ResultSet.Rows[*].Data[*].VarCharValue" \
    --output table
---------------------------------------------------------------------
|                          GetQueryResults                          |
+-------------+-----------+-------------+------------+--------------+
|  employee_id|  name     |  department |  position  |  hire_date   |
|  1001       |  山田太郎 |  営業部     |  部長      |  2015-04-01  |
|  1002       |  佐藤花子 |  営業部     |  課長      |  2017-06-15  |
|  1003       |  鈴木一郎 |  営業部     |  主任      |  2019-08-20  |
|  1004       |  田中美咲 |  営業部     |  一般      |  2021-04-01  |
|  1005       |  高橋健太 |  営業部     |  一般      |  2022-03-15  |
+-------------+-----------+-------------+------------+--------------+