AWS Lake Formation · 2026-11-06 · Lake Formation arc
AWS Lake Formation for MCP Servers: Resource Registration, Permission Grants, LF-Tag TBAC, and Governed Table Transactions
AliveMCP monitors MCP endpoints every 60 seconds, and among the AWS-integrated servers we probe, Lake Formation misconfiguration clusters around four failure modes that catch nearly every team on first contact: a server that calls RegisterResourceCommand and then immediately calls GrantPermissionsCommand on the new resource, without realizing that an unregistered path and a registered path are two completely different access-control regimes — before registration, Athena and Glue respect only IAM and S3 bucket policies, but after registration, Lake Formation permissions are the gate and GrantPermissionsCommand must have been called or the query returns AccessDeniedException; a server that calls PutDataLakeSettingsCommand to add a new IAM role as a data lake admin without first reading the existing settings, inadvertently replacing the entire admin list and locking every other admin out of the data lake; a server that grants table-level SELECT to a principal with the intent of restricting that principal to a subset of columns, unaware that a Table resource grant overrides any TableWithColumns column-level grant on the same table — to enforce column restriction the principal must have only a TableWithColumns grant, never a parallel Table grant; and a server that writes Parquet files to an S3 path governed by a Lake Formation governed table using PutObjectCommand directly, expecting Athena to pick them up, but Athena reads the Lake Formation object manifest and the files are invisible until they are registered via UpdateTableObjectsCommand inside a committed transaction. Four Lake Formation patterns form the complete data lake governance integration stack for MCP server builders. Resource registration and data lake settings with RegisterResourceCommand and GetDataLakeSettingsCommand is the foundation: you bring S3 locations under Lake Formation governance, configure which IAM principals are admins, and set default creation permissions that prevent permission drift in multi-team data lakes. Permission grants with GrantPermissionsCommand, BatchGrantPermissionsCommand, and ListPermissionsCommand are how you control who can query, modify, or manage every database, table, and column in the catalog — including column-level security via the TableWithColumns resource type. LF-tag based access control (TBAC) with CreateLFTagCommand and LFTagPolicy grants replaces per-resource permission management at scale — once you tag a database with environment=prod and grant an IAM role access to any table matching that tag, every new table added to that database is automatically accessible without a new grant. Governed table transactions with StartTransactionCommand and UpdateTableObjectsCommand add ACID semantics to data lake writes — files you upload to S3 are invisible to query engines until your transaction commits, readers always see a consistent snapshot, and you can time-travel to any point in the last 7 days via GetTableObjectsCommand with QueryAsOfTime. This guide synthesizes all four Lake Formation patterns into a complete data governance integration stack for MCP server builders.
TL;DR
- Resource registration — install
@aws-sdk/client-lakeformation; constructLakeFormationClientonce; before callingRegisterResourceCommand, always read the current admin list withGetDataLakeSettingsCommandand merge your changes into the existingDataLakeSettingsobject before callingPutDataLakeSettingsCommand— it replaces the entire settings object; callRegisterResourceCommand({ ResourceArn, UseServiceLinkedRole: true, HybridAccessEnabled: false })to bring an S3 bucket or prefix under Lake Formation governance; after registration, all Glue/Athena/EMR access to that path is mediated by Lake Formation permissions, not S3 bucket policies: The service-linked role (AWSServiceRoleForLakeFormationDataAccess) is created automatically on first registration if your MCP server role hasiam:CreateServiceLinkedRoleforlakeformation.amazonaws.com. If it does not, pre-create the role in IAM — Lake Formation reuses the existing role rather than trying to create a new one, soUseServiceLinkedRole: trueworks fine once the role exists. TheHybridAccessEnabled: trueoption evaluates both Lake Formation permissions and IAM/S3 policies with OR logic, granting access if either allows — use this for gradual migration from IAM-only governance. SetHybridAccessEnabled: false(the default) for strict Lake Formation-only control. Deregistering a resource withDeregisterResourceCommandreverts that path to IAM and S3 bucket policy control — Lake Formation permissions on that path become inert (they are not automatically removed but have no effect). - Permission grants — call
GrantPermissionsCommand({ Principal: { DataLakePrincipalIdentifier: roleArn }, Resource, Permissions, PermissionsWithGrantOption: [] }); the shape ofResourcedetermines valid permissions:TableacceptsSELECT/INSERT/DELETE/DESCRIBE/ALTER/DROP/SUPER,DatabaseacceptsCREATE_TABLE/DESCRIBE/ALTER/DROP/SUPER,TableWithColumnsacceptsSELECT/DESCRIBEonly; for column-level security useResource: { TableWithColumns: { DatabaseName, Name, ColumnNames: ['col1', 'col2'] } }— but only grantTableWithColumnsand never a parallelTablegrant to the same principal, or the table grant wins and all columns are exposed:GrantPermissionsCommandis idempotent — granting a permission that already exists does not error. UseBatchGrantPermissionsCommandto issue up to 20 grants in one call; failed entries land in theFailures[]array and do not throw, so always checkresponse.Failures?.length.RevokePermissionsCommandmust exactly match the original grant — if you grantedPermissions: ['SELECT', 'DESCRIBE'], you must revoke both; revokingSELECTalone leavesDESCRIBEin place and does not error. Revoking a permission that was never granted returnsInvalidInputException("Grantee has no such permission") — useListPermissionsCommandto audit before revoking.ListPermissionsCommandis paginated viaNextTokenand accepts optionalPrincipal,Resource, andResourceTypefilters for scoped auditing. - LF-tags TBAC — create a tag key with
CreateLFTagCommand({ TagKey: 'environment', TagValues: ['prod', 'staging', 'dev'] }); assign it to a database withAddLFTagsToResourceCommandand the tag propagates to all current and future tables in that database as a parent tag; grant access to all tables matching the tag withGrantPermissionsCommand({ Resource: { LFTagPolicy: { ResourceType: 'TABLE', Expression: [{ TagKey: 'environment', TagValues: ['prod'] }] } }, Permissions: ['SELECT'] })— any table later taggedenvironment=prodis automatically accessible to that principal without a new grant call: Expression evaluation: within oneLFTagExpressionentry, multipleTagValuesare OR (resource matches if it has any of those values for that key). Multiple entries in theExpressionarray are AND (resource must satisfy every entry). So[{ key: 'env', values: ['prod', 'staging'] }, { key: 'tier', values: ['gold'] }]means (env=prod OR env=staging) AND tier=gold.UpdateLFTagCommandcan add or remove allowed values, but removing a value that is currently assigned to any resource returnsConstraintViolationException— useSearchTablesByLFTagsCommandto find all resources using that value, runRemoveLFTagsFromResourceCommandon each, then delete the value.DeleteLFTagCommandis irreversible and silently orphans anyLFTagPolicygrants referencing the deleted key — revoke those grants first. - Governed table transactions — call
StartTransactionCommand({ TransactionType: 'READ_AND_WRITE' })to get aTransactionId; upload your Parquet file to S3 withPutObjectCommandand collect the ETag and byte size from the response; register the file withUpdateTableObjectsCommand({ TransactionId, DatabaseName, TableName, WriteOperations: [{ AddObject: { Uri, ETag, Size, PartitionValues } }] }); commit withCommitTransactionCommand({ TransactionId })— the file becomes visible to Athena, Glue, and EMR only after commit; if any step fails, callCancelTransactionCommandto abort (the S3 file is not deleted — clean it up with a lifecycle rule): Transactions have a 10-hour TTL — callExtendTransactionCommandbefore timeout for long-running batch writes. Lake Formation uses optimistic concurrency control at commit: if two transactions modified the same governed table's objects, the first to commit wins and the second receivesTransactionCanceledException("Write conflict") — your MCP tool must start a fresh transaction, re-register the objects, and retry the commit. The ETag at commit time must match the S3 object exactly — for multipart uploads the ETag is a composite hash in the format"hex-etag-N"; mismatch returnsResourceNotReadyException.GetTableObjectsCommandreturns the full object manifest (paginated viaNextToken) and supports aQueryAsOfTimeparameter for point-in-time reads up to 7 days back.
Pattern 1: RegisterResourceCommand, data lake settings, and the service-linked role
When an MCP server tool needs to bring an S3 data lake under centralized access control, Lake Formation resource registration is the right starting point. The model is: register S3 locations once, then all subsequent access control happens through Lake Formation permission grants rather than IAM policies on individual Glue resources and S3 bucket policies on individual prefixes.
Installing the SDK and creating the client
Lake Formation uses a dedicated package. Install it alongside @aws-sdk/client-glue and @aws-sdk/client-s3 if your MCP server manages the full data lake stack.
npm install @aws-sdk/client-lakeformation
Construct the client once at module initialization — it is stateless and safe to share across concurrent tool calls. Lake Formation is available in all regions that support Glue and Athena. Cross-region access is not supported — register resources and grant permissions in the same region as your Glue data catalog.
import { LakeFormationClient } from '@aws-sdk/client-lakeformation';
const lakeFormation = new LakeFormationClient({
region: process.env.AWS_REGION ?? 'us-east-1',
});
Reading and updating data lake settings safely
GetDataLakeSettingsCommand returns the account-level settings: which IAM principals are data lake admins (they bypass all permission checks), what permissions are granted by default when a new database or table is created, and whether cross-account sharing is enabled. Always read before writing — PutDataLakeSettingsCommand is a full replacement that overwrites the entire settings object. Calling it without first merging in the existing state silently removes all current admins.
import {
LakeFormationClient,
GetDataLakeSettingsCommand,
PutDataLakeSettingsCommand,
} from '@aws-sdk/client-lakeformation';
const lakeFormation = new LakeFormationClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function addDataLakeAdmin(roleArn: string): Promise<void> {
// Always read first — PutDataLakeSettings replaces the entire object
const current = await lakeFormation.send(new GetDataLakeSettingsCommand({}));
const existing = current.DataLakeSettings ?? {};
const admins = existing.DataLakeAdmins ?? [];
if (admins.some(a => a.DataLakePrincipalIdentifier === roleArn)) return;
admins.push({ DataLakePrincipalIdentifier: roleArn });
await lakeFormation.send(new PutDataLakeSettingsCommand({
DataLakeSettings: { ...existing, DataLakeAdmins: admins },
}));
}
// Lock down default permissions so new tables don't auto-grant SELECT to creators
export async function lockDownDefaults(): Promise<void> {
const current = await lakeFormation.send(new GetDataLakeSettingsCommand({}));
const existing = current.DataLakeSettings ?? {};
await lakeFormation.send(new PutDataLakeSettingsCommand({
DataLakeSettings: {
...existing,
CreateDatabaseDefaultPermissions: [],
CreateTableDefaultPermissions: [],
},
}));
}
The CreateDatabaseDefaultPermissions and CreateTableDefaultPermissions fields default to granting ALL to the IAM principal that creates the resource. In a multi-team environment this causes permission drift — analysts who create tables get implicit full access. Set both to empty arrays to enforce least-privilege from day one.
Registering an S3 location
RegisterResourceCommand brings an S3 URI under Lake Formation governance. After registration, calls to Athena, Glue, and EMR that read or write data at that path must hold Lake Formation permissions in addition to IAM permissions — S3 bucket policies alone are no longer sufficient.
import { RegisterResourceCommand } from '@aws-sdk/client-lakeformation';
export async function registerS3Bucket(bucketArn: string): Promise<void> {
await lakeFormation.send(new RegisterResourceCommand({
ResourceArn: bucketArn, // e.g. 'arn:aws:s3:::my-data-lake-bucket'
UseServiceLinkedRole: true,
HybridAccessEnabled: false, // strict LF-only governance
}));
}
// Register with a specific IAM role instead of the service-linked role
export async function registerWithCustomRole(
bucketArn: string,
roleArn: string,
): Promise<void> {
await lakeFormation.send(new RegisterResourceCommand({
ResourceArn: bucketArn,
UseServiceLinkedRole: false,
RoleArn: roleArn, // must have s3:GetObject and s3:PutObject
HybridAccessEnabled: false,
}));
}
The service-linked role (AWSServiceRoleForLakeFormationDataAccess) is what Lake Formation uses to vend temporary S3 credentials to query engines like Athena and Glue ETL. It is created automatically on the first RegisterResourceCommand call if your MCP server role has iam:CreateServiceLinkedRole scoped to lakeformation.amazonaws.com. If your security team pre-created the role, Lake Formation finds and reuses the existing role — UseServiceLinkedRole: true still works.
You can register a prefix (arn:aws:s3:::my-bucket/datalake/) rather than the entire bucket. This lets you govern some prefixes with Lake Formation while other prefixes in the same bucket remain under standard S3 ACL control — useful for gradual migrations.
The HybridAccessEnabled: true option is the right choice during a transition period: both Lake Formation permissions and IAM/S3 policies are evaluated, and access is granted if either allows. Once your permission migration is complete, re-register the resource with HybridAccessEnabled: false to enforce strict Lake Formation governance.
Listing and deregistering resources
ListResourcesCommand and DescribeResourceCommand let you audit which S3 locations are currently governed.
import {
ListResourcesCommand,
DescribeResourceCommand,
DeregisterResourceCommand,
} from '@aws-sdk/client-lakeformation';
export async function listRegisteredResources() {
const resources = [];
let nextToken: string | undefined;
do {
const response = await lakeFormation.send(
new ListResourcesCommand({ NextToken: nextToken })
);
resources.push(...(response.ResourceInfoList ?? []));
nextToken = response.NextToken;
} while (nextToken);
return resources.map(r => ({
resourceArn: r.ResourceArn,
roleArn: r.RoleArn,
lastModified: r.LastModified,
}));
}
export async function deregisterS3Bucket(resourceArn: string): Promise<void> {
await lakeFormation.send(
new DeregisterResourceCommand({ ResourceArn: resourceArn })
);
// After deregistration, Lake Formation permissions on this path
// become inert. Standard IAM/S3 policies take effect again.
// Existing permissions are NOT automatically deleted.
}
Deregistering a resource does not delete the Glue catalog entries pointing to it, nor does it remove the Lake Formation permission grants that referenced it — those grants just stop having any effect. If Athena jobs relied on the Lake Formation credential vending mechanism, they will start failing after deregistration until you either re-register the resource or add direct IAM/S3 credentials.
Pattern 2: GrantPermissionsCommand, resource types, and column-level security
Once S3 resources are registered, every Glue database and table in those registered locations needs explicit Lake Formation permission grants before any IAM principal can query them. Lake Formation permissions are the access control layer that sits between IAM and the actual data.
Resource types and valid permission sets
The shape of the Resource field in GrantPermissionsCommand determines which permission strings are accepted. Using a permission not applicable to a resource type returns InvalidInputException.
| Resource type | Valid permissions | Notes |
|---|---|---|
Catalog: {} | CREATE_DATABASE | Grants permission to create databases in the Glue catalog |
Database: { Name } | CREATE_TABLE, DESCRIBE, ALTER, DROP, SUPER | SUPER grants all database-level permissions |
Table: { DatabaseName, Name } | SELECT, INSERT, DELETE, DESCRIBE, ALTER, DROP, SUPER | Use TableWildcard: {} instead of Name to cover all tables in a database |
TableWithColumns: { DatabaseName, Name, ColumnNames } | SELECT, DESCRIBE | Column-level grant — INSERT/DELETE/ALTER not supported at column level |
DataLocation: { ResourceArn } | DATA_LOCATION_ACCESS | Grants permission to register new S3 paths with RegisterResourceCommand |
Granting table and database permissions
import {
LakeFormationClient,
GrantPermissionsCommand,
} from '@aws-sdk/client-lakeformation';
const lakeFormation = new LakeFormationClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
// Grant SELECT + DESCRIBE on a specific table
export async function grantTableSelect(
principalArn: string,
databaseName: string,
tableName: string,
): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: {
Table: { DatabaseName: databaseName, Name: tableName },
},
Permissions: ['SELECT', 'DESCRIBE'],
PermissionsWithGrantOption: [],
}));
}
// Grant CREATE_TABLE on a database
export async function grantDatabaseCreateTable(
principalArn: string,
databaseName: string,
): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: { Database: { Name: databaseName } },
Permissions: ['CREATE_TABLE', 'DESCRIBE'],
PermissionsWithGrantOption: [],
}));
}
// Grant SELECT on ALL tables in a database (wildcard)
export async function grantAllTablesSelect(
principalArn: string,
databaseName: string,
): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: {
Table: {
DatabaseName: databaseName,
TableWildcard: {}, // matches all current and future tables
},
},
Permissions: ['SELECT', 'DESCRIBE'],
PermissionsWithGrantOption: [],
}));
}
GrantPermissionsCommand is idempotent — granting a permission that already exists does not error. The PermissionsWithGrantOption array controls whether the grantee can further grant those permissions to other principals (the equivalent of SQL WITH GRANT OPTION). Leave it empty for most service accounts.
Column-level security with TableWithColumns
To restrict which columns a principal can query, use the TableWithColumns resource type instead of Table. You can specify an explicit ColumnNames allowlist or a ColumnWildcard with an ExcludedColumnNames denylist.
// Grant SELECT on only specific columns (allowlist)
export async function grantColumnSubset(
principalArn: string,
databaseName: string,
tableName: string,
allowedColumns: string[],
): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: {
TableWithColumns: {
DatabaseName: databaseName,
Name: tableName,
ColumnNames: allowedColumns, // ['user_id', 'event_type', 'timestamp']
},
},
Permissions: ['SELECT'],
PermissionsWithGrantOption: [],
}));
}
// Grant SELECT on all columns EXCEPT sensitive ones (denylist)
export async function grantAllColumnsExcept(
principalArn: string,
databaseName: string,
tableName: string,
excludedColumns: string[],
): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: {
TableWithColumns: {
DatabaseName: databaseName,
Name: tableName,
ColumnWildcard: {
ExcludedColumnNames: excludedColumns, // ['ssn', 'credit_card', 'email']
},
},
},
Permissions: ['SELECT'],
PermissionsWithGrantOption: [],
}));
}
The critical enforcement rule: a table-level Table grant always overrides a TableWithColumns grant for the same principal on the same table. If a principal holds both a Table: SELECT grant and a TableWithColumns: SELECT grant restricted to two columns, the table grant wins and they can see all columns. To enforce column restriction, the principal must have only a TableWithColumns grant — no parallel Table grant at any level, including wildcard grants via TableWildcard.
Batch granting and revoking
import {
BatchGrantPermissionsCommand,
RevokePermissionsCommand,
ListPermissionsCommand,
type BatchPermissionsRequestEntry,
} from '@aws-sdk/client-lakeformation';
export async function grantAnalyticsRolePermissions(
roleArn: string,
databaseName: string,
): Promise<void> {
const entries: BatchPermissionsRequestEntry[] = [
{
Id: 'db-describe',
Principal: { DataLakePrincipalIdentifier: roleArn },
Resource: { Database: { Name: databaseName } },
Permissions: ['DESCRIBE'],
PermissionsWithGrantOption: [],
},
{
Id: 'events-select',
Principal: { DataLakePrincipalIdentifier: roleArn },
Resource: { Table: { DatabaseName: databaseName, Name: 'events' } },
Permissions: ['SELECT', 'DESCRIBE'],
PermissionsWithGrantOption: [],
},
{
Id: 'users-columns',
Principal: { DataLakePrincipalIdentifier: roleArn },
Resource: {
TableWithColumns: {
DatabaseName: databaseName,
Name: 'users',
ColumnWildcard: { ExcludedColumnNames: ['ssn', 'dob'] },
},
},
Permissions: ['SELECT'],
PermissionsWithGrantOption: [],
},
];
const response = await lakeFormation.send(
new BatchGrantPermissionsCommand({ Entries: entries })
);
// BatchGrantPermissions does NOT throw on partial failure
if (response.Failures && response.Failures.length > 0) {
for (const failure of response.Failures) {
console.error(`Grant ${failure.RequestEntry?.Id} failed:`, failure.Error);
}
throw new Error(`${response.Failures.length} permission grants failed`);
}
}
BatchGrantPermissionsCommand handles up to 20 entries per call. The Id field is a client-side string for correlating errors in the Failures[] response array — it is not stored by Lake Formation. Failed entries do not cause the entire batch to throw; always check response.Failures?.length after the call.
For revoke, the Permissions and PermissionsWithGrantOption arrays must exactly match the original grant. Revoking a subset leaves the rest in place. Revoking a permission that was never granted returns InvalidInputException — always use ListPermissionsCommand to verify the grant exists before revoking.
// Audit all permissions for a specific role
export async function listRolePermissions(roleArn: string) {
const grants = [];
let nextToken: string | undefined;
do {
const response = await lakeFormation.send(new ListPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: roleArn },
NextToken: nextToken,
}));
grants.push(...(response.PrincipalResourcePermissions ?? []));
nextToken = response.NextToken;
} while (nextToken);
return grants;
}
Pattern 3: LF-tag TBAC — CreateLFTagCommand, tag assignment, and LFTagPolicy grants
LF-tag based access control (TBAC) addresses the scaling problem with direct permission grants: in a large data lake with hundreds of tables, granting permissions individually means a new IAM principal needs hundreds of separate GrantPermissionsCommand calls. TBAC inverts this — you define attributes (tags), attach them to resources, and grant principals access to any resource matching a tag expression. When a new table is tagged, it immediately inherits all existing tag-based grants without any additional API calls.
Creating and managing LF-tag keys
import {
LakeFormationClient,
CreateLFTagCommand,
UpdateLFTagCommand,
DeleteLFTagCommand,
GetLFTagCommand,
ListLFTagsCommand,
} from '@aws-sdk/client-lakeformation';
const lakeFormation = new LakeFormationClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function createTag(key: string, values: string[]): Promise<void> {
await lakeFormation.send(new CreateLFTagCommand({
TagKey: key, // e.g. 'environment'
TagValues: values, // e.g. ['prod', 'staging', 'dev']
}));
}
// Add new allowed values without removing existing ones
export async function addTagValues(key: string, newValues: string[]): Promise<void> {
await lakeFormation.send(new UpdateLFTagCommand({
TagKey: key,
TagValuesToAdd: newValues,
}));
}
// Remove allowed values — fails if any resource still uses that value
export async function removeTagValues(key: string, valuesToRemove: string[]): Promise<void> {
await lakeFormation.send(new UpdateLFTagCommand({
TagKey: key,
TagValuesToDelete: valuesToRemove,
// Returns ConstraintViolationException if any resource has this value assigned
}));
}
export async function listAllTags() {
const tags = [];
let nextToken: string | undefined;
do {
const response = await lakeFormation.send(
new ListLFTagsCommand({ NextToken: nextToken, ResourceShareType: 'ALL' })
);
tags.push(...(response.LFTags ?? []));
nextToken = response.NextToken;
} while (nextToken);
return tags;
}
DeleteLFTagCommand permanently removes a tag key and all its assignments from every resource in the account. It is irreversible. Any LFTagPolicy grants that reference the deleted key become orphaned — they remain in the system but match nothing. Before deleting a key, use SearchTablesByLFTagsCommand to find all resources using it, call RemoveLFTagsFromResourceCommand on each, and revoke any LFTagPolicy grants that reference the key.
Assigning tags to databases and tables
Tagging at the database level is usually the right approach: tags assigned to a database propagate to all its tables as parent tags, so you can govern an entire dataset family with one AddLFTagsToResourceCommand call. Tables can override inherited tags by setting their own value for the same key.
import {
AddLFTagsToResourceCommand,
RemoveLFTagsFromResourceCommand,
GetResourceLFTagsCommand,
} from '@aws-sdk/client-lakeformation';
// Tag a database — propagates to all tables in the database
export async function tagDatabase(
databaseName: string,
tags: Array<{ key: string; value: string }>,
): Promise<void> {
await lakeFormation.send(new AddLFTagsToResourceCommand({
Resource: { Database: { Name: databaseName } },
LFTags: tags.map(t => ({
TagKey: t.key,
TagValues: [t.value], // assign exactly one value per key per resource
})),
}));
}
// Tag a specific table (overrides any inherited database tag for the same key)
export async function tagTable(
databaseName: string,
tableName: string,
tags: Array<{ key: string; value: string }>,
): Promise<void> {
await lakeFormation.send(new AddLFTagsToResourceCommand({
Resource: { Table: { DatabaseName: databaseName, Name: tableName } },
LFTags: tags.map(t => ({ TagKey: t.key, TagValues: [t.value] })),
}));
}
// Get all tags assigned to a resource, including inherited parent tags
export async function getResourceTags(databaseName: string, tableName?: string) {
const resource = tableName
? { Table: { DatabaseName: databaseName, Name: tableName } }
: { Database: { Name: databaseName } };
const response = await lakeFormation.send(new GetResourceLFTagsCommand({
Resource: resource,
ShowAssignedLFTags: true, // include tags inherited from parent databases
}));
return {
lf_tags_on_database: response.LFTagsOnDatabase,
lf_tags_on_table: response.LFTagsOnTable,
lf_tags_on_columns: response.LFTagsOnColumns,
};
}
Granting permissions via LFTagPolicy expressions
An LFTagPolicy grant gives a principal access to any resource whose tags match the expression at query time — the match is evaluated dynamically, not cached at grant time. The ResourceType field ('DATABASE' or 'TABLE') scopes which Glue objects are matched.
import { GrantPermissionsCommand } from '@aws-sdk/client-lakeformation';
// Grant SELECT on all prod tables to an analytics role
export async function grantProdTablesSelect(principalArn: string): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: {
LFTagPolicy: {
ResourceType: 'TABLE',
Expression: [
{ TagKey: 'environment', TagValues: ['prod'] },
],
},
},
Permissions: ['SELECT', 'DESCRIBE'],
PermissionsWithGrantOption: [],
}));
}
// Multi-condition: prod AND gold tier — both conditions must match (AND logic)
export async function grantProdGoldTablesSelect(principalArn: string): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: {
LFTagPolicy: {
ResourceType: 'TABLE',
Expression: [
{ TagKey: 'environment', TagValues: ['prod'] }, // AND
{ TagKey: 'tier', TagValues: ['gold'] }, // AND
],
},
},
Permissions: ['SELECT'],
PermissionsWithGrantOption: [],
}));
}
// OR within a single key: resource matches if it has env=prod OR env=staging
export async function grantProdOrStagingSelect(principalArn: string): Promise<void> {
await lakeFormation.send(new GrantPermissionsCommand({
Principal: { DataLakePrincipalIdentifier: principalArn },
Resource: {
LFTagPolicy: {
ResourceType: 'TABLE',
Expression: [
{ TagKey: 'environment', TagValues: ['prod', 'staging'] },
],
},
},
Permissions: ['SELECT', 'DESCRIBE'],
PermissionsWithGrantOption: [],
}));
}
Expression evaluation rule: within a single LFTagExpression entry, multiple TagValues are evaluated as OR. Multiple entries in the Expression array are evaluated as AND. So [{ key: 'env', values: ['prod', 'staging'] }, { key: 'tier', values: ['gold'] }] means (env=prod OR env=staging) AND tier=gold. This covers common access control patterns without needing one grant per combination.
The grant is dynamic in both directions: when you tag a new table with environment=prod, any principal that holds an LFTagPolicy grant on that expression immediately gains access — no new GrantPermissionsCommand call is needed. Conversely, when you re-tag a table from environment=prod to environment=staging, principals whose grant expression includes prod but not staging immediately lose access to that table.
Use SearchTablesByLFTagsCommand and SearchDatabasesByLFTagsCommand to preview which resources a tag expression would currently match before issuing grants:
import { SearchTablesByLFTagsCommand } from '@aws-sdk/client-lakeformation';
export async function findTablesByTag(tagKey: string, tagValues: string[]) {
const tables = [];
let nextToken: string | undefined;
do {
const response = await lakeFormation.send(new SearchTablesByLFTagsCommand({
Expression: [{ TagKey: tagKey, TagValues: tagValues }],
NextToken: nextToken,
}));
tables.push(...(response.TableList ?? []));
nextToken = response.NextToken;
} while (nextToken);
return tables;
}
Pattern 4: Governed table transactions — StartTransactionCommand, UpdateTableObjects, and time travel
Lake Formation governed tables add ACID transaction semantics to data lake tables stored in S3. Standard unmanaged S3 tables are immediately visible once an object lands in the prefix — concurrent readers may see partial writes. Governed tables invert this: files you write to S3 are invisible to query engines until you commit a transaction that registers them, and readers always see a consistent, committed snapshot.
What governed tables are and when to use them
| Aspect | Unmanaged S3 table | Governed table |
|---|---|---|
| Object discovery | S3 prefix listing (LIST operation) | Lake Formation object manifest (GetTableObjectsCommand) |
| Write atomicity | Files visible immediately on PutObject | Files visible only after CommitTransactionCommand |
| Read isolation | No isolation — concurrent writes may cause partial reads | Snapshot isolation — readers always see committed state |
| Time travel | Not available without S3 versioning | GetTableObjectsCommand with QueryAsOfTime (7-day retention) |
| Write conflict detection | None — last writer wins | Optimistic concurrency — commit fails on conflict, retry required |
Governed tables work with Iceberg-compatible query engines: Athena v3, Glue ETL, and Spark on EMR. The table must be created in Glue with IsRegisteredWithLakeFormation: true to activate the manifest tracking. The Lake Formation API manages the manifest layer — the actual data is still stored in S3; Lake Formation tracks which files belong to the table at each committed transaction version.
Starting transactions
import {
LakeFormationClient,
StartTransactionCommand,
CommitTransactionCommand,
CancelTransactionCommand,
ExtendTransactionCommand,
GetTransactionCommand,
ListTransactionsCommand,
} from '@aws-sdk/client-lakeformation';
const lakeFormation = new LakeFormationClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function startWriteTransaction(): Promise<string> {
const response = await lakeFormation.send(new StartTransactionCommand({
TransactionType: 'READ_AND_WRITE',
}));
return response.TransactionId!;
}
export async function startReadTransaction(): Promise<string> {
const response = await lakeFormation.send(new StartTransactionCommand({
TransactionType: 'READ_ONLY',
// READ_ONLY provides a stable snapshot ID for consistent multi-table reads
// in query engines, but cannot call UpdateTableObjectsCommand
}));
return response.TransactionId!;
}
// Extend the TTL if a write takes longer than 10 hours
export async function extendTransaction(transactionId: string): Promise<void> {
await lakeFormation.send(new ExtendTransactionCommand({ TransactionId: transactionId }));
// Each extension resets the TTL to 10 hours from the extension time
}
Transactions auto-abort after 10 hours if not committed or canceled. For long-running batch writes (processing data in multiple chunks), call ExtendTransactionCommand before the timeout. Each call resets the TTL to a full 10 hours.
The complete write workflow
The workflow is four steps: upload the file to S3, start a transaction, register the file in the transaction, commit. Lake Formation guarantees readers won't see the file until step 4 completes.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import {
UpdateTableObjectsCommand,
CommitTransactionCommand,
CancelTransactionCommand,
} from '@aws-sdk/client-lakeformation';
const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function appendParquetToTable(
databaseName: string,
tableName: string,
bucket: string,
key: string,
parquetBuffer: Buffer,
partitionValues?: string[],
): Promise<void> {
// Step 1: upload the file to S3
const putResponse = await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: parquetBuffer,
ContentType: 'application/octet-stream',
}));
const etag = putResponse.ETag!.replace(/"/g, ''); // strip surrounding quotes
const size = parquetBuffer.length;
// Step 2: start a Lake Formation transaction
const txId = await startWriteTransaction();
try {
// Step 3: register the file in the transaction
await lakeFormation.send(new UpdateTableObjectsCommand({
TransactionId: txId,
DatabaseName: databaseName,
TableName: tableName,
WriteOperations: [{
AddObject: {
Uri: `s3://${bucket}/${key}`,
ETag: etag,
Size: size,
// PartitionValues order must match the table's partition key order in Glue
PartitionValues: partitionValues,
},
}],
}));
// Step 4: commit — file becomes visible to Athena/Glue/EMR only after this
const commitResponse = await lakeFormation.send(
new CommitTransactionCommand({ TransactionId: txId })
);
if (commitResponse.TransactionStatus !== 'committed') {
throw new Error(`Unexpected commit status: ${commitResponse.TransactionStatus}`);
}
} catch (error) {
// If anything fails, cancel the transaction
// The S3 file is still there — Lake Formation just won't expose it to readers
// Clean up orphaned files with an S3 lifecycle rule on the table prefix
await lakeFormation.send(new CancelTransactionCommand({ TransactionId: txId }));
throw error;
}
}
The ETag must exactly match the S3 object's ETag at commit time. For single-part uploads, the ETag is the MD5 of the content. For multipart uploads, the ETag is a composite hash in the format "hex-etag-N" where N is the number of parts — the format differs from single-part uploads. Lake Formation validates the ETag at commit and returns ResourceNotReadyException if it does not match, meaning the S3 object was modified between UpdateTableObjectsCommand and CommitTransactionCommand. Re-upload the file and retry the full transaction sequence.
Canceling a transaction does not delete the S3 files you uploaded — it only tells Lake Formation not to include them in the table manifest. These orphaned files must be cleaned up separately, typically via an S3 lifecycle rule on the table prefix configured to delete objects older than your expected transaction duration.
Removing objects in a transaction
Use DeleteObject in WriteOperations to remove files from the governed table manifest. This marks the objects as deleted in the Lake Formation view but does not delete the S3 objects — delete them separately after the transaction commits if you no longer need them.
export async function removeTableObjects(
transactionId: string,
databaseName: string,
tableName: string,
objects: Array<{ s3Uri: string; etag: string; partitionValues?: string[] }>,
): Promise<void> {
await lakeFormation.send(new UpdateTableObjectsCommand({
TransactionId: transactionId,
DatabaseName: databaseName,
TableName: tableName,
WriteOperations: objects.map(obj => ({
DeleteObject: {
Uri: obj.s3Uri,
ETag: obj.etag, // required for delete — prevents removing wrong version
PartitionValues: obj.partitionValues,
},
})),
}));
}
Reading the object manifest and time travel
GetTableObjectsCommand returns the list of S3 files that belong to the table at the latest committed state, or at any point in time within the 7-day retention window using QueryAsOfTime.
import {
GetTableObjectsCommand,
type GetTableObjectsCommandInput,
} from '@aws-sdk/client-lakeformation';
// Get current objects (latest committed state)
export async function getTableObjects(
databaseName: string,
tableName: string,
partitionPredicate?: string, // SQL predicate: 'dt = \'2026-11-06\''
) {
const objects = [];
let nextToken: string | undefined;
do {
const input: GetTableObjectsCommandInput = {
DatabaseName: databaseName,
TableName: tableName,
PartitionPredicate: partitionPredicate,
NextToken: nextToken,
};
const response = await lakeFormation.send(new GetTableObjectsCommand(input));
for (const partitionObj of response.Objects ?? []) {
objects.push(...(partitionObj.Objects ?? []).map(obj => ({
uri: obj.Uri,
etag: obj.ETag,
size: obj.Size,
partitionValues: partitionObj.PartitionValues,
})));
}
nextToken = response.NextToken;
} while (nextToken);
return objects;
}
// Time-travel read: get objects as of a specific point in time
export async function getTableObjectsAtTime(
databaseName: string,
tableName: string,
asOf: Date,
) {
const objects = [];
let nextToken: string | undefined;
do {
const response = await lakeFormation.send(new GetTableObjectsCommand({
DatabaseName: databaseName,
TableName: tableName,
QueryAsOfTime: asOf, // returns object manifest as of this timestamp
NextToken: nextToken,
}));
for (const partitionObj of response.Objects ?? []) {
objects.push(...(partitionObj.Objects ?? []));
}
nextToken = response.NextToken;
} while (nextToken);
return objects;
}
Time-travel reads are useful for auditing (what was in the table at the time a report ran?), re-processing (re-run a job against yesterday's snapshot), and debugging (compare the manifest before and after a problematic write). Lake Formation retains transaction history for 7 days — queries against timestamps older than the retention window return an error.
Transaction state machine and conflict handling
| State | Meaning | Transitions to |
|---|---|---|
ACTIVE | Open — writes allowed via UpdateTableObjectsCommand | COMMIT_IN_PROGRESS (on CommitTransactionCommand) or ABORTED (on Cancel or timeout) |
COMMIT_IN_PROGRESS | Commit accepted — Lake Formation validating ETag consistency | COMMITTED (success) or ABORTED (ETag mismatch or write conflict) |
COMMITTED | Objects visible to readers | Terminal; history retained for 7 days |
ABORTED | Canceled or auto-aborted after 10-hour TTL | Terminal; orphaned S3 files must be cleaned up manually |
Lake Formation uses optimistic concurrency control during commit. If two transactions both modified the same governed table objects, the first to commit wins and the second receives TransactionCanceledException with the message "Write conflict". Your MCP tool should catch this, cancel the failed transaction, and retry the full write sequence — re-upload if needed, start a new transaction, re-register objects, commit again.
export async function listActiveTransactions() {
const transactions = [];
let nextToken: string | undefined;
do {
const response = await lakeFormation.send(new ListTransactionsCommand({
StatusFilter: 'ACTIVE', // 'ACTIVE' | 'COMMITTED' | 'ABORTED' | 'ALL'
NextToken: nextToken,
}));
transactions.push(...(response.Transactions ?? []));
nextToken = response.NextToken;
} while (nextToken);
return transactions;
}
Use ListTransactionsCommand with StatusFilter: 'ACTIVE' to detect stuck transactions (started but never committed or canceled) — a common sign of an MCP tool that failed mid-write without cleanup.
IAM reference: permissions for the MCP server role
The MCP server's IAM role needs Lake Formation control-plane permissions. Data lake admins bypass permission checks entirely, but for a least-privilege server role, scope the IAM policy to the operations you need. The Lake Formation data-plane operations (Athena, Glue reads) are governed by Lake Formation permission grants — the IAM policy below covers the control-plane API calls the MCP server makes.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "LakeFormationSettings",
"Effect": "Allow",
"Action": [
"lakeformation:GetDataLakeSettings",
"lakeformation:PutDataLakeSettings"
],
"Resource": "*"
},
{
"Sid": "LakeFormationResources",
"Effect": "Allow",
"Action": [
"lakeformation:RegisterResource",
"lakeformation:DeregisterResource",
"lakeformation:ListResources",
"lakeformation:DescribeResource"
],
"Resource": "*"
},
{
"Sid": "LakeFormationPermissions",
"Effect": "Allow",
"Action": [
"lakeformation:GrantPermissions",
"lakeformation:BatchGrantPermissions",
"lakeformation:RevokePermissions",
"lakeformation:BatchRevokePermissions",
"lakeformation:ListPermissions"
],
"Resource": "*"
},
{
"Sid": "LakeFormationTags",
"Effect": "Allow",
"Action": [
"lakeformation:CreateLFTag",
"lakeformation:UpdateLFTag",
"lakeformation:DeleteLFTag",
"lakeformation:GetLFTag",
"lakeformation:ListLFTags",
"lakeformation:AddLFTagsToResource",
"lakeformation:RemoveLFTagsFromResource",
"lakeformation:GetResourceLFTags",
"lakeformation:SearchTablesByLFTags",
"lakeformation:SearchDatabasesByLFTags"
],
"Resource": "*"
},
{
"Sid": "LakeFormationTransactions",
"Effect": "Allow",
"Action": [
"lakeformation:StartTransaction",
"lakeformation:CommitTransaction",
"lakeformation:CancelTransaction",
"lakeformation:ExtendTransaction",
"lakeformation:GetTransaction",
"lakeformation:ListTransactions",
"lakeformation:UpdateTableObjects",
"lakeformation:GetTableObjects"
],
"Resource": "*"
},
{
"Sid": "GlueDataCatalogRead",
"Effect": "Allow",
"Action": [
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables"
],
"Resource": "*"
},
{
"Sid": "CreateServiceLinkedRole",
"Effect": "Allow",
"Action": "iam:CreateServiceLinkedRole",
"Resource": "arn:aws:iam::*:role/aws-service-role/lakeformation.amazonaws.com/*",
"Condition": {
"StringEquals": {
"iam:AWSServiceName": "lakeformation.amazonaws.com"
}
}
}
]
}
Remove the iam:CreateServiceLinkedRole statement if you pre-create the service-linked role before the first RegisterResourceCommand call. The glue:GetDatabase and glue:GetTable permissions are needed if your MCP server resolves database or table ARNs from names before calling Lake Formation APIs. For governed table writes, the server role also needs s3:PutObject on the table's S3 bucket.
Error handling reference
| Error | Pattern | Cause | Fix |
|---|---|---|---|
AlreadyExistsException | Registration, tags | RegisterResourceCommand on a path already registered; CreateLFTagCommand with existing key | Call DescribeResourceCommand / GetLFTagCommand first; skip if already exists |
EntityNotFoundException | All patterns | DeregisterResourceCommand on unregistered ARN; tag key doesn't exist; transaction ID not found; governed table not marked IsRegisteredWithLakeFormation | Verify resource exists before operating on it |
InvalidInputException | Permissions | Permission not valid for resource type; revoking a non-existent grant; ResourceArn is not a valid S3 ARN | Check resource type/permission compatibility table; use ListPermissionsCommand before revoking |
ConstraintViolationException | Tags | DeleteLFTagCommand or UpdateLFTagCommand removing a tag value still assigned to resources | SearchTablesByLFTagsCommand → RemoveLFTagsFromResourceCommand on each result, then retry |
ConcurrentModificationException | Permissions | Concurrent GrantPermissions/RevokePermissions on the same resource | Retry with exponential backoff — Lake Formation uses optimistic concurrency on permission records |
TransactionCanceledException | Governed tables | Transaction aborted (by Cancel, timeout, or write conflict at commit) | Start new transaction and retry write; check TransactionStatus first |
ResourceNotReadyException | Governed tables | ETag mismatch at commit — S3 object was modified after UpdateTableObjectsCommand | Re-upload the file, re-register with new ETag, retry commit |
AccessDeniedException | All patterns | Missing lakeformation:* IAM permission; or caller not a data lake admin and lacks GrantPermissions | Add missing action to MCP server IAM policy; or add caller as data lake admin via PutDataLakeSettings |
OperationTimeoutException | All patterns | Request timed out — Lake Formation APIs have higher latency than S3 | Retry with exponential backoff; use 5–30s SDK timeout for governed table operations |
Related pages
- Lake Formation overview — RegisterResourceCommand, data lake settings, IAM
- Lake Formation permissions — GrantPermissionsCommand, column security, batch operations
- Lake Formation LF-tags — TBAC, CreateLFTagCommand, LFTagPolicy expressions
- Lake Formation governed tables — StartTransactionCommand, ACID transactions, time travel
- AWS Glue Data Catalog for MCP servers
- Athena federated queries for MCP servers
- Athena Iceberg tables for MCP servers