Skip to content

Add Datadog credential issuance - #40

Merged
highb merged 52 commits into
mainfrom
santhosh.kumar/credential-issuance
Aug 31, 2026
Merged

highb merged 52 commits into
mainfrom
santhosh.kumar/credential-issuance

Conversation

@santhosh-c1

@santhosh-c1 santhosh-c1 commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Datadog credential issuance

What this enables

  • Discovers Datadog organization API keys and service-account application keys as two distinct secret kinds.
  • Mints connector-sealed credentials of either kind after approval, with the caller choosing which.
  • Revokes issued credentials on manual revoke or expiry.

Dependencies

Summary

  • Issue Datadog service account application keys, deterministically named from the C1 request id.
  • Issue Datadog organization API keys as a second kind of the same API_KEY shape, selected by the caller.
  • Revoke issued keys of either kind.
  • Sync organization API keys and service-account application keys as distinct secret resource types.
  • Advertise credential issuance only when secret sync is enabled, and organization-API-key issuance and deletion only when explicitly granted.
  • Add opt-in live smoke tests for issue, authenticate and revoke on both kinds.

Two kinds of API key

Datadog organization API keys and service-account application keys are two kinds of the same shape. Both are CAPABILITY_DETAIL_CREDENTIAL_OPTION_API_KEY; they differ in what they are, who owns them, and what they can do. Before baton-sdk#1109 a connector could advertise only one descriptor per shape, so a connector offering both had no way to say which one a request meant.

That SDK release makes descriptor identity the pair (option, secret_resource_type_id) and requires CredentialIssueOptions.secret_resource_type_id on every issue request. This connector advertises both:

Kind secret_resource_type_id Scopes Default
Service account application key service-account-application-key Custom scopes allowed Preferred
Organization API key api-key None — Datadog org keys cannot be scoped

Issue reads the requested type off the request and dispatches. An unrecognized type is refused rather than silently minting the other kind.

Mapping

Application-key issuance targets a service account, not a human. An org-scoped key issued "on behalf of" a selected user is not an honest record of who holds it, so Issue re-checks that the selected Datadog user is still a service account, refuses a human user, and mints a key owned by and scoped to that service account.

Issue is non-duplicating on both kinds: it looks the deterministic request name up first and refuses rather than minting a second key, because Datadog cannot re-return plaintext material.

Revocation of an application key needs the owning service account as well as the key — Datadog has no delete-by-key-id-alone form. It travels in the resource deleter's existing parentResourceID rather than a packed composite handle, so the handle stays a bare provider id like every other secret this connector syncs. No C1 caller populates it today, so when it is absent the connector reads the owner from the key's owned_by relationship and proceeds; a key whose owner cannot be identified is refused rather than guessed at.

Gating organization API key deletion

Organization API keys belong to the organization, not to the person they were issued to, and they cannot be scoped. Deleting one is destructive in a way reading one is not, so read capability is not consent to destroy: the deleter is registered only when allow-org-api-key-deletion is set, and that grant is off by default.

The gate is structural, not a guard inside a method body. The SDK derives CAPABILITY_RESOURCE_DELETE from a type assertion on the registered syncer, so the deleter lives on its own type and is simply not registered without the grant — the capability is absent from what the connector advertises rather than advertised and refused at call time.

The same grant gates organization-API-key issuance, because baton-sdk#1109 requires every advertised issuance descriptor to have a registered deleter. C1 will not mint a credential it has no permission to revoke.

Permissions

Advertised permissions come from the x-permission block Datadog publishes for each operation in its own OpenAPI spec, not from inference:

Capability Endpoint Permission
Sync organization API keys ListAPIKeys api_keys_read
Issue organization API keys ‡ CreateAPIKey api_keys_write
Revoke organization API keys ‡ DeleteAPIKey api_keys_delete
Sync, issue and revoke application keys List / Create / DeleteServiceAccountApplicationKey service_account_write

‡ Only reachable with Allow organization API key deletion on. With it off, organization API keys still sync; they simply cannot be issued or deleted, and the extra permissions are not exercised.

service_account_write sits only on the secret resource types, which are registered only when secret sync is on, so a secrets-off install is not asked to grant it.

Verification

  • go build ./... — clean
  • go vet ./... — clean
  • go test ./... -count=1 — pass, 79 assertions in pkg/connector
  • make lint — 0 issues.
  • baton_capabilities.json regenerated from the built binary rather than hand-edited.

Unit coverage for the new paths: both issuance kinds advertised with distinct output types; dispatch on the requested type; refusal of an unrecognized type; refusal of the organization kind without the grant; the delete capability absent from advertised capabilities without the grant and present with it; application-key create and delete plus provider 404 mapping; create with no returned key material; exact-match-after-filter lookups including a cross-page match; duplicate-request refusal; handle-is-not-the-secret assertions on both delete paths; owner resolution from owned_by when parentResourceID is absent, and refusal when the owner cannot be identified; one-provider-page-per-List-call pagination; bounded paging at both levels of the application-key walk; disabled service accounts skipped rather than failing the sync; and hard failure of the sync on both a 403 and a 404 from the service-account application-key list.

CI coverage gap. The sync-test job does not set BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS, so the two-level service-account application-key walk is covered by httptest fakes only; enabling it in CI requires the CI Datadog role to hold service_account_write, without which the walk fails the sync by design.

Live provider

The live smoke tests are opt-in behind DATADOG_CREDENTIAL_SMOKE=1 and are skipped in CI. Both kinds were exercised against a disposable Datadog trial organization: minted, found through the provider's own list API, used to authenticate a real request, revoked, confirmed to stop authenticating, and confirmed delisted.

Application-key ownership was confirmed independently of the connector:

  • the org application-key record reports owned_by as the named service account, and that owner's user record has service_account: true;
  • the key returns 404 under a different service account and 404 as a current-user application key;
  • the key is absent from the organization API-key list and from the current-user application-key list.

Multi-account attachment was verified across several service accounts: each vended key was ATTACHED to its own account and absent from the others.

Not covered, stated so this is not read as broader than it is: multi-page application-key pagination was not exercised, and neither was reduced-permission behavior — the trial credential holds Datadog Admin-role permissions, so the run shows that service_account_write is sufficient but not that it is necessary.

Notes

  • Datadog application keys do not support create-time expiry.

  • Datadog may retain key metadata after revocation, so authentication failure is the revoke assertion — and only a 401/403 counts as evidence, not any error.

  • Datadog answers 404, not an empty list, for a disabled service account's application keys, and it never deletes users, only disables them. The walk skips disabled accounts; without that, one disabled account made every application key in the organization unsyncable.

  • A List call returns at most one provider page so the SDK keeps control of checkpointing, rate limits and cancellation. Both levels of the walk are page-bounded so a provider that ignores page[number] fails closed instead of paging forever.

  • A service account whose keys the role cannot read fails the whole sync rather than being skipped, because C1 reads a resource absent from a completed sync as deleted, so skipping would retire live credentials from the inventory instead of reporting that they could not be read. A 403 and a 404 are both terminal but carry different messages: the 403 names service_account_write, and the 404 says the service account was not found and may have been deleted mid-sync.

  • Compatibility note. Service-account application-key sync is off by default. Existing installs using Sync secrets continue syncing organization API keys unchanged. After enabling Sync service account application keys, the role also needs service_account_write; without it the connector fails the sync rather than treating an unreadable credential inventory as empty.

  • CLI compatibility note. baton-sdk v0.26.0 removes --diff-syncs, --base-sync-id, and --applied-sync-id. Update automation that invokes those flags before upgrading this connector.

Comment thread pkg/client/client.go Outdated
Comment thread pkg/connector/users.go
Comment thread pkg/connector/users.go Outdated
Comment thread pkg/connector/api_token.go Outdated
Comment thread pkg/connector/users.go Outdated
@github-actions

github-actions Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Connector PR Review: Add Datadog credential issuance

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 776941560ac5.
Review mode: incremental since e1bfc68d
View review run

Review Summary

The only new commit is documentation: a doc-comment paragraph on credentialUserBuilder.Issue (pkg/connector/users.go:102-108) recording that Issue is not retried automatically. Each claim it makes was checked against the code -- the duplicate-issuance lookups (FindAPIKeyByName, FindServiceAccountApplicationKeyByName) are genuinely single-request rather than paged walks, and both issuance arms do delete the freshly minted key when NewSecretResource fails -- so the comment is accurate and no behavior changed. The full PR diff was re-scanned for security and correctness (issuance/revoke entity sources, the two-level application-key walk and its pagination-bag transitions, nil-safety on the Datadog response structs, the allow-org-api-key-deletion capability gate, and the go.mod change promoting stretchr/testify from indirect to direct, which matches the new test files); no new issues were found. The four previously reported suggestions -- the ApplicationKeyResponse nil-data folding at pkg/client/client.go:392, unsampled per-key Warn timestamp logging at pkg/connector/api_token.go:106, the missing BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS in the CI sync test, and the triplicated 10_000 page ceiling -- are all still present in the current tree and are not re-listed here.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

Comment thread pkg/client/client.go Outdated
Comment thread baton_capabilities.json
Comment thread docs/connector.mdx Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

Comment thread pkg/connector/credential_smoke_test.go
Comment thread pkg/connector/credential_smoke_test.go Outdated
Comment thread pkg/connector/credential_smoke_test.go Outdated
Comment thread pkg/connector/credential_smoke_test.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

Comment thread pkg/client/client.go Outdated
Comment thread pkg/client/client.go Outdated
Comment thread pkg/connector/credential_smoke_test.go Outdated
Comment thread pkg/connector/connector.go
Comment thread pkg/connector/users.go
Comment thread pkg/client/client.go Outdated
Comment thread pkg/connector/api_token.go
Comment thread pkg/connector/users.go Outdated
Comment thread pkg/connector/resource_types.go Outdated
Comment thread pkg/connector/users.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Comment thread pkg/client/client.go Outdated
Comment thread pkg/client/client_test.go
highb and others added 15 commits August 31, 2026 18:25
The organization API key deleter was registered whenever sync-secrets
was on. Any install already syncing secrets would therefore acquire
org-wide Datadog API key deletion the moment it upgraded the connector,
without anyone choosing it. Reading a credential inventory is not
consent to destroy what is in it.

Deletion now needs allow-org-api-key-deletion, a separate flag that
defaults to off. Sync behaviour is unchanged either way.

The capability has to be absent, not merely refused: C1 resolves what it
may do from the advertisement, and the SDK derives
CAPABILITY_RESOURCE_DELETE from a type assertion on the registered
syncer. So Delete moves off apiTokenBuilder onto deletableAPITokenBuilder
and only that variant is registered when the grant is set. The advertised
Datadog permissions follow the same split: api_keys_delete and
api_keys_write are advertised only by the variant that can reach them.

Also updates credential_lifecycle_test.go for the moved constructor.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Datadog has two kinds of API key and they are not interchangeable. A
service account application key is scoped to and owned by one identity;
an organization API key is owned by the whole organization, has no
owner inside it, and cannot be scoped at all. Both are the API_KEY
shape, so the shape enum alone cannot tell a caller which one it is
getting.

IssueCapabilityDetails now advertises one descriptor per kind, separated
by secret_resource_type_id, with the service account application key
marked preferred. Issue dispatches on the requested type rather than
always minting an application key, and refuses an unrecognised type
instead of falling back to a default arm.

Organization API key issuance follows allow-org-api-key-deletion. The
SDK will not register an issuance descriptor whose secret resource type
has no ResourceDeleterV2, and that is the right constraint: a credential
this connector cannot revoke is one it should not mint.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Regenerated from the built connector:
  ./connector config > config_schema.json
  ./connector capabilities > baton_capabilities.json

README and docs/connector.mdx describe allow-org-api-key-deletion and
the two issuance kinds. The capabilities document is static and has no
conditional form, so main.go forces every optional surface on when
generating it, as it already did for sync-secrets and sync-schedules.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
TestCredentialIssueLifecycle covers the service account application key
arm only. The organization API key arm is the new one and had no live
coverage, so a caller could not tell from the suite whether the dispatch
actually reaches a different Datadog API.

The new test mirrors the existing opt-in guard and always revokes what
it mints. It asserts the issued resource comes back as the kind that was
requested rather than the preferred one, that it carries no parent
resource id, and that the key authenticates before revocation and stops
afterwards.

The probe is GET /api/v1/validate, which authenticates on the API key
alone. An organization API key has no application key to pair with, so
the connector's own ValidateCredentials path would not isolate the
credential under test.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Datadog answers 404, not an empty list, when asked for a disabled service
account's application keys. listApplicationKeyPage treats 404 as fatal, so
one disabled service account failed the entire application-key walk — and
Datadog never deletes users, it only disables them. Any organization that
has ever disabled a service account could therefore never sync a single
application key, and the resource type never appeared in C1 at all.

The walk now skips service accounts Datadog reports as disabled. They
cannot authenticate, so no live credential is dropped. A 404 on an account
this walk did choose to visit still fails closed: that one was enabled when
the users page was read, so not being readable now is a real anomaly.

Found against a live Datadog organization, where it kept the
service-account application key from ever reaching C1.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
…lient

The organization API key smoke test hand-rolled an http.Client call to
/api/v1/validate. gosec's taint analysis flagged it (G704), and it was the
wrong shape anyway: every other probe in this package goes through
client.DatadogClient.

ValidateCredentials calls the same endpoint. It authenticates on the API
key alone, which is what makes it the right probe for an organization key
— that kind has no application key to pair with, so a probe requiring one
would not isolate the credential under test. The empty application key is
deliberate and now documented.

Datadog refusing the credential is now returned as "not valid" rather than
as a probe error, which is what the revocation check was already treating
it as.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
gosec flags G101 on a struct literal that assigns string constants to
fields named apiKey and appKey, even in a test against a fake provider.
These tests read advertised capabilities, which never touch the
connector's own credentials, so the fields were doing nothing. Leaving
them unset removes the finding rather than suppressing it.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Delete required parentResourceID to name the owning service account, and
no C1 caller populates it, so every revoke of an issued service-account
application key failed InvalidArgument and left the key live at the
provider -- while the connector advertised CAPABILITY_RESOURCE_DELETE for
that type. That is the failure mode api_token.go argues against.

The owner is recoverable from the key: Datadog carries it as the owned_by
relationship on GetApplicationKey. Delete now reads it when the parent is
absent and keeps failing closed only when the lookup cannot name an owner,
so the fallback widens what can be revoked without letting Delete guess.

Also bounds the users level of the application-key walk. The
application-key level already had maxApplicationKeyPages; the users level
terminates on an empty page rather than a short one, so a provider that
ignores page[number] would re-push child states forever.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
The warning and footnote both said a revoke that omits the owning service
account fails, and that revocation is advertised but cannot complete until
the requesting workflow threads the owner through. The connector now reads
the owner from the key, so both claims are stale.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Listing a service account's application keys needs Datadog's
service_account_write, which api_keys_read does not imply, and a 403 there
fails the whole sync rather than skipping the account. Registering that
syncer under sync-secrets alone therefore broke every existing
sync-secrets install on upgrade, on a permission the operator was never
asked for.

sync-service-account-application-keys is that ask, off by default. It
follows the shape allow-org-api-key-deletion already set in this
connector: the syncer is not registered without the grant, so the
capability is absent from what C1 is advertised rather than advertised and
refused. Once granted, the fail-hard behaviour is unchanged -- a
credential absent from a completed sync reads as deleted, so skipping
would retire live credentials from the inventory.

The grant also gates issuance of that kind, because the SDK refuses an
issuance descriptor whose secret resource type has no registered deleter.
With secrets synced and neither kind granted there is nothing to issue, so
CAPABILITY_CREDENTIAL_ISSUE is absent rather than advertised with an empty
option list.

baton_capabilities.json is unchanged: capability generation forces every
optional surface on, so the document still describes the connector's full
capability set.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Two review findings on the owner-lookup fallback.

Delete collapsed every non-NotFound failure from FindApplicationKeyOwner
into InvalidArgument, discarding the classification wrapOfficialClientError
exists to produce: a 500 is retryable, a 403 names a missing permission,
and %v dropped the wrapped error with its rate-limit annotations. A
transient blip therefore read as a terminal revoke failure and stranded the
key -- the outcome the fallback was added to remove. Only the provider
answering with no owner is unresolvable, and that case arrives as a plain
error, so the code distinguishes them.

The lookup also reaches an org-scoped endpoint the advertised permissions
did not cover. Datadog governs GetApplicationKey under org_app_keys_read,
not service_account_write, so a role holding exactly what this connector
asked for would have 403'd on every revoke. Both permissions are now
advertised and documented. Verified against the same OpenAPI spec the rest
of these permissions came from, which also confirms the service-account
endpoints need only service_account_write.

Both name lookups now make one request instead of walking pages. The name
searched for is always "c1-<request id>", so only a key whose own name
contains that whole string can come back and a page of 100 cannot fill with
them. Paging was answering a question the filter had already settled, and
it was the source of the rate-limit concern raised in review. A full page
now fails rather than reporting no match, because reporting no match would
mint a duplicate of a key whose plaintext Datadog will not reissue.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
revive rejected the description line at 213 characters. Shortened rather
than wrapped, since the same string is the flag's help text and appears in
README.md and config_schema.json.

Two review points on the gate's documentation, both correct. The comment
on userResourceType still said sync-secrets alone registers
applicationKeyBuilder, which was the reasoning for scoping
service_account_write to the application-key resource type -- that
reasoning holds but now runs through two flags. And the capability table's
application-key row carried no marker, so a reader scanning only the table
would conclude those keys sync out of the box with Sync secrets.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
The self-hosted env-var block is the only place the connector's variable
names are written out, and it named BATON_ALLOW_ORG_API_KEY_DELETION but
not BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS. An operator copying it got
organization-key deletion and no application keys at all.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@c1-squire-dev
c1-squire-dev Bot force-pushed the santhosh.kumar/credential-issuance branch from 6b5f212 to b4cb4f1 Compare August 31, 2026 18:27

// warnMalformedTimestamp reports an unparseable provider timestamp. The field
// is dropped and the key still syncs.
func (o *apiTokenBuilder) warnMalformedTimestamp(ctx context.Context, apiKeyID, field, raw string, err error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this fires at Warn once per malformed field per key, and it is called twice per key (created_at + modified_at), as TestApiTokenListSurvivesMalformedTimestamps asserts. A provider-side timestamp format change would emit two warnings for every API key on every sync; the same shape exists for the created_at callback in application_key.go:305. Repo criteria L7 asks for logarithmic sampling (1, 10, 100, every 1000) with a total_occurrences field on warnings that can fire per-resource.


nextPageToken := ""
if len(apiTokens) != 0 {
if hasMoreAPIKeyPages(res, page, int64(len(apiTokens)), defaultV2PageSize) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this walk has no page ceiling. When meta.page.total_filtered_count is absent (helpers.go:117/121/131), hasMoreAPIKeyPages falls back to count != 0, so a provider that ignores page[number] and keeps returning full pages would page forever. The new application-key walk added maxUserPages/maxApplicationKeyPages for exactly this failure mode; consider the same bound here so both walks fail closed consistently.

Comment thread pkg/connector/application_key.go Outdated
switch status.Code(err) {
case codes.NotFound:
return nil, nil
case codes.Unknown:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: codes.Unknown is broader than "the provider answered and its answer names no owner". wrapOfficialClientError runs uhttp.GrpcCodeFromHTTPStatus, whose default arm returns Unknown for any status outside 4xx/5xx — so a 2xx or 3xx whose body the generated client cannot unmarshal (an intercepting proxy returning HTML with 200, for example) also lands here and gets converted into a permanent InvalidArgument refusal instead of a retryable error. Consider distinguishing the connector's own "no owner" sentinels in FindApplicationKeyOwner (a typed error / errors.Is) from an unmapped transport code. Same at line 120.

Comment thread .github/workflows/ci.yaml
# The auth-error check intentionally invalidates BATON_* secrets.
# Keep this feature flag parseable so it reaches authentication.
bad-credentials: |
BATON_SYNC_SECRETS=true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the live sync test still runs with only BATON_SYNC_SECRETS, so the new two-level service-account application-key walk (users page → per-service-account key pages, bag push/pop, disabled-account skip) is never exercised against a real Datadog org — and the PR notes multi-page application-key pagination was not exercised in the live smoke run either. If the CI role can be granted service_account_write, adding BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS: true to the job env would cover the highest-risk new code path; if it cannot, the fail-hard-on-403 design means it must stay off, which is worth noting in the PR.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

highb and others added 2 commits August 31, 2026 20:47
apiTokenBuilder.List had no page ceiling. hasMoreAPIKeyPages compares against
meta.page.total_filtered_count only when the response carries one
(pkg/connector/helpers.go:119-131); with the field absent it falls back to
"the page was not empty", so a provider that ignores page[number] and keeps
answering with full pages is paged forever. Nothing else in the walk
terminates it.

Add maxOrgAPIKeyPages, mirroring maxApplicationKeyPages and maxUserPages in
application_key.go: same 10_000 value, same fail-closed check before the
request goes out, same error shape naming the resource type. 10_000 pages of
100 is 1M organization API keys, far past any real Datadog organization.

TestApiTokenListBoundsPagination drives the exact response shape
hasMoreAPIKeyPages cannot terminate on -- a full page with no meta -- and
asserts the page below the bound is still served and still offers another,
that the page at the bound errors, and that no request is issued for it.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
…PC code

Delete's two owner-lookup switches treated codes.Unknown as "the provider
answered and named no owner" and converted it to a permanent
codes.InvalidArgument refusal. Unknown is too broad for that.
FindApplicationKeyOwner's failures reach Delete through
wrapOfficialClientError, which classifies with uhttp.GrpcCodeFromHTTPStatus;
that function's default arm returns Unknown for every status outside 4xx/5xx,
including a 2xx or 3xx whose body the generated Datadog client cannot
unmarshal. A response the transport could not decode is retryable, and Delete
was making it permanent -- stranding a live application key that a retry would
have revoked.

Fix it where the distinction is known. FindApplicationKeyOwner now joins a
package-level client.ErrApplicationKeyOwnerUnknown onto its two hand-rolled
cases -- a response with no owned_by relationship, and an owned_by naming no
user -- which are the only failures where the provider succeeded and still
named no owner. Delete branches on errors.Is against that sentinel, so every
other error, whatever its code, falls through to the wrapped-error default arm
with its classification intact.

The sentinel matches how this package already distinguishes an unretryable
condition the gRPC code cannot express (client.ErrNotFound, joined the same
way).

Two tests pin the split. The ownerless case still yields InvalidArgument and
now asserts the sentinel where it is produced, since status.Errorf formats its
cause with %v rather than chaining it.
TestApplicationKeyBuilderDeleteKeepsUnmappedTransportFailureRetryable drives a
200 with an undecodable body and requires the error to keep codes.Unknown, to
not be InvalidArgument, and to not carry the sentinel; it fails on the
InvalidArgument assertion against the old codes.Unknown branch.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/client/client.go
// Both of these are the provider answering and naming no owner, which no
// retry changes; ErrApplicationKeyOwnerUnknown is how a caller tells them
// apart from a transport failure that merely mapped to the same gRPC code.
if response.Data == nil || response.Data.Relationships == nil || response.Data.Relationships.OwnedBy == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: response.Data == nil is folded into ErrApplicationKeyOwnerUnknown, but it is not only reachable when the provider named no owner — it is also what an undecodable 200 looks like. ApplicationKeyResponse.UnmarshalJSON (vendor/.../model_application_key_response.go:120) falls back to datadog.Unmarshal(bytes, &o.UnparsedObject) and returns a nil error whenever the outer decode fails, leaving Data == nil. So a 200 body of {}, {"data": 5}, or a proxy envelope like {"error":"upstream"} reaches FindApplicationKeyOwner with no error and nil Data, gets the sentinel, and application_key.go:106 converts it into a permanent codes.InvalidArgument refusal — the exact "retryable transport failure made permanent" outcome this commit set out to avoid, and it leaves the issued key live at the provider. The new TestApplicationKeyBuilderDeleteKeepsUnmappedTransportFailureRetryable misses it because {"data": not-json is syntactically invalid, so the UnparsedObject fallback also fails and the call errors out on the codes.Unknown path instead.

Consider narrowing the sentinel to the cases where the provider demonstrably answered about this key — Relationships == nil, OwnedBy == nil, and ownerID == "" — and returning a plain retryable error for Data == nil, since a response carrying no data object at all is indistinguishable from a body the client could not decode.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

A failed issuance is not re-driven by the platform, so every failure in
Issue is terminal for that request. The fact only lived in a resolved
review thread; it explains two existing design choices -- the
single-request name lookups and the best-effort cleanup of a mint whose
result cannot be returned -- so it belongs on the function.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@highb
highb merged commit 2a93dd0 into main Aug 31, 2026
11 checks passed
@highb
highb deleted the santhosh.kumar/credential-issuance branch August 31, 2026 21:19

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants