Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Migrate IAM role implementation#13798

Merged
dfangl merged 2 commits into
iam/moto-migrationfrom
daniel/unc-229
Feb 20, 2026
Merged

Migrate IAM role implementation#13798
dfangl merged 2 commits into
iam/moto-migrationfrom
daniel/unc-229

Conversation

@dfangl

@dfangl dfangl commented Feb 19, 2026

Copy link
Copy Markdown
Member

Motivation

We are migrating IAM role handling from moto to LocalStack now - this includes attachement and detachement of policies.

Changes

1. Entity Structure

The RoleEntity dataclass wraps the API Role type with additional relations:

@dataclasses.dataclass
class RoleEntity:
    role: Role  # From localstack.aws.api.iam
    inline_policies: dict[str, str] = field(default_factory=dict)  # policy_name -> document
    attached_policy_arns: list[str] = field(default_factory=list)  # ARNs of attached managed policies
    linked_service: str | None = None  # For service-linked roles

Rationale:

  • role: Role stores all core role attributes (Path, RoleName, RoleId, Arn, CreateDate, AssumeRolePolicyDocument, Description, MaxSessionDuration, PermissionsBoundary, Tags, RoleLastUsed)
  • inline_policies is a dict mapping policy name to policy document JSON string, matching how inline policies work
  • attached_policy_arns is a list (not set) to preserve attachment order; the ManagedPolicyEntity tracks its own AttachmentCount
  • linked_service is needed for service-linked role identification

2. Storage Key

Roles are keyed by role name in IamStore.ROLES:

ROLES: dict[str, RoleEntity] = CrossRegionAttribute(default=dict)

Rationale:

  • Role names are unique per account
  • Provides O(1) lookup by name, which is the most common access pattern
  • Uses CrossRegionAttribute since IAM is a global service

3. Locking Strategy

A separate _role_lock is used for role operations, independent of _policy_lock:

_role_lock: threading.Lock

Rationale:

  • Allows concurrent policy and role operations
  • Simpler than fine-grained locking per role
  • Consistent with the policy migration approach

4. Trust Policy Validation

Trust policies (AssumeRolePolicyDocument) are validated separately from regular IAM policies, as in moto:

  • Must be valid JSON
  • Cannot contain Resource field (unlike regular policies)
  • Actions must be valid STS actions: sts:AssumeRole, sts:AssumeRoleWithSAML, sts:AssumeRoleWithWebIdentity, sts:TagSession, sts:SetSourceIdentity, or *

5. Permissions Boundary Handling

Permissions boundaries are validated to:

  • Have valid ARN format (must contain :policy/)
  • Exist (for customer-managed policies; AWS-managed policies are trusted)

Stored in role["PermissionsBoundary"] as AttachedPermissionsBoundary:

{
    "PermissionsBoundaryType": "Policy",
    "PermissionsBoundaryArn": "<arn>"
}

6. Managed Policy Attachment

When attaching/detaching customer-managed policies:

  • The policy ARN is added to/removed from attached_policy_arns
  • The AttachmentCount on ManagedPolicyEntity.policy is incremented/decremented with a policy lock
  • AWS-managed policies (prefix arn:{partition}:iam::aws:policy/) are tracked in attached_policy_arns but don't update any count since they're not in our store

7. Instance Profile Interaction

Instance profiles remain in moto for now. The delete_role operation checks moto's instance profiles to ensure the role is not attached before deletion:

for profile in backend.instance_profiles.values():
    for role in profile.roles:
        if role.name == role_name:
            raise DeleteConflictException(...)

Future work: When instance profiles are migrated, they should store role names and look up from IamStore.ROLES.

Migrated Operations

Core CRUD

  • create_role - Creates role in native store with validation
  • get_role - Returns role from native store
  • delete_role - Deletes from native store with constraint checks
  • list_roles - Lists from native store with path filtering and pagination
  • update_role - Updates description and/or max_session_duration
  • update_role_description - Updates description only
  • update_assume_role_policy - Updates trust policy with validation

Tagging

  • tag_role - Adds/updates tags with validation
  • untag_role - Removes tags by key
  • list_role_tags - Lists tags with pagination

Inline Policies

  • put_role_policy - Adds/updates inline policy with validation
  • get_role_policy - Returns inline policy document
  • list_role_policies - Lists inline policy names
  • delete_role_policy - Removes inline policy

