Skip to content

feat(iam): enforce AssumeRole trust policies when enforcement is enabled - #1552

Merged
hectorvent merged 6 commits into
floci-io:mainfrom
abanna:feat/assumerole-trust-policy
Jul 1, 2026
Merged

feat(iam): enforce AssumeRole trust policies when enforcement is enabled#1552
hectorvent merged 6 commits into
floci-io:mainfrom
abanna:feat/assumerole-trust-policy

Conversation

@abanna

@abanna abanna commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

When FLOCI_SERVICES_IAM_ENFORCEMENT_ENABLED=true, STS AssumeRole now evaluates the target role's trust policy (AssumeRolePolicyDocument) against the caller and returns AccessDenied if it isn't permitted. Enforcement is off by default, so existing behavior is unchanged.

Trust policies are principal-centric and carry no Resource element, so the identity/resource-oriented IamPolicyEvaluator can't evaluate them. This adds a focused AssumeRolePolicyEvaluator that:

  • matches each statement's Action against sts:AssumeRole (supports sts:* / *),
  • matches the AWS Principal"*", a bare account id, an account-root ARN (arn:aws:iam::<acct>:root), or an exact principal ARN,
  • applies AWS precedence: an explicit Deny wins, otherwise a matching Allow grants.

The role is resolved in its own account (parsed from the role ARN) via a new IamService.findRole(account, roleName) overload, since IAM roles are account-namespaced. Roles that Floci has no record of stay permissive, so this only affects roles created through IAM with a real trust policy.

Note: the plan that motivated this work suggested reusing IamPolicyEvaluator, but its statements require a Resource match and ignore Principal, so a dedicated evaluator is used instead (it reuses the shared glob matcher).

Type of change

  • New feature (feat:) — gated behind an existing opt-in flag

AWS Compatibility

Query protocol, unchanged wire format. Only adds an authorization decision on AssumeRole when enforcement is enabled. Service / Federated principals are intentionally not matched against SigV4 callers (function-before-fidelity).

Checklist

  • ./mvnw test passes locally (targeted: AssumeRolePolicyEvaluatorTest 12 + AssumeRoleTrustPolicyIntegrationTest 3 + IamIntegrationTest 37 green; built in a JDK 25 container)
  • New unit + integration tests added (permitted caller allowed, unauthorized denied, unknown role permissive; evaluator principal/action/deny/malformed coverage)
  • Commit messages follow Conventional Commits

Independent of #1549 and #1551, though it composes with them for full cross-account assume-role fidelity.

When floci.iam.enforcement-enabled is true, STS AssumeRole now evaluates the
target role's trust policy (AssumeRolePolicyDocument) against the caller and
returns AccessDenied if it is not permitted. Enforcement is off by default,
so existing behavior is unchanged.

Trust policies are principal-centric and carry no Resource element, so the
identity/resource-oriented IamPolicyEvaluator cannot evaluate them. A focused
AssumeRolePolicyEvaluator matches each statement's Action (sts:AssumeRole) and
AWS Principal (account id, account-root ARN, exact principal ARN, or "*")
against the caller, with explicit Deny taking precedence. The role is resolved
in its own account (from the role ARN) via a new IamService.findRole overload,
since roles are account-namespaced. Roles unknown to Floci stay permissive to
preserve backward compatibility.

Tests: unit coverage for the evaluator across principal/action/deny/malformed
cases, plus an enforcement-enabled integration test asserting a permitted
caller succeeds, an unauthorized caller is denied, and an unknown role stays
permissive.
@hectorvent hectorvent added feature iam AWS Identity and Access Management (IAM) labels Jun 25, 2026
@hectorvent

Copy link
Copy Markdown
Collaborator

This is really clean, Alex, and the evaluator is nicely tested. The wire side checks out: AccessDenied at 403 is the right denial, and the action, principal, and explicit deny precedence all match how AWS evaluates a trust policy.

Two gaps worth noting, both fine to defer as documented limitations since they are about evaluating more than the trust policy itself:

  1. Condition blocks are not evaluated, so sts:ExternalId is not enforced. A role whose trust policy requires an external id is currently assumable without one, and the ExternalId request param is ignored too. For example this statement matches purely on the principal today, with the condition skipped:
{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::111111111111:root" },
  "Action": "sts:AssumeRole",
  "Condition": { "StringEquals": { "sts:ExternalId": "secret-123" } }
}

So the caller gets in without passing ExternalId=secret-123. For what it is worth, moto and localstack do not evaluate this either, so you are already ahead here. Just worth a line in the docs so people know the confused deputy guard is not active yet.

  1. Only the trust policy side is checked. Real cross account AssumeRole also needs the caller's identity policy to allow sts:AssumeRole. Reasonable to leave for later, just good to state as a known limitation.

One tiny thing: AWS prefixes the denial message with the caller, like:

User: arn:aws:iam::111111111111:user/alice is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::222222222222:role/foo

so adding the User: prefix would match it exactly.

Thanks for gating this behind the opt in flag and keeping the default behavior untouched, nice and safe.

Resolve conflicts in IamService.java and StsQueryHandler.java against main's
account-aware session changes. Also address review feedback:
- prefix the AssumeRole AccessDenied message with 'User: <arn>' to match AWS
- document that Condition blocks (sts:ExternalId) and the caller's identity
  policy are not evaluated yet (known limitations)
@abanna

abanna commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @hectorvent! Addressed in 5f4a3e8 (also merged latest main, which had diverged):

  • User: prefix — the AccessDenied message is now User: <callerArn> is not authorized to perform: sts:AssumeRole on resource: <roleArn>, matching AWS exactly. Strengthened the integration test to assert the prefix, action, and resource.
  • ExternalId / Condition blocks — documented as a known limitation in docs/services/sts.md: condition blocks aren't evaluated, so sts:ExternalId isn't enforced and the request param is ignored (matching moto/LocalStack). Left the implementation as a follow-up per your note.
  • Identity-policy side — also documented as a known limitation: only the trust policy is checked, not the caller's own sts:AssumeRole permission.

The merge against main was non-trivial — it reworked IamService/StsQueryHandler for account-aware sessions — so I resolved those and re-ran the IAM/STS suite (151 tests green) in a temurin-25 container against the updated main.

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds trust policy enforcement to STS AssumeRole when FLOCI_SERVICES_IAM_ENFORCEMENT_ENABLED=true. A new AssumeRolePolicyEvaluator evaluates the target role's AssumeRolePolicyDocument against the caller and returns AccessDenied if the caller is not permitted; enforcement is off by default, so existing behavior is unchanged.

  • AssumeRolePolicyEvaluator: Evaluates Action/NotAction (including wildcards), AWS Principal forms ("*", bare account ID, account-root ARN, exact ARN), and applies explicit-Deny-wins precedence. Assumed-role session ARNs are resolved back to the underlying IAM role ARN for principal matching, consistent with AWS semantics.
  • IamService.findRole: New account-scoped lookup overload used by the enforcement path so the role is resolved from the correct account namespace when AccountAwareStorageBackend is in use.
  • StsQueryHandler: Integrates enforceTrustPolicy before building the session, with a permissive fallback for unknown roles (backward compatibility).

Confidence Score: 4/5

Safe to merge with enforcement off (the default); the trust-policy evaluation path has a known gap with non-account-aware storage backends in multi-account setups that was surfaced in a prior review and has not yet been addressed.

The new enforcement logic is gated behind an opt-in flag so existing behavior is untouched. The AssumeRolePolicyEvaluator is well-tested and handles all documented principal forms and Deny-wins precedence correctly. The flat-backend findRole fallback that ignores accountId — flagged in the previous review cycle — remains unresolved and could cause the wrong trust policy to be evaluated in a multi-account flat-storage configuration when enforcement is active.

IamService.java — the findRole fallback to roles.get(roleName) discards the accountId when AccountAwareStorageBackend is not in use.

Important Files Changed

Filename Overview
src/main/java/io/github/hectorvent/floci/services/iam/AssumeRolePolicyEvaluator.java New evaluator correctly handles Action/NotAction, AWS principal forms, assumed-role ARN resolution, and Deny-wins precedence; a statement with a missing Effect key silently defaults to ALLOW.
src/main/java/io/github/hectorvent/floci/services/iam/StsQueryHandler.java Enforcement hook is correctly placed before session registration; callerArn resolution and permissive fallback for unknown roles look correct.
src/main/java/io/github/hectorvent/floci/services/iam/IamService.java Adds account-scoped findRole overload; falls back to flat lookup ignoring accountId when AccountAwareStorageBackend is not in use (known limitation from prior review thread).
src/test/java/io/github/hectorvent/floci/services/iam/AssumeRolePolicyEvaluatorTest.java Good unit coverage of all principal forms, NotAction, Deny-wins, assumed-role ARN resolution, service-only principal, and malformed documents.
src/test/java/io/github/hectorvent/floci/services/iam/AssumeRoleTrustPolicyIntegrationTest.java Covers the three critical end-to-end cases (permitted caller, denied caller, unknown role stays permissive) with enforcement-enabled profile; no issues.
docs/services/sts.md Documents the new enforcement flag and its known limitations (Condition blocks, caller-side identity policy); accurate and complete for the feature scope.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant StsQueryHandler
    participant IamService
    participant AssumeRolePolicyEvaluator

    Client->>StsQueryHandler: AssumeRole(RoleArn, RoleSessionName)
    StsQueryHandler->>StsQueryHandler: Extract roleName, accountId from ARN

    alt enforcement disabled
        StsQueryHandler-->>Client: 200 OK (credentials)
    else enforcement enabled
        StsQueryHandler->>IamService: findRole(roleAccountId, roleName)
        alt role unknown to Floci
            IamService-->>StsQueryHandler: Optional.empty()
            StsQueryHandler-->>Client: 200 OK (permissive fallback)
        else role known
            IamService-->>StsQueryHandler: Optional[IamRole]
            StsQueryHandler->>StsQueryHandler: Resolve callerArn from Authorization header
            StsQueryHandler->>AssumeRolePolicyEvaluator: allows(trustPolicy, callerArn, callerAccount)
            AssumeRolePolicyEvaluator->>AssumeRolePolicyEvaluator: Evaluate Action / NotAction
            AssumeRolePolicyEvaluator->>AssumeRolePolicyEvaluator: Match AWS Principal
            AssumeRolePolicyEvaluator->>AssumeRolePolicyEvaluator: Apply Deny-wins precedence
            AssumeRolePolicyEvaluator-->>StsQueryHandler: true / false
            alt caller permitted
                StsQueryHandler-->>Client: 200 OK (credentials)
            else caller denied
                StsQueryHandler-->>Client: 403 AccessDenied
            end
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant StsQueryHandler
    participant IamService
    participant AssumeRolePolicyEvaluator

    Client->>StsQueryHandler: AssumeRole(RoleArn, RoleSessionName)
    StsQueryHandler->>StsQueryHandler: Extract roleName, accountId from ARN

    alt enforcement disabled
        StsQueryHandler-->>Client: 200 OK (credentials)
    else enforcement enabled
        StsQueryHandler->>IamService: findRole(roleAccountId, roleName)
        alt role unknown to Floci
            IamService-->>StsQueryHandler: Optional.empty()
            StsQueryHandler-->>Client: 200 OK (permissive fallback)
        else role known
            IamService-->>StsQueryHandler: Optional[IamRole]
            StsQueryHandler->>StsQueryHandler: Resolve callerArn from Authorization header
            StsQueryHandler->>AssumeRolePolicyEvaluator: allows(trustPolicy, callerArn, callerAccount)
            AssumeRolePolicyEvaluator->>AssumeRolePolicyEvaluator: Evaluate Action / NotAction
            AssumeRolePolicyEvaluator->>AssumeRolePolicyEvaluator: Match AWS Principal
            AssumeRolePolicyEvaluator->>AssumeRolePolicyEvaluator: Apply Deny-wins precedence
            AssumeRolePolicyEvaluator-->>StsQueryHandler: true / false
            alt caller permitted
                StsQueryHandler-->>Client: 200 OK (credentials)
            else caller denied
                StsQueryHandler-->>Client: 403 AccessDenied
            end
        end
    end
Loading

Reviews (4): Last reviewed commit: "fix(iam): match an assumed-role caller a..." | Re-trigger Greptile

Comment thread docs/services/sts.md
Greptile review on floci-io#1552:
- Restore the FLOCI_SERVICES_STS_ENABLED Configuration section that was dropped
  when the Trust Policy Enforcement section was added.
- Document that NotAction is not evaluated (a Deny with NotAction won't block).
@abanna

abanna commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @greptile-apps — went through all three:

  • Configuration section dropped from sts.md (P2): Valid, fixed in 5ffda29 — restored the FLOCI_SERVICES_STS_ENABLED table.
  • NotAction silently skipped (P2): Valid in principle — a Deny with NotAction won't block the assume. Trust policies using NotAction are very rare, and like Condition evaluation it's an advanced policy feature this PR intentionally defers, so I documented it as a known limitation rather than implementing partial NotAction semantics now.
  • findRole flat-backend fallback ignores accountId (P2): In practice roles is always an AccountAwareStorageBackend (main made IAM account-namespaced), so the fallback is only a safety net. A genuinely flat backend has no account dimension to key on, so roles.get(roleName) is the only thing it can do — passing accountId wouldn't change the result. Left as-is.

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

All three responses are reasonable — thanks for the thorough follow-up.

  1. Configuration section in sts.md — fix looks good.
  2. NotAction — documenting it as a known limitation is the right call for a deferred edge case; trust policies using NotAction are genuinely rare in practice.
  3. flat-backend fallback — the argument holds. A backend with no account dimension can only key on role name, so passing accountId wouldn't change anything. The real concern would be if the AccountAwareStorageBackend path somehow fell through to the flat branch unexpectedly, but that's a pre-existing structural guarantee, not something this PR needs to defend.

No further concerns on these. The PR looks good to go from my end.

evaluateStatement only inspected the Action key, so a trust statement using
NotAction returned NO_MATCH and was skipped entirely. A Deny that should block
sts:AssumeRole (NotAction excluding some other action) was therefore ignored,
over-permitting AssumeRole when IAM enforcement is enabled. Add actionApplies()
mirroring IamPolicyEvaluator's Action/NotAction semantics: Action applies when
any pattern matches, NotAction applies when none match. Covered by three new
AssumeRolePolicyEvaluatorTest cases.
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

…ipal

When a caller uses assumed-role temporary credentials, resolveCallerArn (rightly,
for GetCallerIdentity) yields the STS assumed-role ARN
(arn:aws:sts::ACCT:assumed-role/Role/session). enforceTrustPolicy passes that to
the trust-policy evaluator, but role trust policies name the role's IAM principal
ARN (arn:aws:iam::ACCT:role/Role) — the canonical form AWS resolves the session
back to. So a legitimate role-to-role AssumeRole was denied.

matchesAwsPrincipal now also normalizes an assumed-role caller ARN to its
underlying IAM role ARN and matches the principal against that, while still
matching an exact session-ARN principal. resolveCallerArn is unchanged, so
GetCallerIdentity keeps returning the assumed-role ARN.

Adds AssumeRolePolicyEvaluatorTest cases for role-ARN match, differing-role
denial, and exact session-ARN match.

@hectorvent hectorvent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @abanna

@hectorvent
hectorvent merged commit d54a307 into floci-io:main Jul 1, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature iam AWS Identity and Access Management (IAM) waiting-contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants