Skip to content

Add Datadog credential issuance - #40

Open
santhosh-c1 wants to merge 51 commits into
mainfrom
santhosh.kumar/credential-issuance
Open

Add Datadog credential issuance#40
santhosh-c1 wants to merge 51 commits into
mainfrom
santhosh.kumar/credential-issuance

Conversation

@santhosh-c1

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

Copy link
Copy Markdown

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 lint0 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.

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.
  • Deleting a key that is already absent is treated as success. A revoke that names the wrong owning service account is currently indistinguishable from that case and also returns success while the key survives; this is judged unreachable in the current caller flow and is tracked separately rather than changed here.
  • 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.
  • This is a breaking change for existing installs. An install with secret sync enabled whose Datadog role lacks service_account_write now fails its entire sync, where before it synced without application keys. That is the cost of failing hard; the counterweight is that the failure names the missing permission, so it diagnoses itself, whereas a sync that completes while reporting an empty credential inventory does not.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
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: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base ffab37fa49a9.
Review mode: incremental since 2ff769bb
View review run

Review Summary

The new commit (e1fe609c) is docs-only: it adds BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS to the self-hosted env-var example, which addresses the prior finding at docs/connector.mdx:254 — the block now shows the application-key flag alongside BATON_SYNC_SECRETS and BATON_ALLOW_ORG_API_KEY_DELETION. The full PR diff was re-scanned for security and correctness (client, issuance and revoke paths, the two-level pagination bag walk, resource types, config, and the go.mod / go.sum bump to baton-sdk v0.26.0 plus testify moving to a direct require, both of which match the code changes); no blocking issues found. One new suggestion: the explanatory comment added with the new env var overstates what org_app_keys_read is required for.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • docs/connector.mdx:255 — the new comment says the sync fails without org_app_keys_read, but that permission only backs the revoke owner lookup; only service_account_write is sync-fatal, per pkg/connector/resource_types.go and docs/connector.mdx:57.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `docs/connector.mdx`:
- Around line 253-255: The comment above BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS says the
  connector needs service_account_write and org_app_keys_read and that "without them the sync
  fails". That overstates org_app_keys_read. Per the comment on
  serviceAccountApplicationKeyResourceType in pkg/connector/resource_types.go (a role holding
  only service_account_write still syncs and issues, and fails just the owner lookup) and the
  prose at docs/connector.mdx:57, org_app_keys_read is only used by the revoke path's owner
  lookup (client.FindApplicationKeyOwner); only service_account_write is sync-fatal (see
  pkg/connector/application_key.go around line 254). Reword so the sync-failure consequence
  attaches to service_account_write alone and org_app_keys_read is described as needed for the
  revoke owner lookup.

@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.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
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.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
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.

santhosh-c1 and others added 6 commits August 20, 2026 06:57
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>
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>
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
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
highb and others added 2 commits August 27, 2026 22:43
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>
highb and others added 6 commits August 28, 2026 03:08
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>
The credential type discriminator is in a released tag now, so the
pre-release pseudo-version this pinned can go. It pointed at the pull
request head, which the squash merge has since made unreachable.

The vendored diff is smaller than the version jump suggests: the
discriminator's generated sources are byte-identical, because the
pseudo-version already carried them. What changes is everything else that
landed on main while the pull request was open.

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

Carry the credential type discriminator, and gate org API key deletion
Conflicts were confined to the module pins and the vendored SDK: this
branch is on baton-sdk v0.26.0, main had moved to v0.25.0. Resolved by
keeping v0.26.0, which is the released tag carrying the credential type
discriminator this branch depends on, and regenerating go.sum and vendor
from it rather than merging generated files by hand.

.versions.yaml keeps main's v0.25.0. It is written by baton-admin and
carries no cross-check against go.mod, so it lags a version until that
bot next runs.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment on lines +223 to +231
resp, err := o.wrapper.ListServiceAccountApplicationKeys(ctx, serviceAccountID, page, defaultV2PageSize)
if err != nil {
code := status.Code(err)
if code == codes.PermissionDenied {
return nil, nil, fmt.Errorf(
"baton-datadog: list application keys for service account %q: %w "+
"(listing service-account application keys requires the Datadog service_account_write permission)",
serviceAccountID, err)
}

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.

🟠 Bug: This makes a hard, ungated compatibility break for existing installs. applicationKeyBuilder is registered whenever SyncSecrets is on (connector.go:60), and a 403 here aborts the whole sync — so any install already running with sync-secrets: true and a Datadog role that only has api_keys_read starts failing every sync on upgrade, with no opt-in. The PR body acknowledges this, but the connector already has the right pattern for exactly this situation (allow-org-api-key-deletion): gate application-key sync behind its own opt-in flag so existing installs keep syncing until the operator grants service_account_write, and keep the fail-hard behaviour once they've opted in.

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.

Squire (claude-opus-5): Implemented in bbff6aba, following the pattern you pointed at.

sync-service-account-application-keys is off by default. Without it the application-key syncer is not registered at all, so the resource type is absent from what C1 is advertised rather than advertised and failing — an install already running with sync-secrets on keeps syncing after an upgrade, and organization API keys still sync as before. Once granted, the fail-hard behaviour is unchanged: a credential absent from a completed sync reads as deleted, so skipping a 403 would retire live credentials from the inventory.

One consequence worth naming, since it follows from #1109 rather than from this comment: the grant also gates issuance of that kind. The SDK refuses an issuance descriptor whose secret resource type has no registered deleter, and the syncer is what carries the 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, because capability generation forces every optional surface on — the point made on the main.go thread.

Pinned by TestApplicationKeySyncRequiresItsOwnGrant, TestApplicationKeySyncAdvertisedWithGrant, and TestCredentialIssueFollowsTheKindGrants across all four grant combinations.

Comment thread pkg/connector/application_key.go Outdated
Comment on lines +83 to +85
if parentResourceID == nil || parentResourceID.GetResourceType() != userResourceType.Id {
return nil, status.Error(codes.InvalidArgument, "baton-datadog: the owning service account id is required to delete a service account application key")
}

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.

🟠 Bug: By the PR's own account no C1 caller populates parentResourceID today, so every revoke of an issued service-account application key returns InvalidArgument and the key stays live — the connector mints credentials it cannot revoke while advertising CAPABILITY_RESOURCE_DELETE for this type. That is the same failure mode api_token.go:33-38 argues against ("a capability that is advertised but refuses at call time is worse than one that is absent").

The owning service account is recoverable without a packed handle or a platform change: datadogV2.KeyManagementApi.GetApplicationKey(ctx, appKeyID) returns relationships.owned_by, which is the service-account user id. Consider falling back to that lookup when parentResourceID is nil/empty, and keeping the current fail-closed path only when the lookup itself cannot identify an owner.

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.

Squire (claude-opus-5): Fixed in ed78713e, taking the owned_by route you named.

Delete now treats parentResourceID as the fast path rather than a requirement. When it is absent, wrong-typed, or empty, the connector calls KeyManagementApi.GetApplicationKey and reads relationships.owned_by.data.id, then issues the same service-account-scoped DELETE it would have made had the parent been supplied. It fails closed only when that lookup cannot name an owner — so the fallback widens what can be revoked without letting Delete guess.

You were right about the stake: with the SDK now requiring a registered deleter for every advertised issuance descriptor, a delete path that refuses every call is worse than no capability at all, which is the argument api_token.go makes and this file was contradicting.

Covered by two new tests — TestApplicationKeyBuilderDeleteResolvesOwnerFromTheKey across all three absent-parent shapes, asserting the DELETE lands on the service-account-scoped path, and TestApplicationKeyBuilderDeleteFailsWhenOwnerUnknown, asserting a key with no owned_by is still refused and still reaches no DELETE. The docs warning and footnote said revocation "will not complete" until a caller supplies the owner; both are updated in 4bd1ae5d.

Comment thread cmd/baton-datadog/main.go
SyncSchedules: true,
SyncSecrets: true,
SyncSchedules: true,
AllowOrgAPIKeyDeletion: 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: Forcing this on makes the generated baton_capabilities.json advertise api_keys_write and api_keys_delete on the api-key resource type for every install, which contradicts docs/connector.mdx ("Add API Keys Write and API Keys Delete only if you also enable Allow organization API key deletion") and re-creates the exact problem resource_types.go avoids for service_account_write — sending operators after Datadog Admin rights no reachable code path exercises. Since the two org-key variants share the id api-key, resource-type scoping cannot carry the conditionality here; either leave AllowOrgAPIKeyDeletion false for capability generation, or give the deletable variant a distinct resource type id so the extra permissions are attached only to the gated type.

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.

Squire (claude-opus-5): Pushing back on this one.

The forcing is the established convention in this file, not a new choice: SyncSecrets and SyncSchedules are set the same way, immediately above, under a comment saying every optional surface is forced on so ./connector capabilities documents the connector's full capability set. baton_capabilities.json is a static artifact with no conditional form — it describes what the connector can do, not what a given install is configured to do. Singling out AllowOrgAPIKeyDeletion would make that document describe a partial connector while the other two flags keep describing a whole one.

The suggested alternative — give the deletable variant a distinct resource type id — would be worse than the problem. The two variants are the same kind of credential; the id api-key is what C1 matches an issuance descriptor against. Splitting it would advertise two organization-API-key kinds that differ only in whether the operator granted a permission, which is exactly the implicit selection baton-sdk#1109 set out to remove.

The docs inconsistency you point at is real but is a docs question, and docs/connector.mdx already resolves it correctly: it tells the operator to add API Keys Write and API Keys Delete only alongside Allow organization API key deletion. That is install guidance, and it should stay conditional. The capability document should not.

Leaving unresolved for a human to disagree with.

Comment on lines +159 to +165
if len(data) == 0 {
// Users are exhausted: drop the users-level state so the sync ends
// once the child states pushed by earlier pages are drained.
bag.Pop()
} else if err := bag.Next(strconv.FormatInt(page+1, 10)); err != nil {
return nil, nil, fmt.Errorf("baton-datadog: advance users page: %w", err)
}

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 users level of this walk has no page bound, while the application-key level got maxApplicationKeyPages for exactly the failure it guards against. A provider that ignores page[number] and keeps returning full users pages loops here forever (re-pushing child states each round), which is the same "fails closed instead of paging forever" case. Consider applying an equivalent cap to the users page counter.

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.

Squire (claude-opus-5): Fixed in ed78713e. maxUserPages now bounds the users level the way maxApplicationKeyPages bounds the application-key level, with the same 10,000-page ceiling.

Your reason for it is the sharper one and is recorded on the constant: the users level terminates on an empty page rather than a short one, so a provider that ignores page[number] and keeps returning full pages would re-push child states every round rather than merely spinning — the unbounded case is worse here than at the level that already had a bound.

@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.

highb and others added 2 commits August 28, 2026 23:26
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>
Comment on lines +88 to +95
owner, err := o.wrapper.FindApplicationKeyOwner(ctx, appKeyID)
if err != nil {
if status.Code(err) == codes.NotFound {
return nil, nil
}
return nil, status.Errorf(codes.InvalidArgument,
"baton-datadog: the owning service account for application key %q could not be determined: %v", appKeyID, err)
}

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.

🟠 Bug (high confidence): every non-NotFound failure from FindApplicationKeyOwner is collapsed into codes.InvalidArgument, but wrapOfficialClientError (pkg/client/errors.go:17-33) deliberately maps a network error/429 to Unavailable, 5xx to Internal, and 403 to PermissionDenied precisely so the caller can retry or diagnose. A transient blip during the owner lookup therefore surfaces as a terminal "invalid argument" revoke failure and the issued key stays live at Datadog — the same stranded-credential outcome this commit set out to remove. The %v also drops the wrapped error and the rate-limit annotations WrapErrorsWithRateLimitInfo attached.

The genuinely-unresolvable cases (owned_by omitted / empty owner id) are plain fmt.Errorf, so status.Code(err) == codes.Unknown distinguishes them cleanly:

owner, err := o.wrapper.FindApplicationKeyOwner(ctx, appKeyID)
if err != nil {
    switch status.Code(err) {
    case codes.NotFound:
        return nil, nil
    case codes.Unknown:
        return nil, status.Errorf(codes.InvalidArgument,
            "baton-datadog: the owning service account for application key %q could not be determined: %v", appKeyID, err)
    default:
        return nil, fmt.Errorf("baton-datadog: resolve owner for application key %q: %w", appKeyID, err)
    }
}

TestApplicationKeyBuilderDeleteFailsWhenOwnerUnknown still passes under this shape.

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.

Squire (claude-opus-5): Correct, and fixed in 6dd74a35 with the shape you wrote.

The bug was self-defeating in exactly the way you describe: the fallback exists so a revoke does not strand a key, and collapsing a 500 or a 429 into InvalidArgument stranded it anyway, for a failure that would have succeeded on retry. %v also discarded the wrapped error and the rate-limit annotations.

codes.Unknown is the right discriminator — the two genuinely unresolvable cases are plain fmt.Errorf, so they arrive as Unknown while every provider-classified failure keeps its code.

One correction to the description rather than the fix: uhttp.GrpcCodeFromHTTPStatus maps 500 to Unavailable, not Internal. TestApplicationKeyBuilderDeletePreservesOwnerLookupFailureCode asserts the mapping the code actually produces — Unavailable for a 500, PermissionDenied for a 403 — rather than the one in the comment.

Comment thread pkg/client/client.go
func (w *DatadogClient) FindApplicationKeyOwner(ctx context.Context, appKeyID string) (string, error) {
ctx = w.withAuthContext(ctx)
api := datadogV2.NewKeyManagementApi(w.officialClient)
response, httpRes, err := api.GetApplicationKey(ctx, appKeyID)

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 (medium confidence): this puts a new endpoint on the revoke path — GET /api/v2/application_keys/{id} (KeyManagementApi, org-scoped), not a ServiceAccountsApi call. resource_types.go:103-113 still asserts "Every service-account application-key endpoint this connector calls requires exactly one Datadog RBAC permission, service_account_write: List / Create / DeleteServiceAccountApplicationKey", and docs/connector.mdx says Service Account Write "governs syncing, issuing, and revoking service account application keys". That enumeration no longer covers the code. Datadog governs org-level application-key reads under its own permission (org_app_keys_read), so a role holding exactly the documented set would 403 here — and per the thread on application_key.go that 403 is reported as InvalidArgument, so every revoke fails with a misleading message.

Please re-check GetApplicationKey's x-permission block against the same spec the rest of this file was derived from, then either add the permission to capabilityPermissions on serviceAccountApplicationKeyResourceType and the docs custom-role list, or update the two comments to say why it is already covered.

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.

Squire (claude-opus-5): Confirmed against the spec and fixed in 6dd74a35. You are right on both halves.

Checked GetApplicationKey's x-permission block in the same spec the rest of these permissions came from (DataDog/datadog-api-client-go, .generator/schemas/v2/openapi.yaml):

  • GetApplicationKeyorg_app_keys_read
  • ListApplicationKeysorg_app_keys_read
  • ListServiceAccountApplicationKeys, CreateServiceAccountApplicationKey, DeleteServiceAccountApplicationKeyservice_account_write

So a role holding exactly what this connector advertised would have 403'd on every revoke that relied on the fallback. org_app_keys_read is now advertised alongside service_account_write on serviceAccountApplicationKeyResourceType, the resource_types.go comment says which endpoint is governed by which rather than claiming one permission covers them all, and the custom-role list in docs/connector.mdx names it.

Worth noting the failure is now partial rather than total, which it was not when you wrote this: with the error-mapping fix on the sibling thread, a role missing org_app_keys_read still syncs and still issues — it fails only the owner lookup, and reports PermissionDenied naming the permission instead of a misleading InvalidArgument.

The same spec check confirms the enumeration for the service-account endpoints was right, so only the org-scoped addition was missing.

@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.

highb and others added 2 commits August 28, 2026 23:40
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>
Comment on lines +23 to +40
// service_account_write is deliberately NOT listed here even though
// userResourceType carries CAPABILITY_CREDENTIAL_ISSUE. Issuance only
// exists when sync-secrets is on -- that is the flag that swaps
// credentialUserBuilder in for userBuilder and registers
// applicationKeyBuilder at all -- so listing it here would tell every
// sync-secrets-off install to grant a Datadog Admin permission that no
// code path in that configuration can reach.
//
// baton_capabilities.json cannot express "only when sync-secrets is on":
// it is one static document, generated by `./connector capabilities` from
// a connector built with SyncSecrets and SyncSchedules forced true (see
// cmd/baton-datadog/main.go), and CapabilityPermissions has no
// conditional form. Scoping the permission to the resource type that only
// exists under the flag is therefore how the conditionality is carried:
// service_account_write lives on serviceAccountApplicationKeyResourceType,
// which is only registered when sync-secrets is on, and the user type's
// credential_issue block points at that type via secretResourceTypeId, so
// the requirement is still discoverable from the metadata.

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 comment is now stale after the new gate. It says sync-secrets "is the flag that swaps credentialUserBuilder in for userBuilder and registers applicationKeyBuilder at all", and that serviceAccountApplicationKeyResourceType "is only registered when sync-secrets is on". As of connector.go:50-79, both also require sync-service-account-application-keys, and the user syncer is swapped only when at least one credential kind is granted. Worth updating so the reasoning for scoping service_account_write to this resource type still matches the code. (confidence: high)

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.

Squire (claude-opus-5): Fixed in 2ff769bb. The comment now says both flags: sync-secrets swaps credentialUserBuilder in for userBuilder, and sync-service-account-application-keys is what registers applicationKeyBuilder and puts that kind in the descriptor list.

The reasoning it exists to record survives the change — scoping service_account_write to the resource type is still how the conditionality is carried, since baton_capabilities.json has no conditional form. It just runs through two flags now instead of one, so the sentence describing "only when sync-secrets is on" was the stale part rather than the argument.

Comment thread docs/connector.mdx
| Roles | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | | |
| Teams | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | | |
| Schedules | <Icon icon="square-check" iconType="solid" color="#c937ae"/>* | | | |
| Secrets - Organization API keys | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | | <Icon icon="square-check" iconType="solid" color="#c937ae"/>‡ | <Icon icon="square-check" iconType="solid" color="#c937ae"/>‡ |

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 * footnote now reads "Schedules and application keys are not enabled by default", but this row carries no * marker — only the Schedules row does. A reader scanning just the table will conclude service account application keys sync out of the box with Sync secrets. Adding * to this row's Sync cell (and/or Issue/Revoke) would make the new gate visible in the table itself. (confidence: high)

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.

Squire (claude-opus-5): Fixed in 2ff769bb. The application-key row now carries * on Sync, Issue and Revoke, so the gate is visible to someone reading only the table.

The footnote was doing the wrong job too — it lumped schedules and application keys into one sentence, so even a reader who followed the marker got "enable Sync schedules or Sync secrets, respectively". It now separates them and says the part that was missing: Sync secrets alone syncs organization API keys only.

@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.

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>
Comment thread docs/connector.mdx

# Optional: include ONLY if you want C1 to issue and delete organization-wide
# Datadog API keys. Syncing secrets does not grant this on its own.
BATON_ALLOW_ORG_API_KEY_DELETION: 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 (medium confidence): this self-hosted env-var example gained BATON_ALLOW_ORG_API_KEY_DELETION but not BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS, which is the flag gating this PR's headline feature. A self-hosted operator copying this block gets org-API-key deletion but no application-key sync/issue/revoke, and the block is the only place the connector's env-var names are spelled out. Worth adding it next to BATON_SYNC_SECRETS with a note that both are required.

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.

Squire (claude-opus-5): Fixed in e1fe609c. BATON_SYNC_SERVICE_ACCOUNT_APPLICATION_KEYS now sits directly under BATON_SYNC_SECRETS in that block, with a comment saying the two go together and naming the Datadog permissions it needs (service_account_write and org_app_keys_read).

The BATON_SYNC_SECRETS comment was misleading on its own too, so it now says what that flag does by itself: syncs organization API keys only.

@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.

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>
Comment thread docs/connector.mdx
Comment on lines +254 to +255
# service account application keys. Needs the Datadog service_account_write
# and org_app_keys_read permissions; without them the sync fails.

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 (medium confidence): this new comment says org_app_keys_read is one of the permissions "without them the sync fails", but the connector's own account of that permission is narrower. serviceAccountApplicationKeyResourceType's comment in pkg/connector/resource_types.go says "A role holding only service_account_write still syncs and issues -- it fails just the owner lookup", and the prose at docs/connector.mdx:57 likewise scopes org_app_keys_read to the revoke owner-lookup path. Only service_account_write is sync-fatal (pkg/connector/application_key.go:254). An operator reading only this block will think a missing org_app_keys_read breaks their sync.

Suggested change
# service account application keys. Needs the Datadog service_account_write
# and org_app_keys_read permissions; without them the sync fails.
# service account application keys. Needs the Datadog service_account_write
# permission, without which the sync fails, plus org_app_keys_read for the
# revoke path's owner lookup.

@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