Querying Audit Data¶
The value of auditing comes from correlating the Lake Formation authorization events (GetDataAccess) with the S3 records of the objects that were actually read or written (CloudTrail S3 Data Events or S3 Server Access Logs). Most teams do this with Amazon Athena — either on demand, or on a schedule that materializes a curated audit table for dashboards and alerting.
The two queries below stitch the two sides together. Use the first if you capture S3 access with CloudTrail S3 Data Events, and the second if you use S3 Server Access Logs.
Substitute your own table names
The queries reference example Athena table names built from the account ID 111122223333 (for example cloudtrail_logs_aws_cloudtrail_logs_111122223333_us_east_2 and s3_access_logs_2026_07). Replace them with the names of the Glue Data Catalog tables over your own CloudTrail, S3 Data Event, and S3 Server Access Log locations.
Query 1 — CloudTrail S3 Data Events joined to Lake Formation¶
-- s3_access: one row per S3 object operation, from the CloudTrail S3 data events.
with s3_access as (
select
eventtime as s3_eventtime,
-- Reconstruct the full s3:// path from the request parameters.
concat('s3://', json_extract_scalar(requestparameters, '$.bucketName'), '/',
json_extract_scalar(requestparameters, '$.key')) as s3_object_path,
-- Access made under a Lake Formation-vended credential carries a session name
-- beginning 'AWSLF-'. Anything else is direct (non-LF) S3 access.
case
when starts_with(reverse(split(reverse(useridentity.arn), '/')[1]), 'AWSLF-') then 'LF'
else 'DIRECT'
end as access_method,
eventid as s3_eventid,
-- Resolve the underlying IAM identity (the role, not the transient session).
case
when useridentity.type = 'IAMUser' then useridentity.arn
when useridentity.type = 'AssumedRole' then useridentity.sessioncontext.sessionissuer.arn
else null
end as s3_iam_arn,
useridentity.arn as s3_sts_arn,
-- Extract the session name (last '/'-delimited segment of the ARN). This is the
-- key we join on: it matches lakeFormationRoleSessionName on the LF event.
reverse(split(reverse(useridentity.arn), '/')[1]) as s3_sts_session_name,
sourceipaddress as s3_sourceipaddress,
useragent as s3_sourceuseragent,
errorcode as s3_errorcode
from
cloudtrail_logs_aws_cloudtrail_logs_111122223333_us_east_2
-- Filter to the S3 events we care about. Add or remove events based on your use case.
where
eventname in ('GetObject', 'PutObject', 'DeleteObject')
-- lf_getdata_access: one row per Lake Formation authorization decision.
), lf_getdata_access as (
select
-- Resolve the underlying IAM identity (the role, not the transient session).
case
when useridentity.type = 'IAMUser' then useridentity.arn
when useridentity.type = 'AssumedRole' then useridentity.sessioncontext.sessionissuer.arn
else null
end as lf_arn,
useridentity.arn as lf_sts_arn,
-- Identify the calling engine: managed engines are recognized by sourceIPAddress;
-- otherwise fall back to the platformType embedded in additionalAuditContext.
case
when sourceipaddress = 'ops.emr-serverless.amazonaws.com' then 'EMR-SERVERLESS'
when sourceipaddress = 'athena.amazonaws.com' then 'ATHENA'
when sourceipaddress = 'redshift-serverless.amazonaws.com' then 'REDSHIFT-SERVERLESS'
when sourceipaddress = 'redshift.amazonaws.com' then 'REDSHIFT'
when sourceipaddress = 'glue.amazonaws.com' then 'GLUE'
when json_extract_scalar(requestparameters, '$.auditContext.additionalAuditContext') is not null then
case when json_extract_scalar(json_extract_scalar(requestparameters,
'$.auditContext.additionalAuditContext'), '$.platformType') is not null
then json_extract_scalar(json_extract_scalar(requestparameters,
'$.auditContext.additionalAuditContext'), '$.platformType')
else 'UNKNOWN'
end
else useragent
end as lf_source,
-- Whether access was requested by table (tableArn) or by S3 data location.
case
when json_extract(requestparameters, '$.dataLocations') is not null then 'DATA_LOCATION'
when json_extract_scalar(requestparameters, '$.tableArn') is not null then 'TABLE'
else 'UNKNOWN'
end as lf_resource_type,
-- The resource that was authorized (the data location(s) or the table ARN).
case
when json_extract(requestparameters, '$.dataLocations') is not null
then json_format(json_extract(requestparameters, '$.dataLocations'))
when json_extract_scalar(requestparameters, '$.tableArn') is not null
then json_extract_scalar(requestparameters, '$.tableArn')
else 'UNKNOWN'
end as lf_resource,
-- The join key: the session name Lake Formation stamped on the vended credential.
case
when json_extract_scalar(additionaleventdata, '$.lakeFormationRoleSessionName') is not null
then json_extract_scalar(additionaleventdata, '$.lakeFormationRoleSessionName')
else 'UNKNOWN'
end as lf_session_name
from
cloudtrail_logs_aws_cloudtrail_logs_111122223333_us_east_2
where
eventname = 'GetDataAccess'
)
-- Join each S3 operation back to the LF authorization that vended its credential.
select
s3.s3_eventtime,
s3.s3_object_path,
access_method,
lf_resource,
-- For LF-mediated access, report the engine; for direct access, just 'DIRECT'.
case when access_method = 'LF' then lf_source else access_method end as req_source,
-- Prefer the LF principal when the row joined; otherwise the S3 caller.
case when lf_sts_arn is not null then lf_sts_arn else s3_sts_arn end as end_user_arn,
lf.*
from
s3_access s3
-- LEFT OUTER JOIN so S3 access with no matching LF event (e.g. direct access) is still shown.
left outer join
lf_getdata_access lf
on
s3.s3_sts_session_name = lf.lf_session_name;
Query 2 — S3 Server Access Logs joined to Lake Formation¶
-- s3_access: one row per S3 object operation, parsed from the S3 server access log rows.
with s3_access as (
select
-- Server access log timestamps are strings; parse to a real timestamp (null on bad rows).
try(parse_datetime(requestdatetime, 'dd/MMM/yyyy:HH:mm:ss Z')) as s3_eventtime,
concat('s3://', bucket_name, '/', key) as s3_object_path,
-- Access made under a Lake Formation-vended credential carries a session name
-- beginning 'AWSLF-'. Anything else is direct (non-LF) S3 access.
case
when starts_with(reverse(split(reverse(requester), '/')[1]), 'AWSLF-') then 'LF'
else 'DIRECT'
end as access_method,
requestid as s3_eventid,
requester as s3_sts_arn,
-- Extract the session name (last '/'-delimited segment of the requester ARN). This is
-- the key we join on: it matches lakeFormationRoleSessionName on the LF event.
reverse(split(reverse(requester), '/')[1]) as s3_sts_session_name,
remoteip as s3_sourceipaddress,
useragent as s3_sourceuseragent,
errorcode as s3_errorcode,
httpstatus as s3_httpstatus,
-- Bucket the HTTP status into a human-readable outcome.
case
when httpstatus like '2%' then 'successful'
when httpstatus = '401' then 'unauthorized'
when httpstatus like '4%' then 'unsuccessful'
when httpstatus like '5%' then 'system error'
else 'unknown'
end as s3_status
from
s3_access_logs_2026_07
-- Filter to the S3 operations we care about. Add or remove operations based on your use case.
where
operation in ('REST.GET.OBJECT', 'REST.PUT.OBJECT', 'REST.DELETE.OBJECT')
-- Skip rows with no/placeholder request time (e.g. some non-request log lines).
and requestdatetime is not null
and requestdatetime <> '-'
-- lf_getdata_access: one row per Lake Formation authorization decision.
), lf_getdata_access as (
select
-- Resolve the underlying IAM identity (the role, not the transient session).
case
when useridentity.type = 'IAMUser' then useridentity.arn
when useridentity.type = 'AssumedRole' then useridentity.sessioncontext.sessionissuer.arn
else null
end as lf_arn,
useridentity.arn as lf_sts_arn,
-- Identify the calling engine: managed engines are recognized by sourceIPAddress;
-- otherwise fall back to the platformType embedded in additionalAuditContext.
case
when sourceipaddress = 'ops.emr-serverless.amazonaws.com' then 'EMR-SERVERLESS'
when sourceipaddress = 'athena.amazonaws.com' then 'ATHENA'
when sourceipaddress = 'redshift-serverless.amazonaws.com' then 'REDSHIFT-SERVERLESS'
when sourceipaddress = 'redshift.amazonaws.com' then 'REDSHIFT'
when sourceipaddress = 'glue.amazonaws.com' then 'GLUE'
when json_extract_scalar(requestparameters, '$.auditContext.additionalAuditContext') is not null then
case when json_extract_scalar(json_extract_scalar(requestparameters,
'$.auditContext.additionalAuditContext'), '$.platformType') is not null
then json_extract_scalar(json_extract_scalar(requestparameters,
'$.auditContext.additionalAuditContext'), '$.platformType')
else 'UNKNOWN'
end
else useragent
end as lf_source,
-- Whether access was requested by table (tableArn) or by S3 data location.
case
when json_extract(requestparameters, '$.dataLocations') is not null then 'DATA_LOCATION'
when json_extract_scalar(requestparameters, '$.tableArn') is not null then 'TABLE'
else 'UNKNOWN'
end as lf_resource_type,
-- The resource that was authorized (the data location(s) or the table ARN).
case
when json_extract(requestparameters, '$.dataLocations') is not null
then json_format(json_extract(requestparameters, '$.dataLocations'))
when json_extract_scalar(requestparameters, '$.tableArn') is not null
then json_extract_scalar(requestparameters, '$.tableArn')
else 'UNKNOWN'
end as lf_resource,
-- The join key: the session name Lake Formation stamped on the vended credential.
case
when json_extract_scalar(additionaleventdata, '$.lakeFormationRoleSessionName') is not null
then json_extract_scalar(additionaleventdata, '$.lakeFormationRoleSessionName')
else 'UNKNOWN'
end as lf_session_name
from
cloudtrail_logs_aws_cloudtrail_logs_111122223333_us_east_2
where
eventname = 'GetDataAccess'
)
-- Join each S3 operation back to the LF authorization that vended its credential.
select
s3.s3_eventtime,
s3.s3_object_path,
access_method,
lf_resource,
-- For LF-mediated access, report the engine; for direct access, just 'DIRECT'.
case when access_method = 'LF' then lf_source else access_method end as req_source,
-- Prefer the LF principal when the row joined; otherwise the S3 caller.
case when lf_sts_arn is not null then lf_sts_arn else s3_sts_arn end as end_user_arn,
s3.s3_httpstatus,
s3.s3_status,
lf.*
from
s3_access s3
-- LEFT OUTER JOIN so S3 access with no matching LF event (e.g. direct access) is still shown.
left outer join
lf_getdata_access lf
on
s3.s3_sts_session_name = lf.lf_session_name;
How the join works¶
Both queries build two subqueries and join them on a session name:
s3_accessparses each S3 event. The requester/identity ARN ends in a session-name suffix; the query extracts it withreverse(split(reverse(arn), '/')[1])ass3_sts_session_name. If that session name starts withAWSLF-, the access was performed under credentials Lake Formation vended, soaccess_methodis set toLF; otherwise it isDIRECT.lf_getdata_accessparses eachGetDataAccessevent, deriving the calling engine (lf_source) fromsourceIPAddressand theplatformTypeinadditionalAuditContext, the resource (lf_resource) fromtableArnordataLocations, and the session name (lf_session_name) fromadditionalEventData.lakeFormationRoleSessionName.- The two are joined on
s3_sts_session_name = lf_session_name. Because Lake Formation stamps the vended session with the sameAWSLF-…name that appears in the subsequent S3 request, this join reconstructs, for each S3 object access, which principal was authorized, by which engine, against which catalog resource.
flowchart LR
A["Engine calls GetDataAccess<br/>(lakeformation.amazonaws.com)"] -->|"authorized"| B["LF vends short-lived session<br/>session name: AWSLF-…"]
B --> C["S3 GetObject / PutObject<br/>requester ARN carries AWSLF-… session name"]
A -.->|"lakeFormationRoleSessionName"| J{{"JOIN on session name"}}
C -.->|"s3_sts_session_name"| J
D["Direct S3 access<br/>(no LF-vended session)"] --> E["S3 GetObject / PutObject<br/>session name does NOT start with AWSLF-"]
E -.->|"access_method = DIRECT<br/>no LF row to join"| X["Unmapped to any GetDataAccess"]
Limitations of the session-name join
- Direct S3 access via Lake Formation is not mappable. When Lake Formation grants access by S3 data location (rather than by table), the
GetDataAccessevent does not populatelakeFormationRoleSessionName. There is no session name to linkGetDataAccess→ the assumed session → the S3 operation, so those S3 rows cannot be joined back to a Lake Formation event. They appear withaccess_method = 'LF'on the S3 side but find no matchingGetDataAccessrow (theLEFT OUTER JOINleaves thelf.*columns null). - Access mode affects what you see. Full Table Access and Fine-Grained Access Control produce different fields on the
GetDataAccessevent (see Lake Formation CloudTrail Events); FGAC access by Athena/EMR/Glue reads through Lake Formation, while some engines can also read S3 directly. Interpretaccess_methodandreq_sourcetogether when reasoning about a given access.
Sample output¶
Below is a trimmed, sanitized sample of what Query 1 returns (a subset of the columns; identifiers have been replaced with placeholders). Each row is one S3 object access, annotated with the Lake Formation authorization it correlates to. The three rows illustrate the cases described above:
| s3_eventtime | s3_object_path | access_method | req_source | end_user_arn | lf_resource | lf_session_name |
|---|---|---|---|---|---|---|
| 2026-07-08T21:01:59Z | s3://amzn-s3-demo-bucket/iceberg_warehouse/flights/flights_iceberg/data/year=2009/…parquet |
LF | ATHENA | arn:aws:sts::111122223333:assumed-role/Admin/admin-session |
arn:aws:glue:us-east-2:111122223333:table/flights/flights_iceberg |
AWSLF-00-AT-111122223333-EXAMPLE04 |
| 2026-07-02T15:18:45Z | s3://amzn-s3-demo-bucket/dbs/flights/flights_local/year=2007/… |
LF | EMR-SERVERLESS | arn:aws:sts::111122223333:assumed-role/EmrServerlessRuntimeRole-LF-FGAC/00exampleJob2,00exampleAttempt2 |
arn:aws:glue:us-east-2:111122223333:table/flights/flights_local |
AWSLF-00-NA-111122223333-EXAMPLE02 |
| 2026-07-06T20:45:32Z | s3://amzn-s3-demo-bucket/dbs/flights/flights_local/year=2005/… |
LF | arn:aws:sts::111122223333:assumed-role/LakeFormationServiceRoleForS3Access/AWSLF-00-NA-111122223333-EXAMPLE08 |
|||
| 2026-07-01T21:06:46Z | s3://aws-cloudtrail-logs-111122223333-us-east-2/AWSLogs/…/CloudTrail/…json.gz |
DIRECT | DIRECT | arn:aws:sts::111122223333:assumed-role/Admin/admin-session |
Reading the rows:
- Rows 1–2 (fully correlated): the S3 access joined to a
GetDataAccessevent, soreq_sourcenames the engine (Athena, EMR Serverless),end_user_arnis the Lake Formation principal, andlf_resourcenames the catalog table. This is the end-to-end engine-to-S3 mapping. - Row 3 (LF, but unmapped): the object was read under a Lake Formation–vended session (the requester ARN carries an
AWSLF-session name, soaccess_methodisLF), but thelf_*columns are empty. This is the data-location limitation described above — theGetDataAccessevent for data-location access did not populate a session name to join on.end_user_arnfalls back to the S3 caller (the Lake Formation S3 access role). - Row 4 (direct access): access that did not go through a Lake Formation session — here, a role reading CloudTrail log objects directly.
access_methodandreq_sourceare bothDIRECT, and there is no Lake Formation row to join.
Operationalizing the query¶
- Run on a schedule for continuous auditing. Rather than re-scanning raw logs on demand, run the join on a daily or hourly cadence (for example with an Athena
CREATE TABLE AS SELECTor scheduled query, or a Glue job) to materialize a curated audit table that dashboards and alerts read from. - Partition the source tables by date. CloudTrail and S3 Server Access Log tables grow quickly. Define them as partitioned tables (by year/month/day, or use partition projection) and constrain each run to the partitions for the period being audited. This bounds the amount of data scanned and keeps both cost and runtime predictable.
- Filter early. The subqueries already restrict to the relevant
eventname/operationvalues; adding a partition/time predicate in eachwhereclause further reduces scan volume for scheduled runs.