Skip to content

Emit ASSUMABLE_ROLE NHI type for IAM roles (NHI Phase-1 K3)#126

Merged
agustin-conductor merged 5 commits into
mainfrom
nhi/emit-nhi-role
Jul 1, 2026
Merged

Emit ASSUMABLE_ROLE NHI type for IAM roles (NHI Phase-1 K3)#126
agustin-conductor merged 5 commits into
mainfrom
nhi/emit-nhi-role

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Implements NHI Phase-1 K3 type emission (per the NHI RFC §6 row 1); IAM roles (TRAIT_ROLE) → WithNHIType(NHI_TYPE_ASSUMABLE_ROLE, detail).

Per §6 this is a mod: the trust-policy classification is parsed at sync time inside roleResourceType.List() (pkg/connector/role.go), so the NHI type + detail land on every synced role with no extra API call (ListRoles already returns AssumeRolePolicyDocument + Path inline).

Detail strings follow the §2.8 convention <platform>.<object>[.<purpose>] (dotted lowercase), most-specific first:

  • service-linked role (path /aws-service-role/) → aws.role.service_linked
  • trusted by an AWS service principal → aws.role.<service> (e.g. aws.role.lambda, aws.role.ec2, aws.role.ecs_tasks)
  • trusted by a federated provider → aws.role.oidc / aws.role.saml / aws.role.federated
  • trusted by an AWS principal in a different account → aws.role.cross_account
  • otherwise → aws.role

The existing AWS-principal assume-role grant path is unchanged; Service/Federated trust principals (previously discarded in Principal.UnmarshalJSON) are now retained solely for classification.