Managed Policy Attachment

  • attach_role_policy - Attaches managed policy, updates AttachmentCount
  • detach_role_policy - Detaches managed policy, updates AttachmentCount
  • list_attached_role_policies - Lists attached policies with path filtering

Permissions Boundary

  • put_role_permissions_boundary - Sets permissions boundary
  • delete_role_permissions_boundary - Removes permissions boundary

Service-Linked Roles

  • create_service_linked_role - Creates in native store with linked_service set
  • delete_service_linked_role - Deletes from native store (allows attached policies)
  • get_service_linked_role_deletion_status - Returns SUCCESS (unchanged)

Known Limitations

  1. RoleLastUsed: Not implemented - would require tracking role assumption across STS
  2. Instance Profiles: Still in moto - creates potential state divergence if directly manipulated
  3. AWS-Managed Policies: Existence not validated on attach (trusted to exist)
  4. Service-Linked Role Deletion: Synchronous (AWS is async with task tracking)

Deviations from Moto Behavior

  1. Trust Policy Validation: More strict validation of STS actions
  2. Permissions Boundary Validation: Validates ARN format and existence
  3. url encoding: Always URL-encoding policy documents (already at store time), to avoid unencoded policies anywhere

Tests

Tests are in tests/aws/services/iam/test_iam_roles.py and cover:

  • Role lifecycle (create, get, delete, update)
  • Error cases (NoSuchEntity, EntityAlreadyExists, DeleteConflict)
  • Tagging operations
  • Inline policy operations
  • Managed policy attachment
  • Permissions boundary
  • Pagination

Related

Closes UNC-229

@dfangl dfangl requested a review from pinzon as a code owner February 19, 2026 14:32
@dfangl dfangl added this to the 4.14 milestone Feb 19, 2026
@dfangl dfangl added semver: minor Non-breaking changes which can be included in minor releases, but not in patch releases docs: skip Pull request does not require documentation changes notes: skip Pull request does not have to be mentioned in the release notes labels Feb 19, 2026
@github-actions

github-actions Bot commented Feb 19, 2026

Copy link
Copy Markdown

Test Results - Preflight, Unit

23 123 tests   21 252 ✅  6m 19s ⏱️
     1 suites   1 871 💤
     1 files         0 ❌

Results for commit 6706e17.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Feb 19, 2026

Copy link
Copy Markdown

LocalStack Community integration with Pro

  2 files    2 suites   2m 39s ⏱️
518 tests 423 ✅ 88 💤 7 ❌
520 runs  423 ✅ 90 💤 7 ❌

For more details on these failures, see this check.

Results for commit 6706e17.

♻️ This comment has been updated with latest results.

@dfangl dfangl changed the base branch from iam/moto-migration to daniel/unc-227 February 19, 2026 15:06

@pinzon pinzon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for starting this file 👍

Comment on lines -669 to 672
@markers.snapshot.skip_snapshot_verify(
paths=["$..Role.Tags"]
) # Moto returns an empty list for no tags
def test_put_role_policy_encoding(self, snapshot, aws_client, create_role, region_name):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

Comment on lines +12 to +21
@dataclasses.dataclass
class RoleEntity:
"""Wrapper for Role with inline policies and managed policy tracking."""

role: Role # From localstack.aws.api.iam
inline_policies: dict[str, str] = field(default_factory=dict) # policy_name -> document
attached_policy_arns: list[str] = field(
default_factory=list
) # ARNs of attached managed policies
linked_service: str | None = None # For service-linked roles

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like how simple the class is.

if permissions_boundary:
self._validate_permissions_boundary(context, permissions_boundary)

with self._role_lock:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice consideration 👍

Comment on lines +396 to +480
def tag_role(
self,
context: RequestContext,
role_name: roleNameType,
tags: tagListType,
**kwargs,
) -> None:
self._validate_tags(tags, case_sensitive=False)

store = self._get_store(context)
with self._role_lock:
role_entity = self._get_role_entity(store, role_name)

# Initialize tags if not present
if "Tags" not in role_entity.role or role_entity.role["Tags"] is None:
role_entity.role["Tags"] = []

# Merge tags - update existing keys, add new ones, case-insensitive
existing_keys = {
tag["Key"].lower(): i for i, tag in enumerate(role_entity.role["Tags"])
}
for tag in tags:
key = tag["Key"].lower()
if key in existing_keys:
role_entity.role["Tags"][existing_keys[key]] = tag
else:
role_entity.role["Tags"].append(tag)

def untag_role(
self,
context: RequestContext,
role_name: roleNameType,
tag_keys: tagKeyListType,
**kwargs,
) -> None:
self._validate_tag_keys(tag_keys)

store = self._get_store(context)
with self._role_lock:
role_entity = self._get_role_entity(store, role_name)

if "Tags" in role_entity.role and role_entity.role["Tags"]:
# Remove tags with matching keys (case-sensitive)
tag_keys_set = {key.lower() for key in tag_keys}
role_entity.role["Tags"] = [
tag
for tag in role_entity.role["Tags"]
if tag["Key"].lower() not in tag_keys_set
]

def list_role_tags(
self,
context: RequestContext,
role_name: roleNameType,
marker: markerType = None,
max_items: maxItemsType = None,
**kwargs,
) -> ListRoleTagsResponse:
store = self._get_store(context)
with self._role_lock:
role_entity = self._get_role_entity(store, role_name)
tags = list(role_entity.role.get("Tags") or [])

# Sort alphabetically by key, then by key length
tags.sort(key=lambda k: k["Key"])
tags.sort(key=lambda k: len(k["Key"]))

paginated_list = PaginatedList(tags)

def _token_generator(tag: Tag) -> str:
return tag.get("Key")

# base64 encode/decode to avoid plaintext tag as marker
if marker:
marker = base64.b64decode(marker).decode("utf-8")

result, next_marker = paginated_list.get_page(
token_generator=_token_generator, next_token=marker, page_size=max_items or 100
)

if next_marker:
next_marker = base64.b64encode(next_marker.encode("utf-8")).decode("utf-8")
return ListRoleTagsResponse(Tags=result, IsTruncated=True, Marker=next_marker)
else:
return ListRoleTagsResponse(Tags=result, IsTruncated=False)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since time is precious now we should simply migrate but let's remember later that these operations related to tags will change due to the resource group tagging project.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed! I reached out to Ben on the proper current way to do this, I am not sure since the service is in community, and I would like not to use the override appraoch. We can migrate it in a big bang at the end of the project.

Comment on lines +1258 to +1267
role: Role = {
"Path": path,
"RoleName": role_name,
"RoleId": role_id,
"Arn": role_arn,
"CreateDate": datetime.now(tz=UTC),
"AssumeRolePolicyDocument": quote(policy_doc),
"MaxSessionDuration": 3600,
"RoleLastUsed": RoleLastUsed(),
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: IMO defining the role as Role(Path=path....) is more explicit about the usage of the api typed dict.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, did not catch that one! I changed it in most usages (especially in later PRs)

Comment on lines +1428 to +1429
policy_entity = store.MANAGED_POLICIES[policy_arn]
policy_entity.policy["AttachmentCount"] += 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

idea: wouldn't it be better to calculate AttachmentCount per request? this relies on us always remembering to update the value. Maybe it's not worth it since there is only 2 operations for now that affect that count.

@dfangl dfangl Feb 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There will be a total of 6 operations for this (users, roles, groups). I am not super happy with this currently, I agree!
Let's refactor we implemented them all, we can check then what the best pattern would be!

@github-actions

Copy link
Copy Markdown

Test Results (amd64) - Acceptance

7 tests   5 ✅  3m 0s ⏱️
1 suites  2 💤
1 files    0 ❌

Results for commit 6706e17.

@github-actions

Copy link
Copy Markdown

Test Results (amd64) - Integration, Bootstrap

  5 files  ±0    5 suites  ±0   11m 21s ⏱️ -2s
542 tests ±0  446 ✅ +19  88 💤  - 20  8 ❌ +1 
548 runs  ±0  446 ✅ +19  94 💤  - 20  8 ❌ +1 

For more details on these failures, see this check.

Results for commit 6706e17. ± Comparison against base commit 6241c44.

Base automatically changed from daniel/unc-227 to iam/moto-migration February 20, 2026 09:25
@dfangl dfangl merged commit 3e9b86f into iam/moto-migration Feb 20, 2026
5 of 7 checks passed
@dfangl dfangl deleted the daniel/unc-229 branch February 20, 2026 09:35
dfangl added a commit that referenced this pull request Feb 26, 2026
dfangl added a commit that referenced this pull request Feb 26, 2026
dfangl added a commit that referenced this pull request Mar 4, 2026
dfangl added a commit that referenced this pull request Mar 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

docs: skip Pull request does not require documentation changes notes: skip Pull request does not have to be mentioned in the release notes semver: minor Non-breaking changes which can be included in minor releases, but not in patch releases

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants