Tag DynamoDB and S3 client spans with the owning AWS account - #12602
pedramsafaei wants to merge 1 commit into
Conversation
mhlidd
left a comment
There was a problem hiding this comment.
LGTM from SDK Capabilities POV
574076d to
e1b9ef2
Compare
PerfectSlayer
left a comment
There was a problem hiding this comment.
Looking good from platform POV. Thanks for the follow up changes!
There was a problem hiding this comment.
Looks like we could use AWS SDK provided ARN parser for a good chunk of the work here, any reason to reimplement it ourselves here ?
https://docs.aws.amazon.com/java/api/latest/software/amazon/awssdk/arns/Arn.html
Also, if we want to keep our own implem, I think we should parse it once in an object. Here we end up checking several times if an ARN is valid for instance when we retrieve multiple values.
There was a problem hiding this comment.
The arns module is not a dependency of aws-core at the 2.2.0 floor this instrumentation compiles against (checked the 2.2.0 POM), and SDK v1 has no equivalent, so depending on it would break loading on older v2 and be unusable from the v1 module. Agreed on parsing once though pushed a new change that replaces the per-field helpers with an AwsArn value type parsed in a single pass, and both decorators call parse once.
| tableName = resource.substring("table/".length()); | ||
| int slash = tableName.indexOf('/'); | ||
| if (slash > 0) { | ||
| tableName = tableName.substring(0, slash); |
There was a problem hiding this comment.
doing the substring once with beginning and end would be more efficient (it'd save an intermediary string)
There was a problem hiding this comment.
Done. The extraction moved into AwsArn.dynamoDbTableName() and does one substring(begin, end).
| String account = AwsAccountIdentity.accountFromArn(tableName); | ||
| String resource = AwsAccountIdentity.resourceFromArn(tableName); | ||
| if (resource != null && resource.startsWith("table/")) { | ||
| span.setTag(InstrumentationTags.AWS_TABLE_ARN, tableName); |
There was a problem hiding this comment.
isn't it weird that if we found that the tableName is an ARN but the resource doesn't start with "table/" then we don't save it in the "AWS_TABLE_ARN" tag ?
There was a problem hiding this comment.
no it is intentional, but it deserved to be explicit. An ARN whose resource is not a table (a backup or export ARN, or a mistaken value) is not a table ARN, so tagging it as aws.table.arn would be wrong so in that case the raw value stays as the table name exactly as before this PR. In the new commit I pushed that decision lives in AwsArn.dynamoDbTableName() returning null, with a comment at both call sites.
| } | ||
| String expectedBucketOwner = access.getExpectedBucketOwner(originalRequest); | ||
| if (AwsAccountIdentity.isAccountId(expectedBucketOwner)) { | ||
| // Asserted by the caller and enforced by S3 (403 on mismatch), so authoritative when set. |
There was a problem hiding this comment.
if it's authoritative when set, why do we need to check if the returned value is an account ID ?
There was a problem hiding this comment.
Fair, the comment was misleading. It is authoritative for requests S3 accepts, but the span is tagged before the response is known, and S3 rejects a malformed owner (400) or a wrong one (403). Without the check a request the application got wrong would stamp aws_account=not-an-account on its error span. Kept the check and reworded the comment in the new commit the "malformed owner" test should cover it.
| if (resource != null && resource.startsWith("table/")) { | ||
| name = resource.substring("table/".length()); | ||
| int slash = name.indexOf('/'); | ||
| if (slash > 0) { | ||
| // arn:...:table/<name>/index/<index> and similar sub-resources | ||
| name = name.substring(0, slash); | ||
| } | ||
| tableArn = tableName; |
There was a problem hiding this comment.
looks like this duplicated part could go in the common helper
There was a problem hiding this comment.
new tests should be written in java using jUnit, not in groovy (except if strongly interconnected with existing tests, but I don't think it's the case here ?)
There was a problem hiding this comment.
Done. Now the only Groovy changes are new rows in the existing Aws2ClientTest and AWS1ClientTest.
|
|
||
| // Owning account of the addressed resource. aws_account matches the tag dd-trace-py sets and the | ||
| // dimension tag on the AWS integration metrics. | ||
| public static final String AWS_ACCOUNT = "aws_account"; |
There was a problem hiding this comment.
why not with a dot ?
| public static final String AWS_ACCOUNT = "aws_account"; | |
| public static final String AWS_ACCOUNT = "aws.account"; |
There was a problem hiding this comment.
Three reasons, all about the join. aws_account is the exact dimension tag on the aws.dynamodb.* and aws.s3.* integration metrics, so span and metric join without a rename and it is the key dd-trace-py already emits for the SNS and SQS account and also it is on the Agent credit-card obfuscator's allow list, whereas any other key carrying a 12-digit value is rewritten to ? at intake today (that happened live with aws.bucket.owner, see the obfuscator note in the description). aws.account would arrive as ? until DataDog/datadog-agent#56242 lands. Happy to also emit aws.account once that merges if you want the dotted form.
e1b9ef2 to
383c14b
Compare
vandonr
left a comment
There was a problem hiding this comment.
ok looks good (2 minor comments)
I'll need to duplicate this PR under my own account to be able to run the CI, I'll take care of this next week :)
| * | ||
| * @return the 12-digit account, or {@code null} when the key does not have the expected shape. | ||
| */ | ||
| public static String accountFromAccessKeyId(final String accessKeyId) { |
There was a problem hiding this comment.
I think it'd be nicer to untangle the base 32 parsing from actually getting the access key ID
| } | ||
|
|
||
| /** {@code true} when the value has the shape of an ARN, without fully parsing it. */ | ||
| public static boolean isArn(final String value) { |
There was a problem hiding this comment.
I think we can remove this as it's only used in tests, and checking AwsArn.parse(...) != null is actually a stricter check
383c14b to
a9e1606
Compare
DynamoDB and S3 client spans named the table or bucket but never the
account that owns it, so two accounts each holding an "orders" table
produced indistinguishable spans. The owning account is available to
the tracer without any extra call in three situations, and this change
uses all of them:
DynamoDB, TableName given as an ARN. The ARN carries partition, Region
and account. Spans now get aws.table.arn and aws_account, and
aws.table.name / tablename / peer.service carry the bare table name
instead of the raw ARN.
DynamoDB, bare TableName. DynamoDB documents that a bare name is
resolved in the requestor's own account ("If you only provide the table
name parameter instead of a complete ARN, the API operation will be
performed on the table in the account to which the requestor belongs"),
so the account owning the signing credentials is the table owner. On
SDK v2 the account is read from the credentials object
(AwsCredentialsIdentity.accountId(), SDK 2.26+, populated by the STS,
SSO, profile, process and container credential providers) through a
reflective, per-class cached getter so the instrumentation still loads
against 2.2.0. Spans get aws_account and a synthesized aws.table.arn
built from the client Region, account and table name. SDK v1 does not
expose the signing credentials to request handlers, so v1 only handles
the ARN form. The enrichment is gated on the DynamoDB service so
other request models with a TableName member (Timestream, Keyspaces)
keep the plain table name tags.
S3, ExpectedBucketOwner. When the caller sets it, S3 rejects the
request with 403 on a mismatch, so it names the owner whenever the
request succeeds. Spans get aws_account on both SDKs, applied from the
successful response so a rejected owner is never tagged.
For older v2 SDKs whose credentials carry no account, the account can
optionally be decoded from the access key ID, which encodes it in its
trailing base32 characters for keys issued since March 2019 (older keys
carry no account and are rejected by a format marker check). That
encoding is not part of the documented AWS API surface, so it is behind
dd.trace.aws.account.from.access.key.enabled
(DD_TRACE_AWS_ACCOUNT_FROM_ACCESS_KEY_ENABLED), default false.
aws_account is the tag name dd-trace-py already uses for the SNS and
SQS account it derives from TopicArn and QueueUrl, and the dimension
tag on the aws.dynamodb.* integration metrics, so the span joins to the
metrics for the same table without translation. It is also on the
allow list of the Agent's credit card obfuscator; a 12-digit account
under any other key is redacted to "?" by default, which is why no
second account-bearing key is introduced.
ARNs are parsed once into a new AwsArn value type (the SDK's own
software.amazon.awssdk.arns.Arn is not available at the 2.2.0 floor nor
to SDK v1), which also owns the table-name extraction shared by both
decorators. Partition mapping, table ARN assembly and access key decoding
live in AwsAccountIdentity. Both sit in the aws-java-common module and
are registered as injected helper classes by both SDK instrumentations.
a9e1606 to
2b35c67
Compare
What Does This Do
Tags DynamoDB and S3 client spans (AWS SDK v1 and v2) with the AWS account that owns the addressed resource, using only information the tracer already holds. No additional network calls.
TableNameis an ARNaws_account,aws.table.arn;aws.table.name/tablename/peer.servicenow carry the bare table nameTableName(SDK v2)aws_account, synthesizedaws.table.arnExpectedBucketOwnerset and the request succeedsaws_accountWhy the bare-name case is sound: DynamoDB documents that "If you only provide the table name parameter instead of a complete ARN, the API operation will be performed on the table in the account to which the requestor belongs" (Cross-account access with resource-based policies). Cross-account access requires either the full ARN as
TableName(account is then in the request) or credentials from a role in the owning account (account is then in the credentials). Either way the account owning the signing credentials is the table owner whenever the name is bare.Why
ExpectedBucketOwneris sound: S3 rejects the request with 403 when it does not match the bucket owner, so on a successful response it is authoritative. The tag is applied from the response hook (v2: carried in anExecutionAttributefromonSdkRequesttoonSdkResponse, set only on a 2xx; v1: set inafterResponse, which does not run for errors), so a rejected owner is never tagged on the error span.Where the caller account comes from on SDK v2:
AwsSignerExecutionAttribute.AWS_CREDENTIALSis read inonSdkRequest, andAwsCredentialsIdentity.accountId()(added in SDK 2.26, populated by the STS, SSO, profile, process and container credential providers) is called through a reflective, per-class cachedMethodHandleso the instrumentation still loads against the 2.2.0 floor. For older SDKs whose credentials carry no account, the account can be decoded from the access key ID (it is encoded in the trailing base32 characters). That encoding is not part of the documented AWS API surface, so it is opt-in behinddd.trace.aws.account.from.access.key.enabled/DD_TRACE_AWS_ACCOUNT_FROM_ACCESS_KEY_ENABLED, defaultfalse, registered inmetadata/supported-configurations.json.SDK v1 does not expose the signing credentials to request handlers (and
HandlerContextKeydoes not exist at the 1.11.0 floor), so v1 covers the ARN andExpectedBucketOwnercases only.The tag name
aws_accountis whatdd-trace-pyalready emits for the SNS and SQS account it derives fromTopicArnandQueueUrl, and it is the dimension tag on theaws.dynamodb.*integration metrics, so a span and the metrics for the same table join without translation. It is also on the allow list of the Agent's credit card obfuscator (see below).Implementation:
aws-java-common: newAwsArnvalue type that parses an ARN once (partition, service, Region, account, resource) and owns the DynamoDB table-name extraction (table/<name>/index/...and other sub-resources stripped), plusAwsAccountIdentity(Region to partition mapping, table ARN assembly, access key decode). Both are purejava.langwith no agent dependencies. The SDK's ownsoftware.amazon.awssdk.arns.Arnis not used because thearnsmodule is not a dependency ofaws-coreat the 2.2.0 floor these instrumentations compile against, and SDK v1 has no equivalent. Both SDK modules takeaws-java-commonas animplementation project(...)dependency and register the classes inhelperClassNames()so they are injected alongside the decorators, the same pattern aselasticsearch-commonandkafka-common. (Initially placed inagent-bootstrap; moved here per review, since bootstrap is loaded for every JVM and this is AWS-specific.)aws-java-sdk-2.2:onDynamoDbTablereplaces the plainsetTableNamecall;callerAccount/credentialsAccountIdread the credentials;ExpectedBucketOwnerhandled in the S3 branch.aws-java-sdk-1.11:GetterAccessgainsgetExpectedBucketOwner; the decorator handles the ARN form ofTableNameandExpectedBucketOwner.internal-api/dd-trace-api: tag constants, the new config flag.Motivation
aws.table.namesays which table, not whose. Two accounts can each own anorderstable in the same Region, and the span alone cannot tell them apart, which breaks any account-aware dependency map and any join to the AWS integration metrics (which are keyed by account). The ARN and the signing credentials already carry the answer; the tracer was discarding it.There is also a small correctness fix in the ARN case: today a
TableNamegiven as an ARN is emitted verbatim asaws.table.name,tablenameandpeer.service, so the same table produces differentpeer.servicevalues depending on how the caller addressed it.Additional Notes
Testing
AwsArnTestandAwsAccountIdentityTest(aws-java-common, JUnit 5, 30 + 43 cases): ARN parsing across partitions and resource shapes, malformed ARNs, twelve-digit account validation, DynamoDB table-name extraction (plain,/index/,/stream/, non-table resources), partition mapping (commercial, cn, gov, iso, iso-b, iso-e, iso-f, eusc), table ARN assembly, the base32 decoder against the RFC 4648foobarvector (upper and lower case, sub-range, alphabet rejects), account extraction from a hand-built byte vector, and the end-to-end access key decode against synthetically encoded keys plus malformed shapes.Aws2ClientTest(forkedTest, 37 + 37 across the V0/V1 naming forks + 23 legacy): new feature methods for an ARNTableName(asserts the exact tag set includingaws.table.arn,aws_accountand the bare name inpeer.service) and for a bare name with credentials that carry no account (asserts no account tags are invented).S3BucketOwnerForkedTest(JUnit 5, 4 cases, in thepayloadTaggingTestsource set because its S3 model, 2.18.40, hasExpectedBucketOwner; the base suite pins S3 2.2.0 which predates the field): owner set and accepted, owner absent, owner malformed, owner rejected with 403 (noaws_accounton the error span).AWS1ClientTest(24 + 24 forked + 17 legacy): new row with an ARNTableName.spotlessApplyclean on all touched modules.How the end-to-end test was run
Publish, KinesisPutRecord, Step FunctionsStartExecution+DescribeExecution, LambdaInvoke, DynamoDBGetItem(by name and by ARN), S3GetObjectand a JDBCSELECT 1, all inside one traced method so the client spans share a trace.dd-java-agent.jarcopied into it: the released 1.66.0 tracer (before) and the jar built from this branch with./gradlew :dd-java-agent:shadowJar(after). Same node pool, sameDD_ENV, distinctDD_VERSIONso the two runs are separable in Span Search./api/v2/spans/events/search) was queried for each run's client spans and the per-span attributes compared key by key; the raw responses are retained. Where the change enables a join to the AWS integration metrics, the corresponding/api/v1/querywas run against the same org and the returned scope recorded.Live before/after on a real EKS workload
A Java 21 workload using AWS SDK v2 2.29.52 under IRSA on an EKS cluster ran
GetItemagainst a real table twice per cycle (once by bare name, once by ARN) andGetObjectwithExpectedBucketOwneragainst a real bucket, once with the stockdd-java-agent1.66.0 and once with the jar from this branch, same image, same node pool. Spans as returned by the Span Search API (account masked):The join this enables, against the same org's AWS integration metrics:
Obfuscator note for reviewers
The first live run also emitted the S3 owner under a second key,
aws.bucket.owner, and it arrived at Datadog as?. The Agent's credit card obfuscator redacts any 12-digit value whose key is not on its allow list;aws_accountis on that list,aws.bucket.ownerwas not. This PR therefore emits the account underaws_accountonly. Anyone adding a new account-bearing key in the tracer should expect the same behaviour (see also DataDog/datadog-agent#56242, which addscloud.account.idandaws.account.idto that allow list).Design notes
TableNamemember (Timestream, Keyspaces, Athena) keep the pre-existing plainaws.table.name/tablenametags and never receive an account or a synthesized DynamoDB ARN.Qor later). Older keys carry no account and are rejected rather than decoded into an arbitrary value;AKIAIOSFODNN7EXAMPLEfrom the AWS docs is such a key and is a test case.aws_accountis only set where the account is provable: an ARN in the request, credentials whose account is asserted by the SDK, or an owner the caller asserted and S3 enforces. A bare S3 bucket name is deliberately not attributed to the caller, since bucket policies allow cross-account access by name.StreamNameand LambdaFunctionNamefollow the same "bare name resolves in the requestor's account" rule and could get the same treatment; left for a follow-up to keep this change reviewable.AwsAccountIdentity.accountFromAccessKeyIdplus the config flag and can be dropped without touching the rest.Contributor Checklist
type:and (comp:orinst:) labels in addition to any other useful labels — suggestedinst: aws sdk,inst: aws dynamodb,inst: aws s3,type: feature(external contributor, cannot set labels)close,fix, or any linking keywords when referencing an issuedd-java-agent/instrumentation/aws-java/(aws-java-common/andaws-java-sdk-2.2/src/payloadTaggingTest/java/), covered by the existing@DataDog/apm-serverlessdirectory entryDD_TRACE_AWS_ACCOUNT_FROM_ACCESS_KEY_ENABLED, registered inmetadata/supported-configurations.json; happy to add the public docs entry once naming is agreedSuggested labels (cannot set as an external contributor):
inst: aws sdk,inst: aws dynamodb,inst: aws s3,type: feature.