Self-bumps baton-sdk → v0.11.0 (interim; rebases after the baton-admin fleet bump). No protogen. Verified the shipped v0.11.0 API is WithNHIType / NonHumanIdentityTrait_NHI_TYPE_ASSUMABLE_ROLE (the RFC's WithNHISubtype was a stale draft name).

go build ./..., go test ./..., and golangci-lint run all pass; added TestClassifyRoleNHIDetail covering each branch.


🛰️ Built with pqprime.

@c1-squire-dev
c1-squire-dev Bot requested a review from a team May 31, 2026 15:54
@github-actions

github-actions Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: Emit ASSUMABLE_ROLE NHI type for IAM roles (NHI Phase-1 K3)

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: loaded .claude/skills/ci-review.md from trusted base 1461605cbb3c
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. This change classifies IAM roles into NHI types at sync time inside roleResourceType.List() by parsing the trust policy already returned inline by ListRoles, and retains Service/Federated trust principals (previously discarded) solely for classification. The AWS-principal assume-role grant path is unchanged, the addition is additive (no breaking trait/ID/slug changes), and classifyRoleNHI is well covered by table-driven tests. No go.mod/go.sum changes are present (the SDK is already at v0.16.0). No blocking issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/role.go:349isCrossAccountTrust skips bare-account-ID AWS principals, so such a cross-account role falls back to aws.role; usually mitigated by IAM normalizing bare IDs to :root ARNs (low confidence).
  • pkg/connector/helpers.go:355 — (previously raised, still unaddressed) Principal.UnmarshalJSON uses the awsDisplayName UI display-name constant as the JSON map key for the IAM AWS principal field; it works only because the value coincides with the IAM key. A literal key would decouple wire parsing from the UI display name.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/role.go`:
- Around line 349-356: `isCrossAccountTrust` calls `arnAccountID`, which uses `arn.Parse` and returns ok=false for a bare 12-digit account-ID AWS principal. Such a principal is skipped, so a genuinely cross-account role expressed that way is classified as `aws.role` instead of `aws.role.cross_account`. If full coverage is desired, detect a bare 12-digit numeric AWS principal and treat it as the account ID directly when comparing against the role own account. Low priority: IAM usually normalizes bare account IDs to a root ARN in the stored document.

In `pkg/connector/helpers.go`:
- Around line 354-358: In `Principal.UnmarshalJSON`, the map literal uses the `awsDisplayName` constant as the JSON key for the IAM `AWS` principal field. This couples wire parsing to a UI display-name constant. Replace the `awsDisplayName` key with a literal AWS string so the parser does not break if the display name ever changes.

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

@c1-squire-dev
c1-squire-dev Bot force-pushed the nhi/emit-nhi-role branch from ba8b3e5 to 5dc7ee3 Compare May 31, 2026 16:49

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

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

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

@c1-squire-dev
c1-squire-dev Bot force-pushed the nhi/emit-nhi-role branch from f5d6965 to 8c8aab3 Compare June 1, 2026 04:13

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

@johnallers johnallers self-assigned this Jun 23, 2026
Comment thread .github/workflows/ci.yaml Outdated
Comment on lines +23 to +34
- name: Install baton CLI from baton-sdk (conductorone/baton is archived)
run: |
set -euxo pipefail
BATON_VERSION=v0.11.0
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
if [ "${ARCH}" = "x86_64" ]; then ARCH="amd64"; fi
FILENAME="baton-${BATON_VERSION}-${OS}-${ARCH}.tar.gz"
curl -fsSL -O "https://github.com/conductorone/baton-sdk/releases/download/${BATON_VERSION}/${FILENAME}"
tar xzf "${FILENAME}"
mv baton /usr/local/bin/baton
baton --version

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.

This is probably unnecessary. I think this was addressed by ConductorOne/github-workflows#89

@linear-code

linear-code Bot commented Jun 23, 2026

Copy link
Copy Markdown

CXP-605

@agustin-conductor
agustin-conductor requested review from a team and johnallers June 26, 2026 12:30
pquerna and others added 4 commits June 26, 2026 09:32
Implements NHI Phase-1 K3 type emission (per the NHI RFC §6 row 1); IAM
roles (TRAIT_ROLE) are annotated WithNHIType(NHI_TYPE_ASSUMABLE_ROLE,
detail). The trust-policy classification is parsed at sync time in
List() so the type detail is set on every synced role.

Detail strings follow the §2.8 convention <platform>.<object>[.<purpose>]:
service-linked roles -> aws.role.service_linked; service-trusted ->
aws.role.<service> (e.g. aws.role.lambda); federated -> aws.role.oidc/
saml/federated; cross-account -> aws.role.cross_account; else aws.role.

Self-bumps baton-sdk -> v0.11.0 (interim; rebases after the baton-admin
fleet bump). The existing AWS-principal grant path is unchanged; Service
and Federated trust principals are now retained solely for classification.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
…archived (v0.4.5) and can't resolve NonHumanIdentityTrait

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

AWS service-linked roles are platform-custodied — the org doesn't control
their trust policy, AWS does — so they map to NHI_TYPE_MANAGED_IDENTITY
rather than ASSUMABLE_ROLE. Detected by the reserved /aws-service-role/
path or the AWSServiceRoleFor* name prefix; all other roles keep
ASSUMABLE_ROLE with their existing trust-derived detail.

Bumps baton-sdk to v0.11.1, which adds NHI_TYPE_MANAGED_IDENTITY=3.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/connector/helpers.go
var singleARN string
err = json.Unmarshal(awsFieldData, &singleARN)
for field, dest := range map[string]*[]string{
awsDisplayName: &principal.AWS,

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: Using awsDisplayName (the connector's "AWS" display-name constant from connector.go) as the JSON field key for the IAM Principal.AWS field couples trust-policy parsing to an unrelated UI constant. It works today only because both happen to be "AWS". If someone ever changes the display name (e.g. to "Amazon Web Services"), AWS principal parsing silently breaks and assume-role grants would no longer be detected. Use a literal "AWS" here instead.

@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/connector/helpers.go
// Try string first
var singleARN string
err = json.Unmarshal(awsFieldData, &singleARN)
for field, dest := range map[string]*[]string{

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.

the loop handles AWS/Service/Federated but there's no branch for when Principal is the string "*" (wildcard trust). classifyRoleNHI falls back to base "aws.role", making a publicly-assumable role look the same as a same-account one. probably worth a distinct detail like "aws.role.public".

Comment thread pkg/connector/helpers.go
return tp, nil
}

func hasAssumeRoleAction(actions Action) bool {

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.

this uses strings.HasPrefix so it catches AssumeRoleWithWebIdentity and AssumeRoleWithSAML too, but extractTrustPrincipals (the one that drives actual grant emission) still uses slices.Contains(actions, "sts:AssumeRole") — exact match only. worth a comment explaining why they differ, or aligning them.

Comment thread pkg/connector/helpers.go Outdated
Comment thread pkg/connector/role.go
func serviceTrustDetail(services []string) string {
short := make([]string, 0, len(services))
for _, s := range services {
name, _, _ := strings.Cut(s, ".") // "lambda.amazonaws.com" -> "lambda"

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.

strings.Cut(s, ".") on something like "accounts.google.com" gives "accounts", which passes the non-empty check and produces "aws.role.accounts". probably fine in practice since non-amazonaws.com values are rare in the Service field, but a guard checking that the string ends with .amazonaws.com before using the first segment would make it safer.

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.

not a valid concern, AWS only allows *.amazonaws.com
service principals in trust policies — it's enforced at policy creation time

Comment thread pkg/connector/role.go
}

// arnAccountID extracts the 12-digit account ID from an ARN.
func arnAccountID(arnStr string) (string, bool) {

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.

this duplicates AccountIdFromARN in arn.go — same arn.Parse call, just returns bool instead of error. could just be: id, err := AccountIdFromARN(arnStr); return id, err == nil

Comment thread pkg/connector/helpers.go
// returns the AWS, Service, and Federated principals from Allow statements that
// grant an sts:AssumeRole* action (AssumeRole, AssumeRoleWithWebIdentity,
// AssumeRoleWithSAML).
func extractTrustPrincipalsByKind(policyDocument string) (trustPrincipals, 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.

extractTrustPrincipals (line 152) already does the same url.QueryUnescape + json.Unmarshal + Effect=Allow loop. now that this function exists, extractTrustPrincipals could just call it and return tp.aws, eliminating the duplication.

@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/connector/role.go
Comment on lines +349 to +356
func isCrossAccountTrust(roleAccountID string, awsPrincipals []string) bool {
for _, p := range awsPrincipals {
if acct, ok := arnAccountID(p); ok && acct != roleAccountID {
return true
}
}
return false
}

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 (low confidence): isCrossAccountTrust relies on arnAccountID, which uses arn.Parse and so returns ok=false for a bare-account-ID principal (e.g. "AWS": "123456789012"). Such a principal is silently skipped, so a genuinely cross-account role expressed that way would fall back to aws.role instead of aws.role.cross_account. In practice IAM normalizes bare account IDs to arn:aws:iam::<acct>:root in the stored trust document, so this rarely manifests — but if you want full coverage, treat a 12-digit numeric principal as an account ID directly.

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

@agustin-conductor
agustin-conductor merged commit 7d75518 into main Jul 1, 2026
10 checks passed
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