diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6f7d953d7..209fb4444 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -168,7 +168,7 @@ read. | Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status | |---------|-------------------|---------------|---------------|-------------|------------|--------| -| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName only — routes via spread or cluster strategy) | `--security-group-ids`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--iam-instance-profile`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional) | Gateway parses AWS query → if Placement.GroupName set, looks up strategy: spread → `distributeInstancesSpread()` (1 instance per node, atomic CAS reservation), cluster → `distributeInstancesCluster()` (pin all to single node); otherwise NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → cloud-init injects user-data/keys → on termination, removes instance from placement group → returns reservation with instance ID | 1. Launch with valid AMI and key pair
2. Launch with invalid AMI ID (error)
3. Launch with block device mappings (custom volume size)
4. Launch multiple instances (MinCount/MaxCount)
5. Launch with subnet-id (auto-creates ENI)
6. Invalid instance type returns error
7. Launch with spread placement group (1 per node)
8. Launch with cluster placement group (all on one node)
9. Insufficient capacity for placement group (error) | **DONE** | +| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName only — routes via spread or cluster strategy), `--iam-instance-profile` (Name or Arn form — gateway resolves via `IAMService.ResolveInstanceProfile`, enforces `iam:PassRole` on the contained role ARN, rejects cross-account profile ARNs) | `--security-group-ids`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional), `create-instance-profile` + `add-role-to-instance-profile` (optional) | Gateway parses AWS query → if `IamInstanceProfile` set, resolves via `IAMService.ResolveInstanceProfile` (Name or same-account Arn), enforces `iam:PassRole` on the contained role ARN (skipped if profile has no role attached — matches AWS), rejects cross-account profile ARNs with AccessDenied → if Placement.GroupName set, looks up strategy: spread → `distributeInstancesSpread()` (1 instance per node, atomic CAS reservation), cluster → `distributeInstancesCluster()` (pin all to single node); otherwise NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → if profile attached, stamps `IamInstanceProfileArn` + generates `iip-assoc-…` onto `vm.VM` and populates `Instance.IamInstanceProfile` on response → cloud-init injects user-data/keys → on termination, removes instance from placement group and auto-disassociates profile → returns reservation with instance ID | 1. Launch with valid AMI and key pair
2. Launch with invalid AMI ID (error)
3. Launch with block device mappings (custom volume size)
4. Launch multiple instances (MinCount/MaxCount)
5. Launch with subnet-id (auto-creates ENI)
6. Invalid instance type returns error
7. Launch with spread placement group (1 per node)
8. Launch with cluster placement group (all on one node)
9. Insufficient capacity for placement group (error)
10. Launch with `--iam-instance-profile Name=…` populates `vm.VM` profile fields and `DescribeInstances` returns IamInstanceProfile
11. Launch with `--iam-instance-profile Arn=…` (same-account ARN form) accepted
12. Caller lacks `iam:PassRole` on role (AccessDenied)
13. Cross-account profile ARN (AccessDenied)
14. Non-existent profile (InvalidIamInstanceProfile.NotFound)
15. Profile with no role attached (PassRole check skipped, instance launches) | **DONE** | | `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:\*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway fans out NATS `ec2.DescribeInstances` to all nodes (no queue group) → each daemon returns local instances → gateway aggregates and returns combined list. Filters applied per-node before aggregation (reduces payload). Also applies to stopped/terminated instances via `describeInstancesFromKV()`. | 1. Describe all instances (no filter)
2. Describe by instance ID
3. Describe with filters (e.g. instance-state-name)
4. Instance not found returns empty set
5. Multi-node aggregation returns instances from all nodes
6. Filter by tag
7. Unknown filter returns InvalidParameterValue | **DONE** | | `start-instances` | `--instance-ids` | `--dry-run`, `--force` | `run-instances` (instance must exist in stopped state) | Gateway sends NATS `ec2.cmd.{instance-id}` → daemon restarts stopped QEMU process with same config → state transitions stopped→pending→running | 1. Start a stopped instance
2. Start already-running instance (error: IncorrectInstanceState)
3. Start with invalid instance ID
4. Verify volumes re-mount on start | **DONE** | | `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_powerdown` for graceful shutdown → monitors heartbeat until QEMU exits → state transitions running→stopping→stopped | 1. Graceful stop of running instance
2. Force stop (kills QEMU process)
3. Stop already-stopped instance (error)
4. Verify ~30s heartbeat detection | **DONE** | @@ -183,6 +183,17 @@ read. | `monitor-instances` | — | `--instance-ids` | Instance must exist | Enable basic monitoring (CPU, disk, network) → store metrics in NATS KV → return monitoring state | 1. Enable monitoring
2. Verify monitoring state in describe-instances | **NOT STARTED** | | `unmonitor-instances` | — | `--instance-ids` | Instance must exist | Disable detailed monitoring → revert to basic monitoring → return monitoring state | 1. Disable monitoring
2. Verify monitoring state reverts in describe-instances | **NOT STARTED** | +### EC2 - IAM Instance Profile Associations + +IAM instance profile associations bind an IAM `InstanceProfile` (containing 0 or 1 role) to a running EC2 instance. Backed by `vm.VM.IamInstanceProfileArn` + `vm.VM.IamInstanceProfileAssociationId` — a single ARN + association ID per instance, no auxiliary KV index. Mutation ops are routed by `instance_id` (direct send via `ec2.cmd.{instance_id}`) or by `association_id` (NATS fan-out on `ec2.IamProfileAssociation.{disassociate,replace,describe}` with always-respond semantics for early-exit aggregation). `iam:PassRole` is enforced at the gateway on every op that attaches a profile; cross-account profile ARNs are rejected. Association IDs (`iip-assoc-` + 17 hex) are regenerated on every Associate/Replace and never reused. Auto-disassociate on terminate; preserved across stop/start. AWS SDK reference: `ec2:*IamInstanceProfile*`. + +| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status | +|---------|-------------------|---------------|---------------|-------------|------------|--------| +| `associate-iam-instance-profile` | `--instance-id`, `--iam-instance-profile Name=…/Arn=…` | `--dry-run` | Instance exists, profile exists in caller's account, caller has `iam:PassRole` on role ARN | Gateway resolves profile via `IAMService.ResolveInstanceProfile` → if profile has a role, checks `iam:PassRole` on role ARN (skipped when profile has no role attached) → rejects cross-account profile ARNs (AccessDenied) → sends NATS `ec2.cmd.{instance_id}` with `AssociateIamInstanceProfile` flag + ProfileArn + AssociationId payload → daemon validates target instance has no existing profile (`IamInstanceProfileAlreadyAssociated` otherwise) → generates `iip-assoc-{17 hex}` → atomically writes `IamInstanceProfileArn` + `IamInstanceProfileAssociationId` onto `vm.VM` and persists via `WriteState` | 1. Associate to instance with no profile
2. Instance already associated (IamInstanceProfileAlreadyAssociated)
3. Caller lacks `iam:PassRole` (AccessDenied)
4. Cross-account profile ARN (AccessDenied)
5. Profile not found (InvalidIamInstanceProfile.NotFound)
6. Profile has no role (PassRole skipped, attaches) | **DONE** | +| `disassociate-iam-instance-profile` | `--association-id` | `--dry-run` | Association must exist | Gateway broadcasts NATS `ec2.IamProfileAssociation.disassociate` (fan-out, no queue group — every active daemon always responds, NoOp on miss, enabling `expectedNodes` early-exit per `DescribeInstances` pattern) → owning daemon matches `association_id` against local `vm.VM` records under single read-validate-write, clears both profile fields atomically → returns NoSuchAssociation if no owner found | 1. Disassociate valid ID
2. Stale ID (NoSuchAssociation)
3. After terminate auto-disassociate, subsequent call returns NoSuchAssociation | **DONE** | +| `replace-iam-instance-profile-association` | `--association-id`, `--iam-instance-profile Name=…/Arn=…` | `--dry-run` | Association exists, new profile exists in caller's account, caller has `iam:PassRole` on new role | Gateway resolves new profile → enforces `iam:PassRole` on new role only (old role is not re-checked — matches AWS) → rejects cross-account profile ARNs (AccessDenied) → broadcasts NATS `ec2.IamProfileAssociation.replace` (fan-out, always-respond) → owning daemon validates supplied `association_id` matches current `vm.VM.IamInstanceProfileAssociationId` (NoSuchAssociation otherwise), generates fresh `iip-assoc-…`, atomically swaps `(IamInstanceProfileArn, IamInstanceProfileAssociationId)` → returns new IamInstanceProfileAssociation | 1. Replace with new profile (returns fresh association ID)
2. Stale association ID (NoSuchAssociation)
3. Caller lacks PassRole on new role (AccessDenied)
4. Cross-account new profile (AccessDenied) | **DONE** | +| `describe-iam-instance-profile-associations` | `--association-ids`, `--filters` (instance-id, state) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway broadcasts NATS `ec2.IamProfileAssociation.describe` (fan-out, always-respond) → each daemon enumerates local `vm.VM` records, returns synthesized IamInstanceProfileAssociation records matching the filter set → gateway aggregates per `DescribeStoppedInstances` early-exit pattern → returns combined list | 1. List all associations
2. Filter by association-id
3. Filter by instance-id
4. Filter by state
5. Empty result | **DONE** | + ### EC2 - Key Pair Management | Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status | @@ -594,7 +605,7 @@ Instance profiles are containers for IAM roles that allow EC2 instances to assum | `get-instance-profile` | `--instance-profile-name` | — | Profile must exist | IAMService.GetInstanceProfile(accountID, input) → reads from KV → returns InstanceProfile; `Roles` is a 0- or 1-element list derived from `RoleName` | 1. Get existing profile
2. Non-existent (NoSuchEntity) | **DONE** | | `list-instance-profiles` | `--path-prefix` | *(pagination — `--max-items`, `--marker` — intentionally deferred)* | None | IAMService.ListInstanceProfiles(accountID, input) → prefix-scans KV for `{accountID}.*` → filters by `strings.HasPrefix(Path, PathPrefix)` → returns list | 1. List all profiles
2. List with path prefix
3. Empty list | **DONE** | | `list-instance-profiles-for-role` | `--role-name` | *(pagination — `--max-items`, `--marker` — intentionally deferred)* | Role must exist | IAMService.ListInstanceProfilesForRole(accountID, input) → scan profiles in caller's account → filter where `RoleName == this role` → return list | 1. List profiles for role
2. Role not in any profile (empty) | **DONE** | -| `delete-instance-profile` | `--instance-profile-name` | — | Profile must exist, `RoleName` must be empty | IAMService.DeleteInstanceProfile(accountID, input) → refuse if `RoleName != ""` (DeleteConflict — "instance profile has a role attached") → delete from KV. Note: live-instance check is added by the EC2 association plan (`iam-roles-v1-ec2.md`) — no EC2 association mechanism exists at this stage. | 1. Delete empty profile
2. Delete profile with role (DeleteConflict)
3. Non-existent (NoSuchEntity) | **DONE** | +| `delete-instance-profile` | `--instance-profile-name` | — | Profile must exist, `RoleName` must be empty, no live instances using profile | Gateway looks up profile via `IAMService.GetInstanceProfile` → refuse if `RoleName != ""` (DeleteConflict — "instance profile has a role attached") → live-instance gate: broadcast `ec2.IamProfileAssociation.describe` fan-out filtered by `IamInstanceProfileArn == profile.ARN` → DeleteConflict if any associations returned ("instance profile is in use by N instances") → `IAMService.DeleteInstanceProfile` removes from KV. TOCTOU race window between scan and delete is accepted (matches `DeletePolicy` pattern); a concurrent `RunInstances`/`AssociateIamInstanceProfile` landing between the two steps leaves a dangling ARN on the instance — `DescribeInstances` warn-logs and returns the ARN with empty Id. | 1. Delete empty profile (no role, no instances)
2. Delete profile with role attached (DeleteConflict)
3. Delete profile attached to live instance (DeleteConflict — "in use by N instances")
4. Delete succeeds after terminate auto-disassociates
5. Non-existent (NoSuchEntity) | **DONE** | | `add-role-to-instance-profile` | `--instance-profile-name`, `--role-name` | — | Both must exist, max 1 role per profile | IAMService.AddRoleToInstanceProfile(accountID, input) → verify profile exists → verify role exists → check `RoleName == ""` (LimitExceeded — "instance profile can contain only one role" — if already set) → set `InstanceProfile.RoleName` and write back (single-record update) | 1. Add role to empty profile
2. Profile already has role (LimitExceeded)
3. Non-existent profile (NoSuchEntity)
4. Non-existent role (NoSuchEntity) | **DONE** | | `remove-role-from-instance-profile` | `--instance-profile-name`, `--role-name` | — | Role must be in profile | IAMService.RemoveRoleFromInstanceProfile(accountID, input) → NoSuchEntity if profile has no role attached → clear `InstanceProfile.RoleName` and write back | 1. Remove role
2. Role not in profile (NoSuchEntity) | **DONE** | diff --git a/spinifex/awserrors/awserrors.go b/spinifex/awserrors/awserrors.go index 685d04e22..f05ce4a36 100644 --- a/spinifex/awserrors/awserrors.go +++ b/spinifex/awserrors/awserrors.go @@ -165,6 +165,9 @@ var ( ErrorInvalidID = "InvalidID" ErrorInvalidIPAddressInUse = "InvalidIPAddress.InUse" ErrorInvalidIamInstanceProfileArnMalformed = "InvalidIamInstanceProfileArn.Malformed" + ErrorInvalidIamInstanceProfileNotFound = "InvalidIamInstanceProfile.NotFound" + ErrorIamInstanceProfileAlreadyAssociated = "IamInstanceProfileAlreadyAssociated" + ErrorNoSuchAssociation = "NoSuchAssociation" ErrorInvalidInput = "InvalidInput" ErrorInvalidInstanceAttributeValue = "InvalidInstanceAttributeValue" ErrorInvalidInstanceConnectEndpointIdMalformed = "InvalidInstanceConnectEndpointId.Malformed" @@ -625,6 +628,9 @@ var ErrorLookup = map[string]ErrorMessage{ ErrorInvalidID: {HTTPCode: 400, Message: "The specified ID for the resource you are trying to tag is not valid. Ensure that you provide the full resource ID; for example, ami-2bb65342 for an AMI. If you're using the command line tools on a Windows system, you might need to use quotation marks for the key-value pair; for example, \"Name=TestTag\"."}, ErrorInvalidIPAddressInUse: {HTTPCode: 409, Message: "The specified IP address is already in use. If you are trying to release an address, you must first disassociate it from the instance."}, ErrorInvalidIamInstanceProfileArnMalformed: {HTTPCode: 400, Message: "The specified IAM instance profile ARN is not valid. For more information about valid ARN formats, see Amazon Resource Names (ARNs)."}, + ErrorInvalidIamInstanceProfileNotFound: {HTTPCode: 404, Message: "The specified IAM instance profile name or ARN does not exist."}, + ErrorIamInstanceProfileAlreadyAssociated: {HTTPCode: 400, Message: "There is an existing association for the specified instance."}, + ErrorNoSuchAssociation: {HTTPCode: 404, Message: "The specified IAM instance profile association does not exist."}, ErrorInvalidInput: {HTTPCode: 400, Message: "An input parameter in the request is not valid. For example, you may have specified an incorrect Reserved Instance listing ID in the request or the Reserved Instance you tried to list cannot be sold in the Reserved Instances Marketplace (for example, if it has a scope of Region, or is a Convertible Reserved Instance)."}, ErrorInvalidInstanceAttributeValue: {HTTPCode: 400, Message: "The specified instance attribute value is not valid. This error is most commonly encountered when trying to set the InstanceType/--instance-type attribute to an unrecognized value."}, ErrorInvalidInstanceConnectEndpointIdMalformed: {HTTPCode: 400, Message: "The specified EC2 Instance Connect Endpoint ID is malformed. Ensure that you specify the ID in the form eice-xxxxxxxxxxxxxxxxx."}, diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index b875d173f..d36f22498 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -753,6 +753,12 @@ func (d *Daemon) subscribeAll() error { {"ec2.DescribeInstances", d.handleEC2DescribeInstances, ""}, {"ec2.DescribeInstanceStatus", d.handleEC2DescribeInstanceStatus, ""}, {"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""}, + // IAM instance profile associations: Disassociate/Replace mutate the + // owning daemon's vm.VM (non-owners NoOp with Found=false); Describe + // returns per-daemon matches that the gateway concatenates. + {"ec2.IamProfileAssociation.disassociate", d.handleIamProfileDisassociate, ""}, + {"ec2.IamProfileAssociation.replace", d.handleIamProfileReplace, ""}, + {"ec2.IamProfileAssociation.describe", d.handleIamProfileDescribe, ""}, {"ec2.EnableEbsEncryptionByDefault", d.handleEC2EnableEbsEncryptionByDefault, "spinifex-workers"}, {"ec2.DisableEbsEncryptionByDefault", d.handleEC2DisableEbsEncryptionByDefault, "spinifex-workers"}, {"ec2.GetEbsEncryptionByDefault", d.handleEC2GetEbsEncryptionByDefault, "spinifex-workers"}, diff --git a/spinifex/daemon/daemon_handlers.go b/spinifex/daemon/daemon_handlers.go index 77f819ef9..c84bd7451 100644 --- a/spinifex/daemon/daemon_handlers.go +++ b/spinifex/daemon/daemon_handlers.go @@ -86,6 +86,8 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) { d.handleAttachVolume(msg, command, instance) case command.Attributes.DetachVolume: d.handleDetachVolume(msg, command, instance) + case command.Attributes.AssociateIamInstanceProfile: + d.handleAssociateIamInstanceProfile(msg, command, instance) case command.Attributes.StartInstance: if err := d.instanceService.StartInstance(instance, command); err != nil { respondWithError(msg, awserrors.ValidErrorCode(err.Error())) diff --git a/spinifex/daemon/daemon_handlers_iam_profile.go b/spinifex/daemon/daemon_handlers_iam_profile.go new file mode 100644 index 000000000..aa3b24124 --- /dev/null +++ b/spinifex/daemon/daemon_handlers_iam_profile.go @@ -0,0 +1,95 @@ +package daemon + +import ( + "log/slog" + + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/types" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/mulgadc/spinifex/spinifex/vm" + "github.com/nats-io/nats.go" +) + +// handleAssociateIamInstanceProfile services the per-instance Associate path +// dispatched from handleEC2Events. The gateway has already resolved the +// profile reference and enforced iam:PassRole; instance ownership has been +// verified by checkInstanceOwnership before this point. +func (d *Daemon) handleAssociateIamInstanceProfile(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) { + result, err := d.instanceService.AssociateIamInstanceProfile(instance, command) + if err != nil { + respondWithError(msg, awserrors.ValidErrorCode(err.Error())) + return + } + respondWithJSON(msg, result) +} + +// handleIamProfileDisassociate services the ec2.IamProfileAssociation.disassociate +// fan-out subject. Every daemon responds: the owner with the populated +// association after mutating vm.VM, non-owners (or daemons whose VM has a +// different account) with JSON null so the gateway's expectedNodes collector +// can exit before the timeout. Errors short-circuit the fan-out — only the +// owner can produce a meaningful error (e.g. persistence failure) since +// non-owners NoOp. +func (d *Daemon) handleIamProfileDisassociate(msg *nats.Msg) { + accountID := utils.AccountIDFromMsg(msg) + input := &ec2.DisassociateIamInstanceProfileInput{} + if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil { + if err := msg.Respond(errResp); err != nil { + slog.Error("handleIamProfileDisassociate: respond failed", "err", err) + } + return + } + + result, err := d.instanceService.DisassociateIamProfileAssociation(input, accountID) + if err != nil { + respondWithError(msg, awserrors.ValidErrorCode(err.Error())) + return + } + respondWithJSON(msg, result) +} + +// handleIamProfileReplace services the ec2.IamProfileAssociation.replace +// fan-out subject. Same response contract as handleIamProfileDisassociate: +// every daemon always responds (JSON null on non-owners) so the gateway +// collector exits early when the cluster is healthy. +func (d *Daemon) handleIamProfileReplace(msg *nats.Msg) { + accountID := utils.AccountIDFromMsg(msg) + input := &ec2.ReplaceIamInstanceProfileAssociationInput{} + if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil { + if err := msg.Respond(errResp); err != nil { + slog.Error("handleIamProfileReplace: respond failed", "err", err) + } + return + } + + result, err := d.instanceService.ReplaceIamProfileAssociation(input, accountID) + if err != nil { + respondWithError(msg, awserrors.ValidErrorCode(err.Error())) + return + } + respondWithJSON(msg, result) +} + +// handleIamProfileDescribe services the ec2.IamProfileAssociation.describe +// fan-out subject. Empty IamInstanceProfileAssociations slice is a valid +// response (no matches on this daemon) and counts toward the gateway's +// expectedNodes early-exit collector. The gateway concatenates per-daemon +// slices. +func (d *Daemon) handleIamProfileDescribe(msg *nats.Msg) { + accountID := utils.AccountIDFromMsg(msg) + input := &ec2.DescribeIamInstanceProfileAssociationsInput{} + if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil { + if err := msg.Respond(errResp); err != nil { + slog.Error("handleIamProfileDescribe: respond failed", "err", err) + } + return + } + + out, err := d.instanceService.DescribeIamProfileAssociations(input, accountID) + if err != nil { + respondWithError(msg, awserrors.ValidErrorCode(err.Error())) + return + } + respondWithJSON(msg, out) +} diff --git a/spinifex/daemon/daemon_handlers_instance.go b/spinifex/daemon/daemon_handlers_instance.go index 6f66a846c..e4477c0f5 100644 --- a/spinifex/daemon/daemon_handlers_instance.go +++ b/spinifex/daemon/daemon_handlers_instance.go @@ -284,6 +284,20 @@ func (d *Daemon) handleEC2DescribeInstances(msg *nats.Msg) { instanceCopy.State.SetName("pending") } + // Project IamInstanceProfile from vm.VM (single source of truth + // across Associate/Disassociate/Replace lifecycle). Id is left + // nil — the gateway resolves it via IAMService post-aggregation + // since daemons have no IAM access. Empty Arn clears any stale + // reference left on stored instance.Instance (e.g. after + // Disassociate or auto-clear on terminate). + if instance.IamInstanceProfileArn != "" { + instanceCopy.IamInstanceProfile = &ec2.IamInstanceProfile{ + Arn: aws.String(instance.IamInstanceProfileArn), + } + } else { + instanceCopy.IamInstanceProfile = nil + } + // Populate Placement if instance belongs to a placement group if instance.PlacementGroupName != "" { instanceCopy.Placement = &ec2.Placement{ @@ -504,6 +518,16 @@ func (d *Daemon) describeInstancesFromKV(msg *nats.Msg, listFn func() ([]*vm.VM, instanceCopy.State.SetName(fallbackName) } + // Project IamInstanceProfile from vm.VM (cleared on terminate; + // preserved across stop/start). Mirrors handleEC2DescribeInstances. + if instance.IamInstanceProfileArn != "" { + instanceCopy.IamInstanceProfile = &ec2.IamInstanceProfile{ + Arn: aws.String(instance.IamInstanceProfileArn), + } + } else { + instanceCopy.IamInstanceProfile = nil + } + if len(parsedFilters) > 0 && !instanceMatchesFilters(instance, &instanceCopy, parsedFilters) { continue } diff --git a/spinifex/filterutil/filterutil.go b/spinifex/filterutil/filterutil.go index 0ebe4a45a..fbad30ede 100644 --- a/spinifex/filterutil/filterutil.go +++ b/spinifex/filterutil/filterutil.go @@ -47,7 +47,7 @@ func MatchesAny(filterValues []string, value string) bool { return true } for _, pattern := range filterValues { - if matchWildcard(pattern, value) { + if MatchWildcard(pattern, value) { return true } } @@ -89,9 +89,10 @@ func EC2TagsToMap(tags []*ec2.Tag) map[string]string { return m } -// matchWildcard matches value against a pattern where * matches zero or more -// characters. This implements the AWS filter wildcard convention. -func matchWildcard(pattern, value string) bool { +// MatchWildcard matches value against a pattern where * matches zero or more +// characters. Case-sensitive — callers needing case-insensitive matching +// (e.g. IAM policy evaluation) should lower-case both inputs before calling. +func MatchWildcard(pattern, value string) bool { if pattern == "*" { return true } diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index 95adca7cd..f4a97a35e 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -155,6 +155,9 @@ func (m *mockIAMService) AddRoleToInstanceProfile(_ string, _ *iam.AddRoleToInst func (m *mockIAMService) RemoveRoleFromInstanceProfile(_ string, _ *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) { return nil, nil } +func (m *mockIAMService) ResolveInstanceProfile(_, _ string) (*handlers_iam.InstanceProfile, error) { + return nil, nil +} // testMasterKey is a fixed 32-byte key for deterministic tests. var testMasterKey []byte diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index b8fd613a7..f729a10bd 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -32,12 +32,15 @@ import ( // EC2Handler processes parsed query args and returns XML response bytes. // The action parameter is the EC2 API action name, passed from the map key. // accountID is the caller's AWS account ID extracted from SigV4 auth context. -type EC2Handler func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) +// r is the original HTTP request; handlers that need to perform additional +// resource-scoped policy checks (e.g. iam:PassRole) use it via +// gw.checkPolicyResource. Most handlers ignore it. +type EC2Handler func(action string, q map[string]string, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) // ec2Handler creates a type-safe EC2Handler that allocates the typed input struct, // parses query params into it, calls the handler, and marshals the output to XML. func ec2Handler[In any](handler func(*In, *GatewayConfig, string) (any, error)) EC2Handler { - return func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) { + return func(action string, q map[string]string, gw *GatewayConfig, accountID string, _ *http.Request) ([]byte, error) { input := new(In) if err := awsec2query.QueryParamsToStruct(q, input); err != nil { if errors.Is(err, awsec2query.ErrSliceTooLarge) { @@ -58,12 +61,63 @@ func ec2Handler[In any](handler func(*In, *GatewayConfig, string) (any, error)) } } +// ec2HandlerWithReq is the variant of ec2Handler for handlers that need the +// original *http.Request — e.g. RunInstances calls gw.checkPolicyResource +// to enforce iam:PassRole on the role ARN inside the supplied instance profile. +func ec2HandlerWithReq[In any](handler func(*In, *GatewayConfig, string, *http.Request) (any, error)) EC2Handler { + return func(action string, q map[string]string, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) { + input := new(In) + if err := awsec2query.QueryParamsToStruct(q, input); err != nil { + if errors.Is(err, awsec2query.ErrSliceTooLarge) { + return nil, errors.New(awserrors.ErrorMalformedQueryString) + } + return nil, err + } + output, err := handler(input, gw, accountID, r) + if err != nil { + return nil, err + } + payload := utils.GenerateXMLPayload(action+"Response", output) + xmlOutput, err := utils.MarshalToXML(payload) + if err != nil { + return nil, errors.New("failed to marshal response to XML") + } + return xmlOutput, nil + } +} + var ec2Actions = map[string]EC2Handler{ "DescribeInstances": ec2Handler(func(input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) { - return gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID) + out, err := gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID) + if err != nil { + return out, err + } + gateway_ec2_instance.EnrichInstanceProfileIDs(out, gw.IAMService, accountID) + return out, nil }), - "RunInstances": ec2Handler(func(input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string) (any, error) { - return gateway_ec2_instance.RunInstances(input, gw.NATSConn, accountID) + "RunInstances": ec2HandlerWithReq(func(input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { + passRoleCheck := func(roleARN string) error { + return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + } + return gateway_ec2_instance.RunInstances(input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck) + }), + "AssociateIamInstanceProfile": ec2HandlerWithReq(func(input *ec2.AssociateIamInstanceProfileInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { + passRoleCheck := func(roleARN string) error { + return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + } + return gateway_ec2_instance.AssociateIamInstanceProfile(input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck) + }), + "DisassociateIamInstanceProfile": ec2Handler(func(input *ec2.DisassociateIamInstanceProfileInput, gw *GatewayConfig, accountID string) (any, error) { + return gateway_ec2_instance.DisassociateIamInstanceProfile(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID) + }), + "ReplaceIamInstanceProfileAssociation": ec2HandlerWithReq(func(input *ec2.ReplaceIamInstanceProfileAssociationInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) { + passRoleCheck := func(roleARN string) error { + return gw.checkPolicyResource(r, "iam", "PassRole", roleARN) + } + return gateway_ec2_instance.ReplaceIamInstanceProfileAssociation(input, gw.NATSConn, gw.IAMService, gw.DiscoverActiveNodes(), accountID, passRoleCheck) + }), + "DescribeIamInstanceProfileAssociations": ec2Handler(func(input *ec2.DescribeIamInstanceProfileAssociationsInput, gw *GatewayConfig, accountID string) (any, error) { + return gateway_ec2_instance.DescribeIamInstanceProfileAssociations(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID) }), "StartInstances": ec2Handler(func(input *ec2.StartInstancesInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_instance.StartInstances(input, gw.NATSConn, accountID) @@ -104,14 +158,14 @@ var ec2Actions = map[string]EC2Handler{ "DescribeKeyPairs": ec2Handler(func(input *ec2.DescribeKeyPairsInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_key.DescribeKeyPairs(input, gw.NATSConn, accountID) }), - "ImportKeyPair": func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) { + "ImportKeyPair": func(action string, q map[string]string, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) { // Workaround: parser leaves Base64 padding URL-encoded if strings.HasSuffix(q["PublicKeyMaterial"], "%3D%3D") { q["PublicKeyMaterial"] = strings.Replace(q["PublicKeyMaterial"], "%3D%3D", "==", 1) } return ec2Handler(func(input *ec2.ImportKeyPairInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_key.ImportKeyPair(input, gw.NATSConn, accountID) - })(action, q, gw, accountID) + })(action, q, gw, accountID, r) }, "DescribeImages": ec2Handler(func(input *ec2.DescribeImagesInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_image.DescribeImages(input, gw.NATSConn, accountID) @@ -399,7 +453,7 @@ func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) err return errors.New(awserrors.ErrorServerInternal) } - xmlOutput, err := handler(action, queryArgs, gw, accountID) + xmlOutput, err := handler(action, queryArgs, gw, accountID, r) if err != nil { return err } diff --git a/spinifex/gateway/ec2/instance/DescribeInstances.go b/spinifex/gateway/ec2/instance/DescribeInstances.go index 8511442af..39c926aa3 100644 --- a/spinifex/gateway/ec2/instance/DescribeInstances.go +++ b/spinifex/gateway/ec2/instance/DescribeInstances.go @@ -8,8 +8,10 @@ import ( "sync" "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" ) @@ -152,6 +154,50 @@ func DescribeInstances(input *ec2.DescribeInstancesInput, natsConn *nats.Conn, e return output, nil } +// EnrichInstanceProfileIDs fills Instance.IamInstanceProfile.Id for every +// instance whose daemon-side payload carried only the profile ARN. Daemons +// have no IAM access, so they emit Arn only; the gateway resolves Id via +// IAMService per unique ARN (cached to avoid one RPC per instance). Misses +// are warn-logged and leave Id empty per the AWS contract — this happens +// when an instance still references a deleted profile (graceful degradation). +// +// Safe to call with a nil output or nil IAMService (no-op). +func EnrichInstanceProfileIDs(out *ec2.DescribeInstancesOutput, iamSvc handlers_iam.IAMService, accountID string) { + if out == nil || iamSvc == nil { + return + } + cache := map[string]string{} // ARN → InstanceProfileID; "" means miss + for _, res := range out.Reservations { + if res == nil { + continue + } + for _, inst := range res.Instances { + if inst == nil || inst.IamInstanceProfile == nil { + continue + } + arn := aws.StringValue(inst.IamInstanceProfile.Arn) + if arn == "" { + continue + } + id, cached := cache[arn] + if !cached { + profile, err := iamSvc.ResolveInstanceProfile(accountID, arn) + if err != nil || profile == nil { + slog.Warn("DescribeInstances: failed to resolve instance profile ID", + "arn", arn, "err", err) + cache[arn] = "" + } else { + id = profile.InstanceProfileID + cache[arn] = id + } + } + if id != "" { + inst.IamInstanceProfile.Id = aws.String(id) + } + } + } +} + // queryInstanceBucket sends a NATS request to a describe topic and returns the reservations. func queryInstanceBucket(natsConn *nats.Conn, topic string, jsonData []byte, accountID string) []*ec2.Reservation { reqMsg := nats.NewMsg(topic) diff --git a/spinifex/gateway/ec2/instance/DescribeInstances_test.go b/spinifex/gateway/ec2/instance/DescribeInstances_test.go index 3959ee97a..68411330d 100644 --- a/spinifex/gateway/ec2/instance/DescribeInstances_test.go +++ b/spinifex/gateway/ec2/instance/DescribeInstances_test.go @@ -2,11 +2,14 @@ package gateway_ec2_instance import ( "encoding/json" + "errors" "testing" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" @@ -254,3 +257,76 @@ func TestDescribeInstances_ClosedConnection(t *testing.T) { require.Error(t, err) } + +// describeOutputWithProfiles builds a DescribeInstancesOutput with one +// reservation whose instances each carry the supplied ARN (empty string = +// no profile attached). +func describeOutputWithProfiles(arns ...string) *ec2.DescribeInstancesOutput { + out := &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{{ReservationId: aws.String("r-1")}}, + } + for i, arn := range arns { + inst := &ec2.Instance{InstanceId: aws.String("i-" + string(rune('a'+i)))} + if arn != "" { + inst.IamInstanceProfile = &ec2.IamInstanceProfile{Arn: aws.String(arn)} + } + out.Reservations[0].Instances = append(out.Reservations[0].Instances, inst) + } + return out +} + +func TestEnrichInstanceProfileIDs_CachesPerARN(t *testing.T) { + arn := "arn:aws:iam::123456789012:instance-profile/shared" + var calls int + svc := &fakeIAMService{ + resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) { + calls++ + return &handlers_iam.InstanceProfile{ARN: nameOrARN, InstanceProfileID: "AIPAEXAMPLE"}, nil + }, + } + + out := describeOutputWithProfiles(arn, arn, arn) + EnrichInstanceProfileIDs(out, svc, "123456789012") + + assert.Equal(t, 1, calls, "three instances sharing one ARN should resolve once") + for _, inst := range out.Reservations[0].Instances { + assert.Equal(t, "AIPAEXAMPLE", aws.StringValue(inst.IamInstanceProfile.Id)) + } +} + +func TestEnrichInstanceProfileIDs_ResolveErrorLeavesIdEmpty(t *testing.T) { + failingARN := "arn:aws:iam::123456789012:instance-profile/missing" + okARN := "arn:aws:iam::123456789012:instance-profile/present" + svc := &fakeIAMService{ + resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) { + if nameOrARN == failingARN { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + return &handlers_iam.InstanceProfile{ARN: nameOrARN, InstanceProfileID: "AIPAOK"}, nil + }, + } + + out := describeOutputWithProfiles(failingARN, okARN) + EnrichInstanceProfileIDs(out, svc, "123456789012") + + assert.Nil(t, out.Reservations[0].Instances[0].IamInstanceProfile.Id, + "failing lookup leaves Id nil") + assert.Equal(t, "AIPAOK", aws.StringValue(out.Reservations[0].Instances[1].IamInstanceProfile.Id), + "adjacent instances still enriched") +} + +func TestEnrichInstanceProfileIDs_NoOpInputs(t *testing.T) { + // Should not panic on nil out or nil iamSvc, and instances without a + // profile attached are untouched. + EnrichInstanceProfileIDs(nil, &fakeIAMService{}, "123456789012") + + out := describeOutputWithProfiles("arn:aws:iam::123456789012:instance-profile/foo") + EnrichInstanceProfileIDs(out, nil, "123456789012") + assert.Nil(t, out.Reservations[0].Instances[0].IamInstanceProfile.Id, + "nil iamSvc is a no-op") + + noProfile := describeOutputWithProfiles("") + EnrichInstanceProfileIDs(noProfile, &fakeIAMService{}, "123456789012") + assert.Nil(t, noProfile.Reservations[0].Instances[0].IamInstanceProfile, + "instance with no profile is untouched") +} diff --git a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation.go b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation.go new file mode 100644 index 000000000..ce9b021bc --- /dev/null +++ b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation.go @@ -0,0 +1,357 @@ +package gateway_ec2_instance + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + spxtypes "github.com/mulgadc/spinifex/spinifex/types" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" +) + +// fanOutTimeout bounds the wait for each broadcast on the +// ec2.IamProfileAssociation.* subjects. Daemons always reply (JSON null on +// no-match), so under healthy conditions the expectedNodes collector exits +// well before the timeout — the deadline only matters when a daemon is down. +const fanOutTimeout = 3 * time.Second + +// resolveAndAuthorizeProfile resolves a profile reference (name or ARN) and +// enforces iam:PassRole on the underlying role. Returns the resolved profile +// for the caller to use when building the response. Mirrors the gateway-side +// contract used by RunInstances — profile-with-no-role skips PassRole, missing +// profile maps to InvalidIamInstanceProfile.NotFound, denied PassRole returns +// AccessDenied. +func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker) (*handlers_iam.InstanceProfile, error) { + nameOrARN := profileNameOrARN(spec) + if nameOrARN == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if iamSvc == nil { + slog.Error("IAM service not available, cannot resolve instance profile", "nameOrARN", nameOrARN) + return nil, errors.New(awserrors.ErrorServerInternal) + } + profile, err := iamSvc.ResolveInstanceProfile(accountID, nameOrARN) + if err != nil { + if err.Error() == awserrors.ErrorIAMNoSuchEntity { + return nil, errors.New(awserrors.ErrorInvalidIamInstanceProfileNotFound) + } + return nil, err + } + if profile.RoleName != "" && passRoleCheck != nil { + roleARN := fmt.Sprintf("arn:aws:iam::%s:role/%s", profile.AccountID, profile.RoleName) + if err := passRoleCheck(roleARN); err != nil { + return nil, err + } + } + return profile, nil +} + +// AssociateIamInstanceProfile attaches an instance profile to a running +// instance. The gateway resolves the profile and enforces iam:PassRole; the +// owning daemon validates that the instance currently has no profile (returns +// IamInstanceProfileAlreadyAssociated otherwise), generates a fresh +// iip-assoc-… ID, and writes both fields atomically on vm.VM. +func AssociateIamInstanceProfile(input *ec2.AssociateIamInstanceProfileInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker) (*ec2.AssociateIamInstanceProfileOutput, error) { + if input == nil || input.InstanceId == nil || *input.InstanceId == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.IamInstanceProfile == nil { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + profile, err := resolveAndAuthorizeProfile(input.IamInstanceProfile, iamSvc, accountID, passRoleCheck) + if err != nil { + return nil, err + } + + command := spxtypes.EC2InstanceCommand{ + ID: *input.InstanceId, + Attributes: spxtypes.EC2CommandAttributes{ + AssociateIamInstanceProfile: true, + }, + IamProfileAssociationData: &spxtypes.IamProfileAssociationData{ + InstanceProfileArn: profile.ARN, + }, + } + + subject := fmt.Sprintf("ec2.cmd.%s", *input.InstanceId) + assoc, err := utils.NATSRequest[ec2.IamInstanceProfileAssociation]( + natsConn, subject, command, fanOutTimeout, accountID) + if err != nil { + if errors.Is(err, nats.ErrNoResponders) { + return nil, errors.New(awserrors.ErrorInvalidInstanceIDNotFound) + } + return nil, err + } + + enrichProfileID(assoc, profile) + return &ec2.AssociateIamInstanceProfileOutput{IamInstanceProfileAssociation: assoc}, nil +} + +// DisassociateIamInstanceProfile detaches the profile referenced by +// AssociationId. The gateway broadcasts to all daemons; the owner mutates and +// returns the populated association. All-null means no live instance carries +// that ID. +func DisassociateIamInstanceProfile(input *ec2.DisassociateIamInstanceProfileInput, natsConn *nats.Conn, expectedNodes int, accountID string) (*ec2.DisassociateIamInstanceProfileOutput, error) { + if input == nil || input.AssociationId == nil || *input.AssociationId == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + assoc, err := broadcastForAssociation(natsConn, "ec2.IamProfileAssociation.disassociate", input, expectedNodes, accountID) + if err != nil { + return nil, err + } + return &ec2.DisassociateIamInstanceProfileOutput{IamInstanceProfileAssociation: assoc}, nil +} + +// ReplaceIamInstanceProfileAssociation swaps the profile referenced by an +// existing AssociationId for a new profile. The gateway resolves the new +// profile and enforces iam:PassRole on its role; the owning daemon validates +// the old AssociationId and generates a new one atomically with the swap. +func ReplaceIamInstanceProfileAssociation(input *ec2.ReplaceIamInstanceProfileAssociationInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, expectedNodes int, accountID string, passRoleCheck PassRoleChecker) (*ec2.ReplaceIamInstanceProfileAssociationOutput, error) { + if input == nil || input.AssociationId == nil || *input.AssociationId == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.IamInstanceProfile == nil { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + profile, err := resolveAndAuthorizeProfile(input.IamInstanceProfile, iamSvc, accountID, passRoleCheck) + if err != nil { + return nil, err + } + + // Normalise the on-wire payload to the resolved canonical ARN — daemons + // don't have IAM access and can't dereference a Name reference. + wireInput := &ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: input.AssociationId, + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Arn: aws.String(profile.ARN)}, + } + + assoc, err := broadcastForAssociation(natsConn, "ec2.IamProfileAssociation.replace", wireInput, expectedNodes, accountID) + if err != nil { + return nil, err + } + enrichProfileID(assoc, profile) + return &ec2.ReplaceIamInstanceProfileAssociationOutput{IamInstanceProfileAssociation: assoc}, nil +} + +// CountInstanceProfileAssociations returns the number of live associations +// across all daemons in the caller's account that currently reference +// profileARN. The IAM DeleteInstanceProfile gateway uses this to refuse delete +// while the profile is still attached to any instance. Cross-account records +// are not visible to the daemon walker, so the count is naturally scoped. +func CountInstanceProfileAssociations(natsConn *nats.Conn, expectedNodes int, accountID, profileARN string) (int, error) { + associations, err := broadcastDescribeAssociations(natsConn, &ec2.DescribeIamInstanceProfileAssociationsInput{}, expectedNodes, accountID) + if err != nil { + return 0, err + } + count := 0 + for _, a := range associations { + if a != nil && a.IamInstanceProfile != nil && aws.StringValue(a.IamInstanceProfile.Arn) == profileARN { + count++ + } + } + return count, nil +} + +// DescribeIamInstanceProfileAssociations aggregates associations across all +// daemons. Filter names are validated at the gateway so bad inputs fail fast +// without a NATS round-trip; the daemons re-parse the input to do the actual +// filtering against their local VM records. +func DescribeIamInstanceProfileAssociations(input *ec2.DescribeIamInstanceProfileAssociationsInput, natsConn *nats.Conn, expectedNodes int, accountID string) (*ec2.DescribeIamInstanceProfileAssociationsOutput, error) { + for _, f := range input.Filters { + if f == nil || f.Name == nil { + continue + } + switch *f.Name { + case "instance-id", "state": + default: + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + } + + associations, err := broadcastDescribeAssociations(natsConn, input, expectedNodes, accountID) + if err != nil { + return nil, err + } + return &ec2.DescribeIamInstanceProfileAssociationsOutput{IamInstanceProfileAssociations: associations}, nil +} + +// enrichProfileID fills in IamInstanceProfile.Id from the gateway-resolved +// profile — daemons cannot resolve Id since they have no IAM access. Safe to +// call with a nil association (no-op). +func enrichProfileID(assoc *ec2.IamInstanceProfileAssociation, profile *handlers_iam.InstanceProfile) { + if assoc == nil || profile == nil { + return + } + if assoc.IamInstanceProfile == nil { + assoc.IamInstanceProfile = &ec2.IamInstanceProfile{} + } + assoc.IamInstanceProfile.Id = aws.String(profile.InstanceProfileID) +} + +// broadcastForAssociation fans out a mutation request to all daemons and +// returns the first populated response. Returns NoSuchAssociation when every +// reachable daemon replied with JSON null. +func broadcastForAssociation(natsConn *nats.Conn, subject string, payload any, expectedNodes int, accountID string) (*ec2.IamInstanceProfileAssociation, error) { + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + inbox := nats.NewInbox() + sub, err := natsConn.SubscribeSync(inbox) + if err != nil { + return nil, fmt.Errorf("failed to create inbox: %w", err) + } + defer sub.Unsubscribe() + + pubMsg := nats.NewMsg(subject) + pubMsg.Reply = inbox + pubMsg.Data = jsonData + pubMsg.Header.Set(utils.AccountIDHeader, accountID) + if err := natsConn.PublishMsg(pubMsg); err != nil { + return nil, fmt.Errorf("failed to publish request: %w", err) + } + + deadline := time.Now().Add(fanOutTimeout) + responsesReceived := 0 + if expectedNodes <= 0 { + expectedNodes = -1 + } + + for time.Now().Before(deadline) { + if expectedNodes > 0 && responsesReceived >= expectedNodes { + break + } + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + msg, err := sub.NextMsg(remaining) + if err != nil { + if err == nats.ErrTimeout { + break + } + return nil, fmt.Errorf("fan-out receive error: %w", err) + } + responsesReceived++ + + if errPayload, parseErr := utils.ValidateErrorPayload(msg.Data); parseErr != nil { + // A daemon-side error short-circuits the fan-out — it represents a + // definitive answer (e.g. IamInstanceProfileAlreadyAssociated would + // only come from the owning daemon). + code := awserrors.ErrorServerInternal + if errPayload.Code != nil { + code = *errPayload.Code + } + return nil, errors.New(code) + } + + var assoc *ec2.IamInstanceProfileAssociation + if err := json.Unmarshal(msg.Data, &assoc); err != nil { + slog.Warn("fan-out: skipping malformed response", "subject", subject, "err", err) + continue + } + if assoc != nil { + return assoc, nil + } + } + + return nil, errors.New(awserrors.ErrorNoSuchAssociation) +} + +// broadcastDescribeAssociations fans out a Describe request and concatenates +// every daemon's matching records. Daemons always reply (empty slice when +// no matches), so the expectedNodes collector exits early under healthy +// conditions; a partial collection is acceptable for Describe semantics. +func broadcastDescribeAssociations(natsConn *nats.Conn, input *ec2.DescribeIamInstanceProfileAssociationsInput, expectedNodes int, accountID string) ([]*ec2.IamInstanceProfileAssociation, error) { + jsonData, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + inbox := nats.NewInbox() + sub, err := natsConn.SubscribeSync(inbox) + if err != nil { + return nil, fmt.Errorf("failed to create inbox: %w", err) + } + defer sub.Unsubscribe() + + pubMsg := nats.NewMsg("ec2.IamProfileAssociation.describe") + pubMsg.Reply = inbox + pubMsg.Data = jsonData + pubMsg.Header.Set(utils.AccountIDHeader, accountID) + if err := natsConn.PublishMsg(pubMsg); err != nil { + return nil, fmt.Errorf("failed to publish request: %w", err) + } + + deadline := time.Now().Add(fanOutTimeout) + responsesReceived := 0 + var associations []*ec2.IamInstanceProfileAssociation + var clientError string + + if expectedNodes <= 0 { + expectedNodes = -1 + } + + for time.Now().Before(deadline) { + if expectedNodes > 0 && responsesReceived >= expectedNodes { + break + } + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + msg, err := sub.NextMsg(remaining) + if err != nil { + if err == nats.ErrTimeout { + break + } + return nil, fmt.Errorf("fan-out receive error: %w", err) + } + responsesReceived++ + + if errPayload, parseErr := utils.ValidateErrorPayload(msg.Data); parseErr != nil { + code := "" + if errPayload.Code != nil { + code = *errPayload.Code + } + // Capture the first deterministic client error so the caller sees the + // invalid-filter response instead of an empty success list. 5xx and + // unknown codes are dropped (treated as transient per-daemon noise) + // but logged so flaky daemons don't silently corrupt the aggregate + // — important for CountInstanceProfileAssociations which feeds + // DeleteInstanceProfile's live-instance gate. + if clientError == "" && code != "" { + if info, known := awserrors.ErrorLookup[code]; known && info.HTTPCode >= 400 && info.HTTPCode < 500 { + clientError = code + continue + } + } + slog.Warn("Describe fan-out: daemon error dropped from aggregate", + "subject", "ec2.IamProfileAssociation.describe", "code", code) + continue + } + + var resp ec2.DescribeIamInstanceProfileAssociationsOutput + if err := json.Unmarshal(msg.Data, &resp); err != nil { + slog.Warn("Describe fan-out: skipping malformed response", "err", err) + continue + } + associations = append(associations, resp.IamInstanceProfileAssociations...) + } + + if clientError != "" && len(associations) == 0 { + return nil, errors.New(clientError) + } + return associations, nil +} diff --git a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go new file mode 100644 index 000000000..1a86954b2 --- /dev/null +++ b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go @@ -0,0 +1,755 @@ +package gateway_ec2_instance + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + spxtypes "github.com/mulgadc/spinifex/spinifex/types" + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testGwAccountID = "111122223333" + testProfileARNApp = "arn:aws:iam::111122223333:instance-profile/app-profile" + testProfileARNOther = "arn:aws:iam::111122223333:instance-profile/other-profile" + testCrossAccountARN = "arn:aws:iam::999988887777:instance-profile/cross-profile" + testRoleARNApp = "arn:aws:iam::111122223333:role/app-role" + testProfileNameApp = "app-profile" + testProfileNameOther = "other-profile" + testProfileIDApp = "AIPAEXAMPLE0000000001" + testProfileIDOther = "AIPAEXAMPLE0000000002" +) + +// fakeIAMService is a minimal IAMService stub for gateway-side tests. Only the +// methods the EC2 IAM-profile gateway code actually touches are wired with +// configurable behaviour — the rest return zero-value outputs to satisfy the +// interface contract. +type fakeIAMService struct { + resolveFn func(accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error) +} + +func (f *fakeIAMService) ResolveInstanceProfile(accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error) { + if f.resolveFn != nil { + return f.resolveFn(accountID, nameOrARN) + } + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) +} + +func (f *fakeIAMService) CreateUser(string, *iam.CreateUserInput) (*iam.CreateUserOutput, error) { + return &iam.CreateUserOutput{}, nil +} +func (f *fakeIAMService) GetUser(string, *iam.GetUserInput) (*iam.GetUserOutput, error) { + return &iam.GetUserOutput{}, nil +} +func (f *fakeIAMService) ListUsers(string, *iam.ListUsersInput) (*iam.ListUsersOutput, error) { + return &iam.ListUsersOutput{}, nil +} +func (f *fakeIAMService) DeleteUser(string, *iam.DeleteUserInput) (*iam.DeleteUserOutput, error) { + return &iam.DeleteUserOutput{}, nil +} +func (f *fakeIAMService) CreateAccessKey(string, *iam.CreateAccessKeyInput) (*iam.CreateAccessKeyOutput, error) { + return &iam.CreateAccessKeyOutput{}, nil +} +func (f *fakeIAMService) ListAccessKeys(string, *iam.ListAccessKeysInput) (*iam.ListAccessKeysOutput, error) { + return &iam.ListAccessKeysOutput{}, nil +} +func (f *fakeIAMService) DeleteAccessKey(string, *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) { + return &iam.DeleteAccessKeyOutput{}, nil +} +func (f *fakeIAMService) UpdateAccessKey(string, *iam.UpdateAccessKeyInput) (*iam.UpdateAccessKeyOutput, error) { + return &iam.UpdateAccessKeyOutput{}, nil +} +func (f *fakeIAMService) CreatePolicy(string, *iam.CreatePolicyInput) (*iam.CreatePolicyOutput, error) { + return &iam.CreatePolicyOutput{}, nil +} +func (f *fakeIAMService) GetPolicy(string, *iam.GetPolicyInput) (*iam.GetPolicyOutput, error) { + return &iam.GetPolicyOutput{}, nil +} +func (f *fakeIAMService) GetPolicyVersion(string, *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error) { + return &iam.GetPolicyVersionOutput{}, nil +} +func (f *fakeIAMService) ListPolicies(string, *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { + return &iam.ListPoliciesOutput{}, nil +} +func (f *fakeIAMService) DeletePolicy(string, *iam.DeletePolicyInput) (*iam.DeletePolicyOutput, error) { + return &iam.DeletePolicyOutput{}, nil +} +func (f *fakeIAMService) AttachUserPolicy(string, *iam.AttachUserPolicyInput) (*iam.AttachUserPolicyOutput, error) { + return &iam.AttachUserPolicyOutput{}, nil +} +func (f *fakeIAMService) DetachUserPolicy(string, *iam.DetachUserPolicyInput) (*iam.DetachUserPolicyOutput, error) { + return &iam.DetachUserPolicyOutput{}, nil +} +func (f *fakeIAMService) ListAttachedUserPolicies(string, *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) { + return &iam.ListAttachedUserPoliciesOutput{}, nil +} +func (f *fakeIAMService) CreateRole(string, *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) { + return &iam.CreateRoleOutput{}, nil +} +func (f *fakeIAMService) GetRole(string, *iam.GetRoleInput) (*iam.GetRoleOutput, error) { + return &iam.GetRoleOutput{}, nil +} +func (f *fakeIAMService) ListRoles(string, *iam.ListRolesInput) (*iam.ListRolesOutput, error) { + return &iam.ListRolesOutput{}, nil +} +func (f *fakeIAMService) DeleteRole(string, *iam.DeleteRoleInput) (*iam.DeleteRoleOutput, error) { + return &iam.DeleteRoleOutput{}, nil +} +func (f *fakeIAMService) UpdateRole(string, *iam.UpdateRoleInput) (*iam.UpdateRoleOutput, error) { + return &iam.UpdateRoleOutput{}, nil +} +func (f *fakeIAMService) UpdateAssumeRolePolicy(string, *iam.UpdateAssumeRolePolicyInput) (*iam.UpdateAssumeRolePolicyOutput, error) { + return &iam.UpdateAssumeRolePolicyOutput{}, nil +} +func (f *fakeIAMService) AttachRolePolicy(string, *iam.AttachRolePolicyInput) (*iam.AttachRolePolicyOutput, error) { + return &iam.AttachRolePolicyOutput{}, nil +} +func (f *fakeIAMService) DetachRolePolicy(string, *iam.DetachRolePolicyInput) (*iam.DetachRolePolicyOutput, error) { + return &iam.DetachRolePolicyOutput{}, nil +} +func (f *fakeIAMService) ListAttachedRolePolicies(string, *iam.ListAttachedRolePoliciesInput) (*iam.ListAttachedRolePoliciesOutput, error) { + return &iam.ListAttachedRolePoliciesOutput{}, nil +} +func (f *fakeIAMService) CreateInstanceProfile(string, *iam.CreateInstanceProfileInput) (*iam.CreateInstanceProfileOutput, error) { + return &iam.CreateInstanceProfileOutput{}, nil +} +func (f *fakeIAMService) GetInstanceProfile(string, *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) { + return &iam.GetInstanceProfileOutput{}, nil +} +func (f *fakeIAMService) ListInstanceProfiles(string, *iam.ListInstanceProfilesInput) (*iam.ListInstanceProfilesOutput, error) { + return &iam.ListInstanceProfilesOutput{}, nil +} +func (f *fakeIAMService) DeleteInstanceProfile(string, *iam.DeleteInstanceProfileInput) (*iam.DeleteInstanceProfileOutput, error) { + return &iam.DeleteInstanceProfileOutput{}, nil +} +func (f *fakeIAMService) ListInstanceProfilesForRole(string, *iam.ListInstanceProfilesForRoleInput) (*iam.ListInstanceProfilesForRoleOutput, error) { + return &iam.ListInstanceProfilesForRoleOutput{}, nil +} +func (f *fakeIAMService) AddRoleToInstanceProfile(string, *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) { + return &iam.AddRoleToInstanceProfileOutput{}, nil +} +func (f *fakeIAMService) RemoveRoleFromInstanceProfile(string, *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) { + return &iam.RemoveRoleFromInstanceProfileOutput{}, nil +} +func (f *fakeIAMService) GetUserPolicies(string, string) ([]handlers_iam.PolicyDocument, error) { + return nil, nil +} +func (f *fakeIAMService) LookupAccessKey(string) (*handlers_iam.AccessKey, error) { return nil, nil } +func (f *fakeIAMService) DecryptSecret(string) (string, error) { return "", nil } +func (f *fakeIAMService) SeedBootstrap(*handlers_iam.BootstrapData) error { return nil } +func (f *fakeIAMService) IsEmpty() (bool, error) { return true, nil } +func (f *fakeIAMService) CreateAccount(string) (*handlers_iam.Account, error) { return nil, nil } +func (f *fakeIAMService) GetAccount(string) (*handlers_iam.Account, error) { return nil, nil } +func (f *fakeIAMService) ListAccounts() ([]*handlers_iam.Account, error) { return nil, nil } + +var _ handlers_iam.IAMService = (*fakeIAMService)(nil) + +// profileWithRole builds a resolved profile fixture pointing at the test role. +func profileWithRole() *handlers_iam.InstanceProfile { + return &handlers_iam.InstanceProfile{ + InstanceProfileName: testProfileNameApp, + InstanceProfileID: testProfileIDApp, + AccountID: testGwAccountID, + ARN: testProfileARNApp, + RoleName: "app-role", + } +} + +// profileNoRole builds a resolved profile fixture with no role attached. +// resolveAndAuthorizeProfile must NOT invoke PassRole for this profile. +func profileNoRole() *handlers_iam.InstanceProfile { + return &handlers_iam.InstanceProfile{ + InstanceProfileName: testProfileNameOther, + InstanceProfileID: testProfileIDOther, + AccountID: testGwAccountID, + ARN: testProfileARNOther, + RoleName: "", + } +} + +// --- helper-level tests -------------------------------------------------- + +func TestResolveAndAuthorizeProfile_NameForm(t *testing.T) { + gotNameOrARN := "" + svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) { + gotNameOrARN = nameOrARN + return profileWithRole(), nil + }} + checkCalled := false + check := func(roleARN string) error { + checkCalled = true + assert.Equal(t, testRoleARNApp, roleARN, "PassRole must be checked on the role inside the resolved profile") + return nil + } + + profile, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + svc, testGwAccountID, check) + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, testProfileARNApp, profile.ARN) + assert.Equal(t, testProfileNameApp, gotNameOrARN, "ResolveInstanceProfile must receive the name as-given") + assert.True(t, checkCalled, "PassRole check must run when a role is attached") +} + +func TestResolveAndAuthorizeProfile_ArnForm(t *testing.T) { + gotNameOrARN := "" + svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) { + gotNameOrARN = nameOrARN + return profileWithRole(), nil + }} + _, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{Arn: aws.String(testProfileARNApp)}, + svc, testGwAccountID, func(string) error { return nil }) + require.NoError(t, err) + assert.Equal(t, testProfileARNApp, gotNameOrARN, "Arn takes precedence and is forwarded verbatim") +} + +func TestResolveAndAuthorizeProfile_MissingNameAndArn(t *testing.T) { + svc := &fakeIAMService{} + _, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{}, + svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestResolveAndAuthorizeProfile_NilIAMService(t *testing.T) { + _, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + nil, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorServerInternal, err.Error()) +} + +func TestResolveAndAuthorizeProfile_NotFoundMapsToEC2Error(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + }} + _, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{Name: aws.String("missing")}, + svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidIamInstanceProfileNotFound, err.Error(), + "IAM NoSuchEntity must surface as EC2's InvalidIamInstanceProfile.NotFound") +} + +func TestResolveAndAuthorizeProfile_PassthroughOtherIAMError(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return nil, errors.New(awserrors.ErrorAccessDenied) + }} + _, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{Arn: aws.String(testCrossAccountARN)}, + svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error(), + "non-NotFound IAM errors (e.g. cross-account AccessDenied) must pass through unchanged") +} + +func TestResolveAndAuthorizeProfile_PassRoleDenied(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileWithRole(), nil + }} + check := func(string) error { return errors.New(awserrors.ErrorAccessDenied) } + _, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + svc, testGwAccountID, check) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) +} + +func TestResolveAndAuthorizeProfile_NoRoleSkipsPassRole(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileNoRole(), nil + }} + checkCalled := false + check := func(string) error { + checkCalled = true + return errors.New(awserrors.ErrorAccessDenied) // would fail if invoked + } + profile, err := resolveAndAuthorizeProfile( + &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameOther)}, + svc, testGwAccountID, check) + require.NoError(t, err) + require.NotNil(t, profile) + assert.Empty(t, profile.RoleName) + assert.False(t, checkCalled, "profile-with-no-role must skip PassRole — there is no role to pass") +} + +func TestEnrichProfileID_FillsIdFromResolvedProfile(t *testing.T) { + assoc := &ec2.IamInstanceProfileAssociation{ + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}, + } + enrichProfileID(assoc, profileWithRole()) + require.NotNil(t, assoc.IamInstanceProfile) + assert.Equal(t, testProfileIDApp, aws.StringValue(assoc.IamInstanceProfile.Id)) + assert.Equal(t, testProfileARNApp, aws.StringValue(assoc.IamInstanceProfile.Arn)) +} + +func TestEnrichProfileID_NilAssociationIsNoOp(t *testing.T) { + enrichProfileID(nil, profileWithRole()) // must not panic +} + +func TestEnrichProfileID_NilInnerProfileIsAllocated(t *testing.T) { + assoc := &ec2.IamInstanceProfileAssociation{} + enrichProfileID(assoc, profileWithRole()) + require.NotNil(t, assoc.IamInstanceProfile) + assert.Equal(t, testProfileIDApp, aws.StringValue(assoc.IamInstanceProfile.Id)) +} + +// --- AssociateIamInstanceProfile --------------------------------------------- + +func TestAssociateIamInstanceProfile_NilInput(t *testing.T) { + _, err := AssociateIamInstanceProfile(nil, nil, &fakeIAMService{}, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestAssociateIamInstanceProfile_MissingInstanceID(t *testing.T) { + in := &ec2.AssociateIamInstanceProfileInput{ + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + } + _, err := AssociateIamInstanceProfile(in, nil, &fakeIAMService{}, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestAssociateIamInstanceProfile_MissingProfileSpec(t *testing.T) { + in := &ec2.AssociateIamInstanceProfileInput{InstanceId: aws.String("i-001")} + _, err := AssociateIamInstanceProfile(in, nil, &fakeIAMService{}, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestAssociateIamInstanceProfile_ProfileNotFound(t *testing.T) { + svc := &fakeIAMService{} // default resolve → NoSuchEntity + in := &ec2.AssociateIamInstanceProfileInput{ + InstanceId: aws.String("i-001"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String("ghost")}, + } + _, err := AssociateIamInstanceProfile(in, nil, svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidIamInstanceProfileNotFound, err.Error()) +} + +func TestAssociateIamInstanceProfile_PassRoleDenied(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileWithRole(), nil + }} + in := &ec2.AssociateIamInstanceProfileInput{ + InstanceId: aws.String("i-001"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + } + check := func(string) error { return errors.New(awserrors.ErrorAccessDenied) } + _, err := AssociateIamInstanceProfile(in, nil, svc, testGwAccountID, check) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) +} + +func TestAssociateIamInstanceProfile_NoResponders(t *testing.T) { + _, nc := startTestNATSServer(t) + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileNoRole(), nil + }} + in := &ec2.AssociateIamInstanceProfileInput{ + InstanceId: aws.String("i-no-daemon"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameOther)}, + } + // No subscriber on ec2.cmd.* → ErrNoResponders → maps to + // InvalidInstanceID.NotFound so callers get an AWS-shaped 400 instead of + // a raw NATS timeout. + _, err := AssociateIamInstanceProfile(in, nc, svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error()) +} + +func TestAssociateIamInstanceProfile_Success(t *testing.T) { + _, nc := startTestNATSServer(t) + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileWithRole(), nil + }} + const instanceID = "i-assoc-success" + const newAssocID = "iip-assoc-newly-generated01" + ts := time.Date(2026, 5, 27, 10, 0, 0, 0, time.UTC) + + _, err := nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + var cmd spxtypes.EC2InstanceCommand + require.NoError(t, json.Unmarshal(msg.Data, &cmd)) + assert.True(t, cmd.Attributes.AssociateIamInstanceProfile, "daemon must see the Associate flag set") + require.NotNil(t, cmd.IamProfileAssociationData) + assert.Equal(t, testProfileARNApp, cmd.IamProfileAssociationData.InstanceProfileArn, + "daemon must receive the resolved canonical ARN, not the original name") + + resp, _ := json.Marshal(&ec2.IamInstanceProfileAssociation{ + AssociationId: aws.String(newAssocID), + InstanceId: aws.String(instanceID), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateAssociated), + Timestamp: aws.Time(ts), + }) + msg.Respond(resp) + }) + require.NoError(t, err) + + out, err := AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{ + InstanceId: aws.String(instanceID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + }, nc, svc, testGwAccountID, func(string) error { return nil }) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.IamInstanceProfileAssociation) + assoc := out.IamInstanceProfileAssociation + assert.Equal(t, newAssocID, aws.StringValue(assoc.AssociationId)) + assert.Equal(t, instanceID, aws.StringValue(assoc.InstanceId)) + require.NotNil(t, assoc.IamInstanceProfile) + assert.Equal(t, testProfileARNApp, aws.StringValue(assoc.IamInstanceProfile.Arn)) + assert.Equal(t, testProfileIDApp, aws.StringValue(assoc.IamInstanceProfile.Id), + "gateway must enrich the response with InstanceProfileID — daemons cannot resolve it") + assert.Equal(t, ec2.IamInstanceProfileAssociationStateAssociated, aws.StringValue(assoc.State)) +} + +func TestAssociateIamInstanceProfile_DaemonAlreadyAssociated(t *testing.T) { + _, nc := startTestNATSServer(t) + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileNoRole(), nil + }} + const instanceID = "i-already-bound" + _, err := nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + msg.Respond(utils.GenerateErrorPayload(awserrors.ErrorIamInstanceProfileAlreadyAssociated)) + }) + require.NoError(t, err) + + _, err = AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{ + InstanceId: aws.String(instanceID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameOther)}, + }, nc, svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorIamInstanceProfileAlreadyAssociated, err.Error()) +} + +// --- DisassociateIamInstanceProfile ----------------------------------------- + +func TestDisassociateIamInstanceProfile_MissingAssociationID(t *testing.T) { + _, err := DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{}, nil, 0, testGwAccountID) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestDisassociateIamInstanceProfile_NilInput(t *testing.T) { + _, err := DisassociateIamInstanceProfile(nil, nil, 0, testGwAccountID) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestDisassociateIamInstanceProfile_Success(t *testing.T) { + _, nc := startTestNATSServer(t) + const assocID = "iip-assoc-disassoc00000001" + const instanceID = "i-dis-success" + ts := time.Date(2026, 5, 27, 11, 0, 0, 0, time.UTC) + + // Two daemons subscribe; only the owner returns a populated record. The + // null reply from the other daemon advances the expectedNodes collector so + // we don't wait for the 3s deadline. + _, err := nc.Subscribe("ec2.IamProfileAssociation.disassociate", func(msg *nats.Msg) { + var req ec2.DisassociateIamInstanceProfileInput + require.NoError(t, json.Unmarshal(msg.Data, &req)) + assert.Equal(t, assocID, aws.StringValue(req.AssociationId)) + resp, _ := json.Marshal(&ec2.IamInstanceProfileAssociation{ + AssociationId: aws.String(assocID), + InstanceId: aws.String(instanceID), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateDisassociating), + Timestamp: aws.Time(ts), + }) + nc.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + + nc2, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + defer nc2.Close() + _, err = nc2.Subscribe("ec2.IamProfileAssociation.disassociate", func(msg *nats.Msg) { + nc2.Publish(msg.Reply, []byte("null")) + }) + require.NoError(t, err) + require.NoError(t, nc.Flush()) + require.NoError(t, nc2.Flush()) + + out, err := DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ + AssociationId: aws.String(assocID), + }, nc, 2, testGwAccountID) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.IamInstanceProfileAssociation) + a := out.IamInstanceProfileAssociation + assert.Equal(t, assocID, aws.StringValue(a.AssociationId)) + assert.Equal(t, instanceID, aws.StringValue(a.InstanceId)) + assert.Equal(t, testProfileARNApp, aws.StringValue(a.IamInstanceProfile.Arn)) + assert.Equal(t, ec2.IamInstanceProfileAssociationStateDisassociating, aws.StringValue(a.State), + "AWS surfaces disassociating in the response so callers can show in-flight state") +} + +func TestDisassociateIamInstanceProfile_NoSuchAssociation(t *testing.T) { + _, nc := startTestNATSServer(t) + // Every daemon NoOps with JSON null — gateway must surface + // NoSuchAssociation, not timeout. + _, err := nc.Subscribe("ec2.IamProfileAssociation.disassociate", func(msg *nats.Msg) { + nc.Publish(msg.Reply, []byte("null")) + }) + require.NoError(t, err) + + _, err = DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ + AssociationId: aws.String("iip-assoc-stale-id"), + }, nc, 1, testGwAccountID) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorNoSuchAssociation, err.Error()) +} + +// --- ReplaceIamInstanceProfileAssociation ----------------------------------- + +func TestReplaceIamInstanceProfileAssociation_NilInput(t *testing.T) { + _, err := ReplaceIamInstanceProfileAssociation(nil, nil, &fakeIAMService{}, 0, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestReplaceIamInstanceProfileAssociation_MissingAssociationID(t *testing.T) { + in := &ec2.ReplaceIamInstanceProfileAssociationInput{ + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + } + _, err := ReplaceIamInstanceProfileAssociation(in, nil, &fakeIAMService{}, 0, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestReplaceIamInstanceProfileAssociation_MissingProfileSpec(t *testing.T) { + in := &ec2.ReplaceIamInstanceProfileAssociationInput{AssociationId: aws.String("iip-assoc-x")} + _, err := ReplaceIamInstanceProfileAssociation(in, nil, &fakeIAMService{}, 0, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestReplaceIamInstanceProfileAssociation_PassRoleDeniedBeforeBroadcast(t *testing.T) { + // nc=nil proves the call is rejected before any NATS dispatch. + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileWithRole(), nil + }} + check := func(string) error { return errors.New(awserrors.ErrorAccessDenied) } + _, err := ReplaceIamInstanceProfileAssociation(&ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String("iip-assoc-old"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + }, nil, svc, 0, testGwAccountID, check) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) +} + +func TestReplaceIamInstanceProfileAssociation_Success(t *testing.T) { + _, nc := startTestNATSServer(t) + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileWithRole(), nil + }} + const oldID = "iip-assoc-old00000000001" + const newID = "iip-assoc-fresh000000001" + const instanceID = "i-replace-target" + + _, err := nc.Subscribe("ec2.IamProfileAssociation.replace", func(msg *nats.Msg) { + var req ec2.ReplaceIamInstanceProfileAssociationInput + require.NoError(t, json.Unmarshal(msg.Data, &req)) + assert.Equal(t, oldID, aws.StringValue(req.AssociationId)) + require.NotNil(t, req.IamInstanceProfile) + assert.Equal(t, testProfileARNApp, aws.StringValue(req.IamInstanceProfile.Arn), + "daemon must receive resolved ARN, not the original name") + assert.Nil(t, req.IamInstanceProfile.Name, "Name must not be carried over the wire — gateway normalises to ARN") + resp, _ := json.Marshal(&ec2.IamInstanceProfileAssociation{ + AssociationId: aws.String(newID), + InstanceId: aws.String(instanceID), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateAssociated), + }) + nc.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + + out, err := ReplaceIamInstanceProfileAssociation(&ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String(oldID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}, + }, nc, svc, 1, testGwAccountID, func(string) error { return nil }) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.IamInstanceProfileAssociation) + a := out.IamInstanceProfileAssociation + assert.Equal(t, newID, aws.StringValue(a.AssociationId), "Replace must surface the freshly generated ID") + assert.NotEqual(t, oldID, aws.StringValue(a.AssociationId)) + assert.Equal(t, testProfileIDApp, aws.StringValue(a.IamInstanceProfile.Id)) +} + +func TestReplaceIamInstanceProfileAssociation_NoSuchAssociation(t *testing.T) { + _, nc := startTestNATSServer(t) + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileNoRole(), nil + }} + _, err := nc.Subscribe("ec2.IamProfileAssociation.replace", func(msg *nats.Msg) { + nc.Publish(msg.Reply, []byte("null")) + }) + require.NoError(t, err) + + _, err = ReplaceIamInstanceProfileAssociation(&ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String("iip-assoc-stale"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameOther)}, + }, nc, svc, 1, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorNoSuchAssociation, err.Error()) +} + +// --- DescribeIamInstanceProfileAssociations --------------------------------- + +func TestDescribeIamInstanceProfileAssociations_FanOutAggregates(t *testing.T) { + _, nc := startTestNATSServer(t) + // Two daemons each return one record; the gateway must concatenate them. + _, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) { + resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{ + IamInstanceProfileAssociations: []*ec2.IamInstanceProfileAssociation{{ + AssociationId: aws.String("iip-assoc-node1-rec-001"), + InstanceId: aws.String("i-n1"), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateAssociated), + }}, + }) + nc.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + + nc2, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + defer nc2.Close() + _, err = nc2.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) { + resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{ + IamInstanceProfileAssociations: []*ec2.IamInstanceProfileAssociation{{ + AssociationId: aws.String("iip-assoc-node2-rec-002"), + InstanceId: aws.String("i-n2"), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNOther)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateAssociated), + }}, + }) + nc2.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + require.NoError(t, nc.Flush()) + require.NoError(t, nc2.Flush()) + + out, err := DescribeIamInstanceProfileAssociations( + &ec2.DescribeIamInstanceProfileAssociationsInput{}, nc, 2, testGwAccountID) + require.NoError(t, err) + require.NotNil(t, out) + assert.Len(t, out.IamInstanceProfileAssociations, 2) +} + +func TestDescribeIamInstanceProfileAssociations_ForwardsFilters(t *testing.T) { + _, nc := startTestNATSServer(t) + var gotInput ec2.DescribeIamInstanceProfileAssociationsInput + _, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) { + _ = json.Unmarshal(msg.Data, &gotInput) + resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{}) + nc.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + + _, err = DescribeIamInstanceProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{ + AssociationIds: []*string{aws.String("iip-assoc-1"), aws.String("iip-assoc-2")}, + Filters: []*ec2.Filter{ + {Name: aws.String("instance-id"), Values: []*string{aws.String("i-001"), aws.String("i-002")}}, + {Name: aws.String("state"), Values: []*string{aws.String("associated")}}, + }, + }, nc, 1, testGwAccountID) + require.NoError(t, err) + assert.Equal(t, []string{"iip-assoc-1", "iip-assoc-2"}, aws.StringValueSlice(gotInput.AssociationIds)) + require.Len(t, gotInput.Filters, 2) + assert.Equal(t, "instance-id", aws.StringValue(gotInput.Filters[0].Name)) + assert.Equal(t, []string{"i-001", "i-002"}, aws.StringValueSlice(gotInput.Filters[0].Values)) + assert.Equal(t, "state", aws.StringValue(gotInput.Filters[1].Name)) +} + +func TestDescribeIamInstanceProfileAssociations_InvalidFilterName(t *testing.T) { + _, nc := startTestNATSServer(t) + _, err := DescribeIamInstanceProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{ + Filters: []*ec2.Filter{{Name: aws.String("not-a-valid-filter")}}, + }, nc, 1, testGwAccountID) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error()) +} + +func TestDescribeIamInstanceProfileAssociations_EmptyResultIsValid(t *testing.T) { + _, nc := startTestNATSServer(t) + _, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) { + resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{}) + nc.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + + out, err := DescribeIamInstanceProfileAssociations( + &ec2.DescribeIamInstanceProfileAssociationsInput{}, nc, 1, testGwAccountID) + require.NoError(t, err, "no records is a valid response, not an error") + assert.Empty(t, out.IamInstanceProfileAssociations) +} + +// --- CountInstanceProfileAssociations --------------------------------------- + +func TestCountInstanceProfileAssociations_MatchesByARN(t *testing.T) { + _, nc := startTestNATSServer(t) + _, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) { + resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{ + IamInstanceProfileAssociations: []*ec2.IamInstanceProfileAssociation{ + {AssociationId: aws.String("iip-assoc-001"), InstanceId: aws.String("i-1"), IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}, State: aws.String("associated")}, + {AssociationId: aws.String("iip-assoc-002"), InstanceId: aws.String("i-2"), IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNOther)}, State: aws.String("associated")}, + {AssociationId: aws.String("iip-assoc-003"), InstanceId: aws.String("i-3"), IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}, State: aws.String("associated")}, + }, + }) + nc.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + + got, err := CountInstanceProfileAssociations(nc, 1, testGwAccountID, testProfileARNApp) + require.NoError(t, err) + assert.Equal(t, 2, got, "only associations whose ARN matches must count") +} + +func TestCountInstanceProfileAssociations_NoMatches(t *testing.T) { + _, nc := startTestNATSServer(t) + _, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) { + resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{ + IamInstanceProfileAssociations: []*ec2.IamInstanceProfileAssociation{ + {AssociationId: aws.String("iip-assoc-001"), InstanceId: aws.String("i-1"), IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNOther)}, State: aws.String("associated")}, + }, + }) + nc.Publish(msg.Reply, resp) + }) + require.NoError(t, err) + got, err := CountInstanceProfileAssociations(nc, 1, testGwAccountID, testProfileARNApp) + require.NoError(t, err) + assert.Equal(t, 0, got) +} + +// --- Association ID format sanity check (cross-package contract) ------------ + +// TestAssociationIDFormat asserts the AWS-compatible iip-assoc- prefix on IDs +// generated by GenerateResourceID. The format is a contract between the daemon +// (which produces it) and clients (who parse it via SDKs); regressions in the +// utils generator would break replay parsing of Disassociate/Replace. +func TestAssociationIDFormat(t *testing.T) { + id := utils.GenerateResourceID("iip-assoc") + assert.True(t, strings.HasPrefix(id, "iip-assoc-"), + "GenerateResourceID must preserve the AWS-style hyphen between prefix and suffix") + suffix := strings.TrimPrefix(id, "iip-assoc-") + assert.NotEmpty(t, suffix, "association ID must carry a non-empty random suffix") +} diff --git a/spinifex/gateway/ec2/instance/RunInstances.go b/spinifex/gateway/ec2/instance/RunInstances.go index 076cec67f..42fa6260c 100644 --- a/spinifex/gateway/ec2/instance/RunInstances.go +++ b/spinifex/gateway/ec2/instance/RunInstances.go @@ -9,10 +9,17 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/nats-io/nats.go" ) +// PassRoleChecker enforces iam:PassRole on the role inside an instance profile +// the caller is trying to attach. Implemented by the gateway via +// checkPolicyResource, defined as a callback to avoid an import cycle with +// the gateway package. +type PassRoleChecker func(roleARN string) error + type RunInstancesResponse struct { Reservation *ec2.Reservation `locationName:"RunInstancesResponse"` } @@ -62,7 +69,19 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) { return err } -func RunInstances(input *ec2.RunInstancesInput, natsConn *nats.Conn, accountID string) (reservation ec2.Reservation, err error) { +// RunInstances dispatches an EC2 RunInstances request after validating the +// input, resolving any supplied IAM instance profile, and enforcing +// iam:PassRole on the role inside that profile. The resolved profile ARN +// is normalised onto input.IamInstanceProfile.Arn before NATS dispatch so +// the daemon sees a single canonical reference. The returned reservation +// is enriched with the InstanceProfileID so callers see {Arn, Id} per the +// AWS contract. +// +// iamSvc may be nil only in pre-IAM compatibility paths; if input carries an +// IamInstanceProfile reference, iamSvc must be set or the call fails. +// passRoleCheck may be nil to skip the iam:PassRole enforcement (used by +// unit tests that don't exercise the policy path). +func RunInstances(input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker) (reservation ec2.Reservation, err error) { // Validate input err = ValidateRunInstancesInput(input) @@ -70,6 +89,11 @@ func RunInstances(input *ec2.RunInstancesInput, natsConn *nats.Conn, accountID s return reservation, err } + resolvedProfile, err := resolveAndAuthorizeInstanceProfile(input, iamSvc, accountID, passRoleCheck) + if err != nil { + return reservation, err + } + // Placement group routing: when a placement group is specified, validate it // and route based on its strategy (spread or cluster). groupName := placementGroupName(input) @@ -85,12 +109,14 @@ func RunInstances(input *ec2.RunInstancesInput, natsConn *nats.Conn, accountID s if err != nil { return reservation, err } + enrichReservationWithProfileID(reservationPtr, resolvedProfile) return *reservationPtr, nil case ec2.PlacementStrategyCluster: reservationPtr, err := distributeInstancesCluster(input, natsConn, accountID, groupName) if err != nil { return reservation, err } + enrichReservationWithProfileID(reservationPtr, resolvedProfile) return *reservationPtr, nil default: return reservation, errors.New(awserrors.ErrorInvalidParameterValue) @@ -112,9 +138,60 @@ func RunInstances(input *ec2.RunInstancesInput, natsConn *nats.Conn, accountID s } return reservation, err } + enrichReservationWithProfileID(reservationPtr, resolvedProfile) return *reservationPtr, nil } +// resolveAndAuthorizeInstanceProfile resolves and authorizes the optional +// instance profile in RunInstancesInput, normalising the input so the daemon +// sees only the canonical ARN (clearing Name avoids double-resolution if the +// profile is renamed between gateway resolution and daemon launch). +func resolveAndAuthorizeInstanceProfile(input *ec2.RunInstancesInput, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker) (*handlers_iam.InstanceProfile, error) { + if input.IamInstanceProfile == nil { + return nil, nil + } + profile, err := resolveAndAuthorizeProfile(input.IamInstanceProfile, iamSvc, accountID, passRoleCheck) + if err != nil { + return nil, err + } + input.IamInstanceProfile = &ec2.IamInstanceProfileSpecification{Arn: aws.String(profile.ARN)} + return profile, nil +} + +// profileNameOrARN extracts whichever field of IamInstanceProfileSpecification +// was set by the caller. AWS accepts either Name or Arn (not both). +func profileNameOrARN(spec *ec2.IamInstanceProfileSpecification) string { + if spec == nil { + return "" + } + if arn := aws.StringValue(spec.Arn); arn != "" { + return arn + } + return aws.StringValue(spec.Name) +} + +// enrichReservationWithProfileID fills in Instance.IamInstanceProfile.Id on +// every instance in the reservation when a profile was resolved at the +// gateway. Daemons emit only Arn (they have no IAM access); the gateway +// adds Id from the already-resolved profile to match the AWS response shape. +func enrichReservationWithProfileID(reservation *ec2.Reservation, profile *handlers_iam.InstanceProfile) { + if reservation == nil || profile == nil { + return + } + for _, inst := range reservation.Instances { + if inst == nil { + continue + } + if inst.IamInstanceProfile == nil { + inst.IamInstanceProfile = &ec2.IamInstanceProfile{} + } + if inst.IamInstanceProfile.Arn == nil { + inst.IamInstanceProfile.Arn = aws.String(profile.ARN) + } + inst.IamInstanceProfile.Id = aws.String(profile.InstanceProfileID) + } +} + // placementGroupName extracts the placement group name from RunInstancesInput. func placementGroupName(input *ec2.RunInstancesInput) string { if input.Placement != nil && input.Placement.GroupName != nil { diff --git a/spinifex/gateway/ec2/instance/RunInstances_test.go b/spinifex/gateway/ec2/instance/RunInstances_test.go index 6db8a94a3..aabf468af 100644 --- a/spinifex/gateway/ec2/instance/RunInstances_test.go +++ b/spinifex/gateway/ec2/instance/RunInstances_test.go @@ -7,7 +7,9 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var defaults = ec2.RunInstancesInput{ @@ -229,7 +231,7 @@ func TestParseRunInstances(t *testing.T) { } // For validation tests, we can pass nil conn since validation happens before NATS call - response, err := RunInstances(test.input, nil, "123456789012") + response, err := RunInstances(test.input, nil, nil, "123456789012", nil) // Use assert to check if the error is as expected assert.Equal(t, test.want, err) @@ -242,3 +244,143 @@ func TestParseRunInstances(t *testing.T) { // Additional test } + +// --- RunInstances + IamInstanceProfile ------------------------------------- +// +// These tests exercise the gateway-side resolveAndAuthorizeInstanceProfile + +// enrichReservationWithProfileID flow without standing up the full daemon +// path. We exercise the helper directly so failures point at the IAM/policy +// integration, not at unrelated NATS plumbing. + +func runValidInputWithProfile(spec *ec2.IamInstanceProfileSpecification) *ec2.RunInstancesInput { + in := defaults + in.IamInstanceProfile = spec + return &in +} + +func TestResolveAndAuthorizeInstanceProfile_Absent(t *testing.T) { + // No IamInstanceProfile on the input → no resolution, no PassRole check. + in := &ec2.RunInstancesInput{} // empty + profile, err := resolveAndAuthorizeInstanceProfile(in, nil, testGwAccountID, nil) + require.NoError(t, err) + assert.Nil(t, profile) +} + +func TestResolveAndAuthorizeInstanceProfile_NameForm_NormalisesToARN(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) { + assert.Equal(t, testProfileNameApp, nameOrARN) + return profileWithRole(), nil + }} + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}) + + profile, err := resolveAndAuthorizeInstanceProfile(in, svc, testGwAccountID, func(string) error { return nil }) + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, testProfileARNApp, profile.ARN) + + // Daemons only ever see the canonical ARN — Name must be cleared post-resolution. + require.NotNil(t, in.IamInstanceProfile) + assert.Equal(t, testProfileARNApp, aws.StringValue(in.IamInstanceProfile.Arn)) + assert.Nil(t, in.IamInstanceProfile.Name, "Name must be cleared so the daemon cannot double-resolve") +} + +func TestResolveAndAuthorizeInstanceProfile_ArnForm(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) { + assert.Equal(t, testProfileARNApp, nameOrARN) + return profileWithRole(), nil + }} + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Arn: aws.String(testProfileARNApp)}) + profile, err := resolveAndAuthorizeInstanceProfile(in, svc, testGwAccountID, func(string) error { return nil }) + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, testProfileARNApp, profile.ARN) +} + +func TestResolveAndAuthorizeInstanceProfile_MissingNameAndArn(t *testing.T) { + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{}) + _, err := resolveAndAuthorizeInstanceProfile(in, &fakeIAMService{}, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestResolveAndAuthorizeInstanceProfile_NilIAMService(t *testing.T) { + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}) + _, err := resolveAndAuthorizeInstanceProfile(in, nil, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorServerInternal, err.Error()) +} + +func TestResolveAndAuthorizeInstanceProfile_NotFoundMapsToEC2Error(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + }} + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Name: aws.String("ghost")}) + _, err := resolveAndAuthorizeInstanceProfile(in, svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidIamInstanceProfileNotFound, err.Error()) +} + +func TestResolveAndAuthorizeInstanceProfile_CrossAccountARNPassthrough(t *testing.T) { + // IAM service rejects cross-account; gateway surfaces the AccessDenied verbatim. + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return nil, errors.New(awserrors.ErrorAccessDenied) + }} + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Arn: aws.String(testCrossAccountARN)}) + _, err := resolveAndAuthorizeInstanceProfile(in, svc, testGwAccountID, nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) +} + +func TestResolveAndAuthorizeInstanceProfile_PassRoleDenied(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileWithRole(), nil + }} + check := func(roleARN string) error { + assert.Equal(t, testRoleARNApp, roleARN) + return errors.New(awserrors.ErrorAccessDenied) + } + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)}) + _, err := resolveAndAuthorizeInstanceProfile(in, svc, testGwAccountID, check) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) +} + +func TestResolveAndAuthorizeInstanceProfile_NoRoleSkipsPassRole(t *testing.T) { + svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) { + return profileNoRole(), nil + }} + checkCalled := false + check := func(string) error { + checkCalled = true + return errors.New(awserrors.ErrorAccessDenied) + } + in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameOther)}) + profile, err := resolveAndAuthorizeInstanceProfile(in, svc, testGwAccountID, check) + require.NoError(t, err) + require.NotNil(t, profile) + assert.False(t, checkCalled, "PassRole must be skipped when the profile has no role attached") +} + +func TestEnrichReservationWithProfileID_PopulatesID(t *testing.T) { + r := &ec2.Reservation{Instances: []*ec2.Instance{ + {InstanceId: aws.String("i-001"), IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}}, + {InstanceId: aws.String("i-002")}, // no IamInstanceProfile yet — gateway fills both Arn + Id + }} + enrichReservationWithProfileID(r, profileWithRole()) + require.Len(t, r.Instances, 2) + for _, inst := range r.Instances { + require.NotNil(t, inst.IamInstanceProfile) + assert.Equal(t, testProfileARNApp, aws.StringValue(inst.IamInstanceProfile.Arn)) + assert.Equal(t, testProfileIDApp, aws.StringValue(inst.IamInstanceProfile.Id)) + } +} + +func TestEnrichReservationWithProfileID_NoProfileIsNoOp(t *testing.T) { + r := &ec2.Reservation{Instances: []*ec2.Instance{{InstanceId: aws.String("i-001")}}} + enrichReservationWithProfileID(r, nil) + assert.Nil(t, r.Instances[0].IamInstanceProfile, "nil profile must not synthesise an empty IamInstanceProfile") +} + +func TestEnrichReservationWithProfileID_NilReservationIsSafe(t *testing.T) { + enrichReservationWithProfileID(nil, profileWithRole()) // must not panic +} diff --git a/spinifex/gateway/ec2/instance/placement_test.go b/spinifex/gateway/ec2/instance/placement_test.go index e729a3e25..112f26ea6 100644 --- a/spinifex/gateway/ec2/instance/placement_test.go +++ b/spinifex/gateway/ec2/instance/placement_test.go @@ -631,7 +631,7 @@ func TestRunInstances_SingleInstanceDistributes(t *testing.T) { MaxCount: aws.Int64(1), } - reservation, err := RunInstances(input, nc, "test-account") + reservation, err := RunInstances(input, nc, nil, "test-account", nil) require.NoError(t, err) assert.Len(t, reservation.Instances, 1) assert.Equal(t, "i-single", aws.StringValue(reservation.Instances[0].InstanceId)) @@ -1136,7 +1136,7 @@ func TestRunInstances_ClusterPlacementGroupRouting(t *testing.T) { }, } - reservation, err := RunInstances(input, nc, "test-account") + reservation, err := RunInstances(input, nc, nil, "test-account", nil) require.NoError(t, err) assert.Len(t, reservation.Instances, 2) } @@ -1184,7 +1184,7 @@ func TestRunInstances_MultiInstanceUsesDistribution(t *testing.T) { MaxCount: aws.Int64(2), } - reservation, err := RunInstances(input, nc, "test-account") + reservation, err := RunInstances(input, nc, nil, "test-account", nil) require.NoError(t, err) assert.Len(t, reservation.Instances, 2) assert.True(t, statusQueried, "multi-instance launch should query node status") diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 57fa2b06b..c6a0ad79a 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -292,11 +292,22 @@ func isNATSTransient(err error) bool { errors.Is(err, nats.ErrNoStreamResponse)) } -// checkPolicy evaluates IAM policies for the current request. Returns nil -// if access is allowed, or an ErrorAccessDenied error if denied. -// Root users bypass evaluation entirely. If the IAM service is unavailable, -// access is allowed (pre-IAM compatibility). +// checkPolicy evaluates IAM policies for the current request against the +// wildcard resource "*". Equivalent to checkPolicyResource(r, service, +// action, "*"). Returns nil if access is allowed, or an ErrorAccessDenied +// error if denied. func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) error { + return gw.checkPolicyResource(r, service, action, "*") +} + +// checkPolicyResource evaluates IAM policies for the current request against +// a specific resource ARN. Returns nil if access is allowed, or an +// ErrorAccessDenied error if denied. Root users bypass evaluation entirely. +// If the IAM service is unavailable, access is allowed (pre-IAM compatibility). +// +// Used by EC2 paths that enforce iam:PassRole on a role ARN before attaching +// an instance profile. +func (gw *GatewayConfig) checkPolicyResource(r *http.Request, service, action, resource string) error { if gw.IAMService == nil { slog.Warn("checkPolicy: IAM service not available, skipping policy check", "service", service, "action", action) @@ -346,8 +357,8 @@ func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) er return errors.New(awserrors.ErrorInternalError) } - if policy.EvaluateAccess(identity, iamAction, "*", policies) == policy.Deny { - slog.Info("checkPolicy: access denied", "user", identity, "action", iamAction) + if policy.EvaluateAccess(identity, iamAction, resource, policies) == policy.Deny { + slog.Info("checkPolicy: access denied", "user", identity, "action", iamAction, "resource", resource) return errors.New(awserrors.ErrorAccessDenied) } diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go index 8dec5216c..af628d14a 100644 --- a/spinifex/gateway/gateway_test.go +++ b/spinifex/gateway/gateway_test.go @@ -942,7 +942,7 @@ func TestImportKeyPair_Base64PaddingWorkaround(t *testing.T) { gw := &GatewayConfig{DisableLogging: true, NATSConn: nil} // The handler will fail because NATS is nil, but we can verify the // workaround ran by checking that q["PublicKeyMaterial"] was modified. - _, _ = handler("ImportKeyPair", q, gw, "123456789012") + _, _ = handler("ImportKeyPair", q, gw, "123456789012", nil) // After the workaround, the URL-encoded padding should be decoded assert.True(t, strings.HasSuffix(q["PublicKeyMaterial"], "=="), diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go index d625a3b0c..5cb344834 100644 --- a/spinifex/gateway/iam.go +++ b/spinifex/gateway/iam.go @@ -8,17 +8,19 @@ import ( "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awsec2query" "github.com/mulgadc/spinifex/spinifex/awserrors" + gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance" gateway_iam "github.com/mulgadc/spinifex/spinifex/gateway/iam" - handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/utils" ) // IAMHandler processes parsed query args and returns XML response bytes. type IAMHandler func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) -// iamHandler creates a type-safe IAMHandler that allocates the typed input struct, -// parses query params into it, calls the handler, and marshals the output to XML. -func iamHandler[In any](handler func(string, *In, handlers_iam.IAMService) (any, error)) IAMHandler { +// iamHandler creates a type-safe IAMHandler that allocates the typed input +// struct, parses query params into it, calls the handler with the full gateway +// config (for the rare handler that needs more than gw.IAMService), and +// marshals the output to XML. Mirrors ec2Handler in ec2.go. +func iamHandler[In any](handler func(string, *In, *GatewayConfig) (any, error)) IAMHandler { return func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) { input := new(In) if err := awsec2query.QueryParamsToStruct(q, input); err != nil { @@ -27,7 +29,7 @@ func iamHandler[In any](handler func(string, *In, handlers_iam.IAMService) (any, } return nil, errors.New(awserrors.ErrorIAMInvalidInput) } - output, err := handler(accountID, input, gw.IAMService) + output, err := handler(accountID, input, gw) if err != nil { return nil, err } @@ -41,113 +43,116 @@ func iamHandler[In any](handler func(string, *In, handlers_iam.IAMService) (any, } var iamActions = map[string]IAMHandler{ - "CreateUser": iamHandler(func(accountID string, input *iam.CreateUserInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.CreateUser(accountID, input, svc) + "CreateUser": iamHandler(func(accountID string, input *iam.CreateUserInput, gw *GatewayConfig) (any, error) { + return gateway_iam.CreateUser(accountID, input, gw.IAMService) }), - "GetUser": iamHandler(func(accountID string, input *iam.GetUserInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.GetUser(accountID, input, svc) + "GetUser": iamHandler(func(accountID string, input *iam.GetUserInput, gw *GatewayConfig) (any, error) { + return gateway_iam.GetUser(accountID, input, gw.IAMService) }), - "ListUsers": iamHandler(func(accountID string, input *iam.ListUsersInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListUsers(accountID, input, svc) + "ListUsers": iamHandler(func(accountID string, input *iam.ListUsersInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListUsers(accountID, input, gw.IAMService) }), - "DeleteUser": iamHandler(func(accountID string, input *iam.DeleteUserInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.DeleteUser(accountID, input, svc) + "DeleteUser": iamHandler(func(accountID string, input *iam.DeleteUserInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DeleteUser(accountID, input, gw.IAMService) }), - "CreateAccessKey": iamHandler(func(accountID string, input *iam.CreateAccessKeyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.CreateAccessKey(accountID, input, svc) + "CreateAccessKey": iamHandler(func(accountID string, input *iam.CreateAccessKeyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.CreateAccessKey(accountID, input, gw.IAMService) }), - "ListAccessKeys": iamHandler(func(accountID string, input *iam.ListAccessKeysInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListAccessKeys(accountID, input, svc) + "ListAccessKeys": iamHandler(func(accountID string, input *iam.ListAccessKeysInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListAccessKeys(accountID, input, gw.IAMService) }), - "DeleteAccessKey": iamHandler(func(accountID string, input *iam.DeleteAccessKeyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.DeleteAccessKey(accountID, input, svc) + "DeleteAccessKey": iamHandler(func(accountID string, input *iam.DeleteAccessKeyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DeleteAccessKey(accountID, input, gw.IAMService) }), - "UpdateAccessKey": iamHandler(func(accountID string, input *iam.UpdateAccessKeyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.UpdateAccessKey(accountID, input, svc) + "UpdateAccessKey": iamHandler(func(accountID string, input *iam.UpdateAccessKeyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.UpdateAccessKey(accountID, input, gw.IAMService) }), // Policy CRUD - "CreatePolicy": iamHandler(func(accountID string, input *iam.CreatePolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.CreatePolicy(accountID, input, svc) + "CreatePolicy": iamHandler(func(accountID string, input *iam.CreatePolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.CreatePolicy(accountID, input, gw.IAMService) }), - "GetPolicy": iamHandler(func(accountID string, input *iam.GetPolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.GetPolicy(accountID, input, svc) + "GetPolicy": iamHandler(func(accountID string, input *iam.GetPolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.GetPolicy(accountID, input, gw.IAMService) }), - "GetPolicyVersion": iamHandler(func(accountID string, input *iam.GetPolicyVersionInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.GetPolicyVersion(accountID, input, svc) + "GetPolicyVersion": iamHandler(func(accountID string, input *iam.GetPolicyVersionInput, gw *GatewayConfig) (any, error) { + return gateway_iam.GetPolicyVersion(accountID, input, gw.IAMService) }), - "ListPolicies": iamHandler(func(accountID string, input *iam.ListPoliciesInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListPolicies(accountID, input, svc) + "ListPolicies": iamHandler(func(accountID string, input *iam.ListPoliciesInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListPolicies(accountID, input, gw.IAMService) }), - "DeletePolicy": iamHandler(func(accountID string, input *iam.DeletePolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.DeletePolicy(accountID, input, svc) + "DeletePolicy": iamHandler(func(accountID string, input *iam.DeletePolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DeletePolicy(accountID, input, gw.IAMService) }), // Policy attachment - "AttachUserPolicy": iamHandler(func(accountID string, input *iam.AttachUserPolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.AttachUserPolicy(accountID, input, svc) + "AttachUserPolicy": iamHandler(func(accountID string, input *iam.AttachUserPolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.AttachUserPolicy(accountID, input, gw.IAMService) }), - "DetachUserPolicy": iamHandler(func(accountID string, input *iam.DetachUserPolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.DetachUserPolicy(accountID, input, svc) + "DetachUserPolicy": iamHandler(func(accountID string, input *iam.DetachUserPolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DetachUserPolicy(accountID, input, gw.IAMService) }), - "ListAttachedUserPolicies": iamHandler(func(accountID string, input *iam.ListAttachedUserPoliciesInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListAttachedUserPolicies(accountID, input, svc) + "ListAttachedUserPolicies": iamHandler(func(accountID string, input *iam.ListAttachedUserPoliciesInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListAttachedUserPolicies(accountID, input, gw.IAMService) }), // Role CRUD - "CreateRole": iamHandler(func(accountID string, input *iam.CreateRoleInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.CreateRole(accountID, input, svc) + "CreateRole": iamHandler(func(accountID string, input *iam.CreateRoleInput, gw *GatewayConfig) (any, error) { + return gateway_iam.CreateRole(accountID, input, gw.IAMService) }), - "GetRole": iamHandler(func(accountID string, input *iam.GetRoleInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.GetRole(accountID, input, svc) + "GetRole": iamHandler(func(accountID string, input *iam.GetRoleInput, gw *GatewayConfig) (any, error) { + return gateway_iam.GetRole(accountID, input, gw.IAMService) }), - "ListRoles": iamHandler(func(accountID string, input *iam.ListRolesInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListRoles(accountID, input, svc) + "ListRoles": iamHandler(func(accountID string, input *iam.ListRolesInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListRoles(accountID, input, gw.IAMService) }), - "DeleteRole": iamHandler(func(accountID string, input *iam.DeleteRoleInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.DeleteRole(accountID, input, svc) + "DeleteRole": iamHandler(func(accountID string, input *iam.DeleteRoleInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DeleteRole(accountID, input, gw.IAMService) }), - "UpdateRole": iamHandler(func(accountID string, input *iam.UpdateRoleInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.UpdateRole(accountID, input, svc) + "UpdateRole": iamHandler(func(accountID string, input *iam.UpdateRoleInput, gw *GatewayConfig) (any, error) { + return gateway_iam.UpdateRole(accountID, input, gw.IAMService) }), - "UpdateAssumeRolePolicy": iamHandler(func(accountID string, input *iam.UpdateAssumeRolePolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.UpdateAssumeRolePolicy(accountID, input, svc) + "UpdateAssumeRolePolicy": iamHandler(func(accountID string, input *iam.UpdateAssumeRolePolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.UpdateAssumeRolePolicy(accountID, input, gw.IAMService) }), // Role policy attachment - "AttachRolePolicy": iamHandler(func(accountID string, input *iam.AttachRolePolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.AttachRolePolicy(accountID, input, svc) + "AttachRolePolicy": iamHandler(func(accountID string, input *iam.AttachRolePolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.AttachRolePolicy(accountID, input, gw.IAMService) }), - "DetachRolePolicy": iamHandler(func(accountID string, input *iam.DetachRolePolicyInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.DetachRolePolicy(accountID, input, svc) + "DetachRolePolicy": iamHandler(func(accountID string, input *iam.DetachRolePolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DetachRolePolicy(accountID, input, gw.IAMService) }), - "ListAttachedRolePolicies": iamHandler(func(accountID string, input *iam.ListAttachedRolePoliciesInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListAttachedRolePolicies(accountID, input, svc) + "ListAttachedRolePolicies": iamHandler(func(accountID string, input *iam.ListAttachedRolePoliciesInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListAttachedRolePolicies(accountID, input, gw.IAMService) }), // Instance profile CRUD - "CreateInstanceProfile": iamHandler(func(accountID string, input *iam.CreateInstanceProfileInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.CreateInstanceProfile(accountID, input, svc) + "CreateInstanceProfile": iamHandler(func(accountID string, input *iam.CreateInstanceProfileInput, gw *GatewayConfig) (any, error) { + return gateway_iam.CreateInstanceProfile(accountID, input, gw.IAMService) }), - "GetInstanceProfile": iamHandler(func(accountID string, input *iam.GetInstanceProfileInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.GetInstanceProfile(accountID, input, svc) + "GetInstanceProfile": iamHandler(func(accountID string, input *iam.GetInstanceProfileInput, gw *GatewayConfig) (any, error) { + return gateway_iam.GetInstanceProfile(accountID, input, gw.IAMService) }), - "ListInstanceProfiles": iamHandler(func(accountID string, input *iam.ListInstanceProfilesInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListInstanceProfiles(accountID, input, svc) + "ListInstanceProfiles": iamHandler(func(accountID string, input *iam.ListInstanceProfilesInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListInstanceProfiles(accountID, input, gw.IAMService) }), - "DeleteInstanceProfile": iamHandler(func(accountID string, input *iam.DeleteInstanceProfileInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.DeleteInstanceProfile(accountID, input, svc) + "DeleteInstanceProfile": iamHandler(func(accountID string, input *iam.DeleteInstanceProfileInput, gw *GatewayConfig) (any, error) { + countLive := func(profileARN string) (int, error) { + return gateway_ec2_instance.CountInstanceProfileAssociations(gw.NATSConn, gw.DiscoverActiveNodes(), accountID, profileARN) + } + return gateway_iam.DeleteInstanceProfile(accountID, input, gw.IAMService, countLive) }), - "ListInstanceProfilesForRole": iamHandler(func(accountID string, input *iam.ListInstanceProfilesForRoleInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.ListInstanceProfilesForRole(accountID, input, svc) + "ListInstanceProfilesForRole": iamHandler(func(accountID string, input *iam.ListInstanceProfilesForRoleInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListInstanceProfilesForRole(accountID, input, gw.IAMService) }), // Instance profile ↔ role binding - "AddRoleToInstanceProfile": iamHandler(func(accountID string, input *iam.AddRoleToInstanceProfileInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.AddRoleToInstanceProfile(accountID, input, svc) + "AddRoleToInstanceProfile": iamHandler(func(accountID string, input *iam.AddRoleToInstanceProfileInput, gw *GatewayConfig) (any, error) { + return gateway_iam.AddRoleToInstanceProfile(accountID, input, gw.IAMService) }), - "RemoveRoleFromInstanceProfile": iamHandler(func(accountID string, input *iam.RemoveRoleFromInstanceProfileInput, svc handlers_iam.IAMService) (any, error) { - return gateway_iam.RemoveRoleFromInstanceProfile(accountID, input, svc) + "RemoveRoleFromInstanceProfile": iamHandler(func(accountID string, input *iam.RemoveRoleFromInstanceProfileInput, gw *GatewayConfig) (any, error) { + return gateway_iam.RemoveRoleFromInstanceProfile(accountID, input, gw.IAMService) }), } diff --git a/spinifex/gateway/iam/handler_test.go b/spinifex/gateway/iam/handler_test.go index 39feb3bd8..ecc6ffdb2 100644 --- a/spinifex/gateway/iam/handler_test.go +++ b/spinifex/gateway/iam/handler_test.go @@ -12,8 +12,11 @@ import ( const testAccountID = "000000000000" -// stubIAMService returns empty non-nil outputs for all methods. -type stubIAMService struct{} +// stubIAMService returns empty non-nil outputs for all methods. Individual +// tests can override a method by setting the matching function field. +type stubIAMService struct { + getInstanceProfile func(string, *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) +} func (s *stubIAMService) CreateUser(_ string, _ *iam.CreateUserInput) (*iam.CreateUserOutput, error) { return &iam.CreateUserOutput{}, nil @@ -127,8 +130,11 @@ func (s *stubIAMService) ListAttachedRolePolicies(_ string, _ *iam.ListAttachedR func (s *stubIAMService) CreateInstanceProfile(_ string, _ *iam.CreateInstanceProfileInput) (*iam.CreateInstanceProfileOutput, error) { return &iam.CreateInstanceProfileOutput{}, nil } -func (s *stubIAMService) GetInstanceProfile(_ string, _ *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) { - return &iam.GetInstanceProfileOutput{}, nil +func (s *stubIAMService) GetInstanceProfile(accountID string, in *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) { + if s.getInstanceProfile != nil { + return s.getInstanceProfile(accountID, in) + } + return &iam.GetInstanceProfileOutput{InstanceProfile: &iam.InstanceProfile{}}, nil } func (s *stubIAMService) ListInstanceProfiles(_ string, _ *iam.ListInstanceProfilesInput) (*iam.ListInstanceProfilesOutput, error) { return &iam.ListInstanceProfilesOutput{}, nil @@ -145,6 +151,9 @@ func (s *stubIAMService) AddRoleToInstanceProfile(_ string, _ *iam.AddRoleToInst func (s *stubIAMService) RemoveRoleFromInstanceProfile(_ string, _ *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) { return &iam.RemoveRoleFromInstanceProfileOutput{}, nil } +func (s *stubIAMService) ResolveInstanceProfile(_, _ string) (*handlers_iam.InstanceProfile, error) { + return nil, nil +} func TestCreateUser(t *testing.T) { svc := &stubIAMService{} diff --git a/spinifex/gateway/iam/instance_profile.go b/spinifex/gateway/iam/instance_profile.go index 848585b89..5475b1013 100644 --- a/spinifex/gateway/iam/instance_profile.go +++ b/spinifex/gateway/iam/instance_profile.go @@ -2,12 +2,20 @@ package gateway_iam import ( "errors" + "log/slog" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" ) +// LiveAssociationCounter reports how many live EC2 instance-profile +// associations currently reference the given profile ARN. Implementations +// broadcast on ec2.IamProfileAssociation.describe and filter by ARN. +// DeleteInstanceProfile uses it to refuse delete-while-in-use. +type LiveAssociationCounter func(profileARN string) (int, error) + func CreateInstanceProfile(accountID string, input *iam.CreateInstanceProfileInput, svc handlers_iam.IAMService) (*iam.CreateInstanceProfileOutput, error) { if input.InstanceProfileName == nil || *input.InstanceProfileName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) @@ -26,10 +34,45 @@ func ListInstanceProfiles(accountID string, input *iam.ListInstanceProfilesInput return svc.ListInstanceProfiles(accountID, input) } -func DeleteInstanceProfile(accountID string, input *iam.DeleteInstanceProfileInput, svc handlers_iam.IAMService) (*iam.DeleteInstanceProfileOutput, error) { +// DeleteInstanceProfile refuses to delete a profile that any live VM still +// references. The gateway resolves the profile ARN via svc.GetInstanceProfile, +// then countLive fans out to every daemon to count active associations; a +// non-zero count maps to DeleteConflict. The existing RoleName-attached guard +// runs inside svc.DeleteInstanceProfile after both checks pass. +// +// Race window between the live-instance scan and the bucket delete is +// accepted: same pattern as DeletePolicy (no CAS, eventual consistency). +// countLive may be nil — primarily in unit tests that don't exercise the +// fan-out path; in production the gateway always supplies it. +func DeleteInstanceProfile(accountID string, input *iam.DeleteInstanceProfileInput, svc handlers_iam.IAMService, countLive LiveAssociationCounter) (*iam.DeleteInstanceProfileOutput, error) { if input.InstanceProfileName == nil || *input.InstanceProfileName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + + if countLive != nil { + getOut, err := svc.GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{ + InstanceProfileName: input.InstanceProfileName, + }) + if err != nil { + return nil, err + } + profileARN := aws.StringValue(getOut.InstanceProfile.Arn) + if profileARN != "" { + count, err := countLive(profileARN) + if err != nil { + return nil, err + } + if count > 0 { + slog.Info("DeleteInstanceProfile refused: profile in use", + "accountID", accountID, + "instanceProfileName", *input.InstanceProfileName, + "profileARN", profileARN, + "liveAssociations", count) + return nil, errors.New(awserrors.ErrorIAMDeleteConflict) + } + } + } + return svc.DeleteInstanceProfile(accountID, input) } diff --git a/spinifex/gateway/iam/instance_profile_test.go b/spinifex/gateway/iam/instance_profile_test.go index fd57bea6e..9fb094043 100644 --- a/spinifex/gateway/iam/instance_profile_test.go +++ b/spinifex/gateway/iam/instance_profile_test.go @@ -1,6 +1,7 @@ package gateway_iam import ( + "errors" "testing" "github.com/aws/aws-sdk-go/aws" @@ -67,17 +68,24 @@ func TestListInstanceProfiles(t *testing.T) { func TestDeleteInstanceProfile(t *testing.T) { svc := &stubIAMService{} tests := []struct { - name string - input *iam.DeleteInstanceProfileInput - wantErr string + name string + input *iam.DeleteInstanceProfileInput + countLive LiveAssociationCounter + wantErr string }{ - {"nil InstanceProfileName", &iam.DeleteInstanceProfileInput{}, awserrors.ErrorMissingParameter}, - {"empty InstanceProfileName", &iam.DeleteInstanceProfileInput{InstanceProfileName: aws.String("")}, awserrors.ErrorMissingParameter}, - {"valid", &iam.DeleteInstanceProfileInput{InstanceProfileName: aws.String("p")}, ""}, + {"nil InstanceProfileName", &iam.DeleteInstanceProfileInput{}, nil, awserrors.ErrorMissingParameter}, + {"empty InstanceProfileName", &iam.DeleteInstanceProfileInput{InstanceProfileName: aws.String("")}, nil, awserrors.ErrorMissingParameter}, + {"valid no counter", &iam.DeleteInstanceProfileInput{InstanceProfileName: aws.String("p")}, nil, ""}, + { + "valid with zero live associations", + &iam.DeleteInstanceProfileInput{InstanceProfileName: aws.String("p")}, + func(string) (int, error) { return 0, nil }, + "", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, err := DeleteInstanceProfile(testAccountID, tc.input, svc) + _, err := DeleteInstanceProfile(testAccountID, tc.input, svc, tc.countLive) if tc.wantErr != "" { require.Error(t, err) assert.Equal(t, tc.wantErr, err.Error()) @@ -88,6 +96,48 @@ func TestDeleteInstanceProfile(t *testing.T) { } } +func TestDeleteInstanceProfile_LiveAssociationsRefused(t *testing.T) { + svc := &stubIAMService{ + getInstanceProfile: func(_ string, in *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) { + return &iam.GetInstanceProfileOutput{ + InstanceProfile: &iam.InstanceProfile{ + InstanceProfileName: in.InstanceProfileName, + Arn: aws.String("arn:aws:iam::000000000000:instance-profile/p"), + }, + }, nil + }, + } + gotARN := "" + countLive := func(arn string) (int, error) { + gotARN = arn + return 2, nil + } + _, err := DeleteInstanceProfile(testAccountID, &iam.DeleteInstanceProfileInput{ + InstanceProfileName: aws.String("p"), + }, svc, countLive) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorIAMDeleteConflict, err.Error()) + assert.Equal(t, "arn:aws:iam::000000000000:instance-profile/p", gotARN) +} + +func TestDeleteInstanceProfile_CounterError(t *testing.T) { + svc := &stubIAMService{ + getInstanceProfile: func(_ string, in *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) { + return &iam.GetInstanceProfileOutput{ + InstanceProfile: &iam.InstanceProfile{ + InstanceProfileName: in.InstanceProfileName, + Arn: aws.String("arn:aws:iam::000000000000:instance-profile/p"), + }, + }, nil + }, + } + wantErr := errors.New("nats unavailable") + _, err := DeleteInstanceProfile(testAccountID, &iam.DeleteInstanceProfileInput{ + InstanceProfileName: aws.String("p"), + }, svc, func(string) (int, error) { return 0, wantErr }) + require.ErrorIs(t, err, wantErr) +} + func TestListInstanceProfilesForRole(t *testing.T) { svc := &stubIAMService{} tests := []struct { diff --git a/spinifex/gateway/iam_request_test.go b/spinifex/gateway/iam_request_test.go index c40851abd..11784209f 100644 --- a/spinifex/gateway/iam_request_test.go +++ b/spinifex/gateway/iam_request_test.go @@ -182,6 +182,9 @@ func (m *flexMockIAMService) AddRoleToInstanceProfile(_ string, _ *iam.AddRoleTo func (m *flexMockIAMService) RemoveRoleFromInstanceProfile(_ string, _ *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) { return &iam.RemoveRoleFromInstanceProfileOutput{}, nil } +func (m *flexMockIAMService) ResolveInstanceProfile(_, _ string) (*handlers_iam.InstanceProfile, error) { + return nil, nil +} // setupIAMRequestHandler creates an http.Handler wired for IAM_Request testing. // It injects sigv4 context values so individual tests don't need SigV4 auth. diff --git a/spinifex/gateway/policy/evaluator.go b/spinifex/gateway/policy/evaluator.go index a4fe8ca80..0bd9d5013 100644 --- a/spinifex/gateway/policy/evaluator.go +++ b/spinifex/gateway/policy/evaluator.go @@ -5,6 +5,7 @@ import ( "log/slog" "strings" + "github.com/mulgadc/spinifex/spinifex/filterutil" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" ) @@ -58,36 +59,16 @@ func EvaluateAccess(identity, action, resource string, policies []handlers_iam.P return Deny } -// matchesAny returns true if any pattern in patterns matches the given value. -// Supports the same wildcard matching for both actions and resources: -// - "*" — matches everything -// - "ec2:*" — matches all actions in the ec2 service -// - "s3:Get*" — matches s3:GetObject, s3:GetBucketPolicy, etc. -// - "ec2:RunInstances" — exact match +// matchesAny returns true if any pattern in patterns matches the given value +// with AWS IAM-style wildcard semantics ("*" matches zero or more characters). +// Case-insensitive — both action names ("ec2:*", "s3:Get*") and resource ARNs +// ("arn:aws:iam::*:role/app-*") are compared lower-cased. func matchesAny(patterns []string, value string) bool { + lv := strings.ToLower(value) for _, p := range patterns { - if matchWildcard(p, value) { + if filterutil.MatchWildcard(strings.ToLower(p), lv) { return true } } return false } - -// matchWildcard performs simple wildcard matching where "*" can appear at the -// end of a pattern as a suffix wildcard, or alone to match everything. -// Examples: -// -// "*" matches anything -// "ec2:*" matches "ec2:RunInstances" -// "s3:Get*" matches "s3:GetObject" -// "ec2:RunInstances" matches only "ec2:RunInstances" -func matchWildcard(pattern, value string) bool { - if pattern == "*" { - return true - } - if strings.HasSuffix(pattern, "*") { - prefix := strings.ToLower(pattern[:len(pattern)-1]) - return strings.HasPrefix(strings.ToLower(value), prefix) - } - return strings.EqualFold(pattern, value) -} diff --git a/spinifex/gateway/policy/evaluator_test.go b/spinifex/gateway/policy/evaluator_test.go index a58e57e58..42c139530 100644 --- a/spinifex/gateway/policy/evaluator_test.go +++ b/spinifex/gateway/policy/evaluator_test.go @@ -217,9 +217,9 @@ func TestEvaluateAccess_CaseInsensitiveAction(t *testing.T) { } } -// --- matchWildcard tests --- +// --- wildcard matching tests (via matchesAny) --- -func TestMatchWildcard(t *testing.T) { +func TestMatchesAny_Wildcard(t *testing.T) { tests := []struct { pattern string value string @@ -249,15 +249,108 @@ func TestMatchWildcard(t *testing.T) { {"EC2:RunInstances", "ec2:RunInstances", true}, {"ec2:runinstances", "ec2:RunInstances", true}, + // Embedded wildcards (AWS IAM-style — required for iam:PassRole ARN matching). + {"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:role/app-foo", true}, + {"arn:aws:iam::*:role/app-*", "arn:aws:iam::999999999999:role/app-bar", true}, + {"arn:aws:iam::*:role/*", "arn:aws:iam::123456789012:role/anything", true}, + {"arn:aws:iam::123456789012:role/app-*", "arn:aws:iam::123456789012:role/app-foo", true}, + {"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:role/admin-foo", false}, + {"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:user/app-foo", false}, + {"arn:aws:iam::*:role/app-*", "arn:aws:iam::123456789012:role/app-", true}, + {"a*b*c", "axxbyyc", true}, + {"a*b*c", "axxbyy", false}, + // Edge cases {"", "", true}, {"", "something", false}, } for _, tt := range tests { - got := matchWildcard(tt.pattern, tt.value) + got := matchesAny([]string{tt.pattern}, tt.value) if got != tt.want { - t.Errorf("matchWildcard(%q, %q) = %v, want %v", tt.pattern, tt.value, got, tt.want) + t.Errorf("matchesAny([%q], %q) = %v, want %v", tt.pattern, tt.value, got, tt.want) + } + } +} + +// TestEvaluateAccess_PassRoleResourceARN exercises the resource-ARN matching +// path used by iam:PassRole enforcement. The caller's policy will typically +// scope PassRole to a wildcard ARN; the evaluator must match a concrete role +// ARN against it. +func TestEvaluateAccess_PassRoleResourceARN(t *testing.T) { + policies := []handlers_iam.PolicyDocument{ + { + Version: "2012-10-17", + Statement: []handlers_iam.Statement{ + { + Effect: "Allow", + Action: handlers_iam.StringOrArr{"iam:PassRole"}, + Resource: handlers_iam.StringOrArr{"arn:aws:iam::*:role/app-*"}, + }, + }, + }, + } + + tests := []struct { + resource string + want Decision + }{ + {"arn:aws:iam::123456789012:role/app-foo", Allow}, + {"arn:aws:iam::999999999999:role/app-bar", Allow}, + {"arn:aws:iam::123456789012:role/admin-foo", Deny}, + {"arn:aws:iam::123456789012:user/app-foo", Deny}, + } + for _, tt := range tests { + got := EvaluateAccess("alice", "iam:PassRole", tt.resource, policies) + if got != tt.want { + t.Errorf("PassRole on %s: expected %v, got %v", tt.resource, tt.want, got) + } + } +} + +// TestEvaluateAccess_IAMInstanceProfileActionStrings pins the action strings +// for iam:PassRole + the four EC2 instance-profile association actions so a +// rename of any ec2Actions key or checkPolicyResource call site is caught at +// the policy layer. Strings are produced dynamically via policy.IAMAction. +func TestEvaluateAccess_IAMInstanceProfileActionStrings(t *testing.T) { + actions := []string{ + "iam:PassRole", + "ec2:AssociateIamInstanceProfile", + "ec2:DisassociateIamInstanceProfile", + "ec2:ReplaceIamInstanceProfileAssociation", + "ec2:DescribeIamInstanceProfileAssociations", + } + policies := []handlers_iam.PolicyDocument{ + doc("Allow", "*", "*"), + } + for _, a := range actions { + if got := EvaluateAccess("alice", a, "*", policies); got != Allow { + t.Errorf("expected Allow for action %q under wildcard policy, got %v", a, got) + } + } + + scoped := []handlers_iam.PolicyDocument{ + doc("Allow", "ec2:*IamInstanceProfile*", "*"), + doc("Allow", "iam:PassRole", "arn:aws:iam::*:role/*"), + } + scopedTests := []struct { + action string + resource string + want Decision + }{ + {"ec2:AssociateIamInstanceProfile", "*", Allow}, + {"ec2:DisassociateIamInstanceProfile", "*", Allow}, + {"ec2:ReplaceIamInstanceProfileAssociation", "*", Allow}, + {"ec2:DescribeIamInstanceProfileAssociations", "*", Allow}, + {"iam:PassRole", "arn:aws:iam::123456789012:role/app-foo", Allow}, + {"iam:PassRole", "arn:aws:iam::123456789012:user/app-foo", Deny}, + {"ec2:RunInstances", "*", Deny}, + } + for _, tt := range scopedTests { + got := EvaluateAccess("alice", tt.action, tt.resource, scoped) + if got != tt.want { + t.Errorf("scoped policy, action=%s resource=%s: expected %v, got %v", + tt.action, tt.resource, tt.want, got) } } } diff --git a/spinifex/handlers/ec2/instance/iam_profile_association_test.go b/spinifex/handlers/ec2/instance/iam_profile_association_test.go new file mode 100644 index 000000000..4c0f0d5f8 --- /dev/null +++ b/spinifex/handlers/ec2/instance/iam_profile_association_test.go @@ -0,0 +1,272 @@ +package handlers_ec2_instance + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + spxtypes "github.com/mulgadc/spinifex/spinifex/types" + "github.com/mulgadc/spinifex/spinifex/vm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testIAMAccount = "111122223333" + testIAMOther = "999988887777" + testProfileArn1 = "arn:aws:iam::111122223333:instance-profile/app-profile" + testProfileArn2 = "arn:aws:iam::111122223333:instance-profile/other-profile" +) + +func vmWithAccount(id, accountID string) *vm.VM { + return &vm.VM{ID: id, AccountID: accountID, Status: vm.StateRunning} +} + +func TestAssociateIamInstanceProfile_Success(t *testing.T) { + v := vmWithAccount("i-assoc1", testIAMAccount) + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})} + + cmd := spxtypes.EC2InstanceCommand{ + ID: v.ID, + Attributes: spxtypes.EC2CommandAttributes{AssociateIamInstanceProfile: true}, + IamProfileAssociationData: &spxtypes.IamProfileAssociationData{InstanceProfileArn: testProfileArn1}, + } + result, err := svc.AssociateIamInstanceProfile(v, cmd) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, ec2.IamInstanceProfileAssociationStateAssociated, aws.StringValue(result.State)) + require.NotNil(t, result.IamInstanceProfile) + assert.Equal(t, testProfileArn1, aws.StringValue(result.IamInstanceProfile.Arn)) + assert.True(t, strings.HasPrefix(aws.StringValue(result.AssociationId), "iip-assoc-")) + require.NotNil(t, result.Timestamp) + assert.False(t, result.Timestamp.IsZero()) + + assert.Equal(t, testProfileArn1, v.IamInstanceProfileArn) + assert.Equal(t, aws.StringValue(result.AssociationId), v.IamInstanceProfileAssociationId) +} + +func TestAssociateIamInstanceProfile_AlreadyAssociated(t *testing.T) { + v := vmWithAccount("i-assoc2", testIAMAccount) + v.IamInstanceProfileArn = testProfileArn1 + v.IamInstanceProfileAssociationId = "iip-assoc-deadbeef00000001" + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})} + + cmd := spxtypes.EC2InstanceCommand{ + ID: v.ID, + Attributes: spxtypes.EC2CommandAttributes{AssociateIamInstanceProfile: true}, + IamProfileAssociationData: &spxtypes.IamProfileAssociationData{InstanceProfileArn: testProfileArn2}, + } + _, err := svc.AssociateIamInstanceProfile(v, cmd) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorIamInstanceProfileAlreadyAssociated, err.Error()) + + // The original association is preserved when the conflict check fires. + assert.Equal(t, testProfileArn1, v.IamInstanceProfileArn) + assert.Equal(t, "iip-assoc-deadbeef00000001", v.IamInstanceProfileAssociationId) +} + +func TestAssociateIamInstanceProfile_MissingData(t *testing.T) { + v := vmWithAccount("i-assoc3", testIAMAccount) + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})} + + cmd := spxtypes.EC2InstanceCommand{ID: v.ID, Attributes: spxtypes.EC2CommandAttributes{AssociateIamInstanceProfile: true}} + _, err := svc.AssociateIamInstanceProfile(v, cmd) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestDisassociateIamProfileAssociation_Success(t *testing.T) { + const assocID = "iip-assoc-aaaaaaaaaaaaaaa01" + v := vmWithAccount("i-dis1", testIAMAccount) + v.IamInstanceProfileArn = testProfileArn1 + v.IamInstanceProfileAssociationId = assocID + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})} + + result, err := svc.DisassociateIamProfileAssociation( + &ec2.DisassociateIamInstanceProfileInput{AssociationId: aws.String(assocID)}, testIAMAccount) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, v.ID, aws.StringValue(result.InstanceId)) + require.NotNil(t, result.IamInstanceProfile) + assert.Equal(t, testProfileArn1, aws.StringValue(result.IamInstanceProfile.Arn)) + assert.Equal(t, ec2.IamInstanceProfileAssociationStateDisassociating, aws.StringValue(result.State)) + assert.Empty(t, v.IamInstanceProfileArn) + assert.Empty(t, v.IamInstanceProfileAssociationId) +} + +func TestDisassociateIamProfileAssociation_NotFound(t *testing.T) { + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{})} + result, err := svc.DisassociateIamProfileAssociation( + &ec2.DisassociateIamInstanceProfileInput{AssociationId: aws.String("iip-assoc-missing")}, testIAMAccount) + require.NoError(t, err) + assert.Nil(t, result, "no local owner ⇒ nil result so the fan-out collector treats this daemon as NoOp") +} + +func TestDisassociateIamProfileAssociation_MissingID(t *testing.T) { + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{})} + _, err := svc.DisassociateIamProfileAssociation( + &ec2.DisassociateIamInstanceProfileInput{}, testIAMAccount) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestDisassociateIamProfileAssociation_CrossAccountIsNoOp(t *testing.T) { + const assocID = "iip-assoc-bbbbbbbbbbbbbbb02" + v := vmWithAccount("i-dis-cross", testIAMOther) + v.IamInstanceProfileArn = testProfileArn1 + v.IamInstanceProfileAssociationId = assocID + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})} + + // Caller from a different account must not match — even though the + // association ID is correct, the VM belongs to testIAMOther. + result, err := svc.DisassociateIamProfileAssociation( + &ec2.DisassociateIamInstanceProfileInput{AssociationId: aws.String(assocID)}, testIAMAccount) + require.NoError(t, err) + assert.Nil(t, result, "cross-account disassociate must NoOp") + assert.Equal(t, testProfileArn1, v.IamInstanceProfileArn, "other-account VM must be untouched") +} + +func TestReplaceIamProfileAssociation_Success(t *testing.T) { + const oldID = "iip-assoc-ccccccccccccccc03" + v := vmWithAccount("i-rep1", testIAMAccount) + v.IamInstanceProfileArn = testProfileArn1 + v.IamInstanceProfileAssociationId = oldID + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})} + + result, err := svc.ReplaceIamProfileAssociation(&ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String(oldID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Arn: aws.String(testProfileArn2)}, + }, testIAMAccount) + require.NoError(t, err) + require.NotNil(t, result) + assert.NotEqual(t, oldID, aws.StringValue(result.AssociationId), "Replace must generate a fresh association ID") + assert.True(t, strings.HasPrefix(aws.StringValue(result.AssociationId), "iip-assoc-")) + require.NotNil(t, result.IamInstanceProfile) + assert.Equal(t, testProfileArn2, aws.StringValue(result.IamInstanceProfile.Arn)) + + assert.Equal(t, testProfileArn2, v.IamInstanceProfileArn) + assert.Equal(t, aws.StringValue(result.AssociationId), v.IamInstanceProfileAssociationId) +} + +func TestReplaceIamProfileAssociation_StaleID(t *testing.T) { + const currentID = "iip-assoc-ddddddddddddddd04" + v := vmWithAccount("i-rep2", testIAMAccount) + v.IamInstanceProfileArn = testProfileArn1 + v.IamInstanceProfileAssociationId = currentID + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{v.ID: v})} + + result, err := svc.ReplaceIamProfileAssociation(&ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String("iip-assoc-stale-doesnotexist"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Arn: aws.String(testProfileArn2)}, + }, testIAMAccount) + require.NoError(t, err) + assert.Nil(t, result, "stale ID ⇒ nil result so the fan-out collector treats this daemon as NoOp") + assert.Equal(t, testProfileArn1, v.IamInstanceProfileArn, "binding must be unchanged on stale ID") + assert.Equal(t, currentID, v.IamInstanceProfileAssociationId) +} + +func TestReplaceIamProfileAssociation_MissingParams(t *testing.T) { + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{})} + _, err := svc.ReplaceIamProfileAssociation(&ec2.ReplaceIamInstanceProfileAssociationInput{ + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Arn: aws.String(testProfileArn1)}, + }, testIAMAccount) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) + + _, err = svc.ReplaceIamProfileAssociation(&ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String("iip-assoc-x"), + }, testIAMAccount) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +func TestDescribeIamProfileAssociations_FilterAndOrdering(t *testing.T) { + withProfile := vmWithAccount("i-with", testIAMAccount) + withProfile.IamInstanceProfileArn = testProfileArn1 + withProfile.IamInstanceProfileAssociationId = "iip-assoc-with-profile-001" + + noProfile := vmWithAccount("i-without", testIAMAccount) + + otherTenant := vmWithAccount("i-other-tenant", testIAMOther) + otherTenant.IamInstanceProfileArn = testProfileArn2 + otherTenant.IamInstanceProfileAssociationId = "iip-assoc-other-tenant001" + + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{ + withProfile.ID: withProfile, + noProfile.ID: noProfile, + otherTenant.ID: otherTenant, + })} + + out, err := svc.DescribeIamProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{}, testIAMAccount) + require.NoError(t, err) + require.Len(t, out.IamInstanceProfileAssociations, 1) + assert.Equal(t, withProfile.ID, aws.StringValue(out.IamInstanceProfileAssociations[0].InstanceId)) + assert.Equal(t, ec2.IamInstanceProfileAssociationStateAssociated, aws.StringValue(out.IamInstanceProfileAssociations[0].State)) + + // Filter by instance ID excludes everything. + out, err = svc.DescribeIamProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{ + Filters: []*ec2.Filter{{Name: aws.String("instance-id"), Values: []*string{aws.String("i-not-here")}}}, + }, testIAMAccount) + require.NoError(t, err) + assert.Empty(t, out.IamInstanceProfileAssociations) + + // Filter by association ID picks the one we want. + out, err = svc.DescribeIamProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{ + AssociationIds: []*string{aws.String("iip-assoc-with-profile-001")}, + }, testIAMAccount) + require.NoError(t, err) + require.Len(t, out.IamInstanceProfileAssociations, 1) + + // Filter by state excludes when "associated" isn't requested. + out, err = svc.DescribeIamProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{ + Filters: []*ec2.Filter{{Name: aws.String("state"), Values: []*string{aws.String("disassociating")}}}, + }, testIAMAccount) + require.NoError(t, err) + assert.Empty(t, out.IamInstanceProfileAssociations) + + // Cross-tenant caller sees the other tenant's row when their own. + out, err = svc.DescribeIamProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{}, testIAMOther) + require.NoError(t, err) + require.Len(t, out.IamInstanceProfileAssociations, 1) + assert.Equal(t, otherTenant.ID, aws.StringValue(out.IamInstanceProfileAssociations[0].InstanceId)) +} + +func TestDescribeIamProfileAssociations_UnknownFilterIsInvalidParameterValue(t *testing.T) { + svc := &InstanceServiceImpl{vmMgr: mgrWith(map[string]*vm.VM{})} + _, err := svc.DescribeIamProfileAssociations(&ec2.DescribeIamInstanceProfileAssociationsInput{ + Filters: []*ec2.Filter{{Name: aws.String("not-a-real-filter")}}, + }, testIAMAccount) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error()) +} + +func TestStopOrTerminateInstance_AutoDisassociatesProfileOnTerminate(t *testing.T) { + v := vmWithAccount("i-term-prof", testIAMAccount) + v.IamInstanceProfileArn = testProfileArn1 + v.IamInstanceProfileAssociationId = "iip-assoc-clearonterm0001" + v.Status = vm.StateRunning + v.RunInstancesInput = &ec2.RunInstancesInput{InstanceType: aws.String("t3.micro")} + + svc := &InstanceServiceImpl{ + vmMgr: mgrWith(map[string]*vm.VM{v.ID: v}), + } + + cmd := spxtypes.EC2InstanceCommand{ + ID: v.ID, + Attributes: spxtypes.EC2CommandAttributes{TerminateInstance: true}, + } + // Run synchronously up to the lock-protected attribute stamp; the + // goroutine that follows mutates a fake-less Manager which is fine + // (Terminate is a no-op in this minimal fixture but may log). + err := svc.StopOrTerminateInstance(v, cmd) + // In this minimal fixture the goroutine path's Terminate will fail + // without a full vm.Manager fixture; the synchronous return either + // completes or surfaces IncorrectInstanceState depending on + // transition validation. Either is acceptable — we only assert the + // auto-disassociate side effect happened under the lock. + _ = err + assert.Empty(t, v.IamInstanceProfileArn, "terminate must clear profile ARN") + assert.Empty(t, v.IamInstanceProfileAssociationId, "terminate must clear association ID") +} diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index 3f1f7c131..3b32116b4 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -492,6 +492,22 @@ func (s *InstanceServiceImpl) RunInstance(input *ec2.RunInstancesInput) (*vm.VM, ec2Instance.State.SetCode(0) ec2Instance.State.SetName("pending") + // IAM instance profile attached at launch: gateway has already resolved + // the reference to a canonical ARN and enforced iam:PassRole; here we + // just persist it on the VM and generate the association ID. Id is left + // for the gateway to enrich on DescribeInstances since daemons have no + // IAM service access. + if input.IamInstanceProfile != nil { + arn := aws.StringValue(input.IamInstanceProfile.Arn) + if arn != "" { + instance.IamInstanceProfileArn = arn + instance.IamInstanceProfileAssociationId = utils.GenerateResourceID("iip-assoc") + ec2Instance.IamInstanceProfile = &ec2.IamInstanceProfile{ + Arn: aws.String(arn), + } + } + } + // Store EC2 API metadata in VM for DescribeInstances compatibility instance.RunInstancesInput = input instance.Instance = ec2Instance @@ -951,6 +967,19 @@ func (s *InstanceServiceImpl) StopOrTerminateInstance(instance *vm.VM, command s return } v.Attributes = command.Attributes + // Auto-disassociate IAM instance profile on terminate (matches AWS). + // Stop/Start preserves the binding; only terminate clears it. Done + // under the same lock as the state transition so DescribeInstances + // never observes a terminated instance still advertising a profile. + if isTerminate && v.IamInstanceProfileArn != "" { + slog.Info("IAM instance profile auto-disassociated", + "instance_id", v.ID, + "association_id", v.IamInstanceProfileAssociationId, + "profile_arn", v.IamInstanceProfileArn, + "reason", "instance terminated") + v.IamInstanceProfileArn = "" + v.IamInstanceProfileAssociationId = "" + } }) if !ok { slog.Warn("StopOrTerminateInstance: instance no longer in running map", @@ -995,6 +1024,254 @@ func (s *InstanceServiceImpl) StopOrTerminateInstance(instance *vm.VM, command s return nil } +// AssociateIamInstanceProfile attaches an instance profile to a running +// instance. The gateway has already resolved the profile reference to a +// canonical ARN and enforced iam:PassRole; this method validates that the +// instance has no existing profile (returns IamInstanceProfileAlreadyAssociated +// otherwise) and atomically writes the ARN + a freshly generated +// iip-assoc-… ID to vm.VM. AssociationId is never reused — every Associate +// produces a new one. InstanceProfile.Id is left nil; the gateway enriches it +// from its IAMService cache before returning to the caller. +func (s *InstanceServiceImpl) AssociateIamInstanceProfile(instance *vm.VM, command spxtypes.EC2InstanceCommand) (*ec2.IamInstanceProfileAssociation, error) { + if command.IamProfileAssociationData == nil || command.IamProfileAssociationData.InstanceProfileArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + profileArn := command.IamProfileAssociationData.InstanceProfileArn + newID := utils.GenerateResourceID("iip-assoc") + timestamp := time.Now().UTC() + + var alreadyAssociated bool + found, err := s.vmMgr.UpdateAndPersist(instance.ID, func(v *vm.VM) bool { + if v.IamInstanceProfileArn != "" { + alreadyAssociated = true + return false + } + v.IamInstanceProfileArn = profileArn + v.IamInstanceProfileAssociationId = newID + return true + }) + if err != nil { + slog.Error("AssociateIamInstanceProfile: persist failed", "instanceId", instance.ID, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + if alreadyAssociated { + return nil, errors.New(awserrors.ErrorIamInstanceProfileAlreadyAssociated) + } + if !found { + return nil, errors.New(awserrors.ErrorInvalidInstanceIDNotFound) + } + + slog.Info("AssociateIamInstanceProfile: associated", + "instanceId", instance.ID, "associationId", newID, "profileArn", profileArn) + return &ec2.IamInstanceProfileAssociation{ + AssociationId: aws.String(newID), + InstanceId: aws.String(instance.ID), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(profileArn)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateAssociated), + Timestamp: aws.Time(timestamp), + }, nil +} + +// DisassociateIamProfileAssociation finds the VM carrying the requested +// AssociationId in this daemon's vmMgr and clears both profile fields +// atomically. Returns nil when no local VM owns the ID, or when the caller's +// account doesn't own the matching VM, so the gateway fan-out collector can +// advance past this daemon's NoOp response without waiting on the deadline. +// The accountID scope mirrors checkInstanceOwnership semantics applied +// per-instance on ec2.cmd.. +func (s *InstanceServiceImpl) DisassociateIamProfileAssociation(input *ec2.DisassociateIamInstanceProfileInput, accountID string) (*ec2.IamInstanceProfileAssociation, error) { + if input == nil || input.AssociationId == nil || *input.AssociationId == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + associationID := *input.AssociationId + owner, ok := s.findInstanceByAssociationID(associationID, accountID) + if !ok { + return nil, nil + } + + var clearedArn string + timestamp := time.Now().UTC() + _, err := s.vmMgr.UpdateAndPersist(owner, func(v *vm.VM) bool { + // Re-check under the manager lock: another fan-out (or a concurrent + // terminate) could have cleared or replaced the association between + // our snapshot read and this mutation. + if v.IamInstanceProfileAssociationId != associationID { + return false + } + clearedArn = v.IamInstanceProfileArn + v.IamInstanceProfileArn = "" + v.IamInstanceProfileAssociationId = "" + return true + }) + if err != nil { + slog.Error("DisassociateIamProfileAssociation: persist failed", "instanceId", owner, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + if clearedArn == "" { + // Lost a race with terminate / another disassociate. NoOp so the + // gateway collector treats it as "not this daemon's instance". + return nil, nil + } + + slog.Info("DisassociateIamProfileAssociation: disassociated", + "instanceId", owner, "associationId", associationID, "profileArn", clearedArn) + return &ec2.IamInstanceProfileAssociation{ + AssociationId: aws.String(associationID), + InstanceId: aws.String(owner), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(clearedArn)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateDisassociating), + Timestamp: aws.Time(timestamp), + }, nil +} + +// ReplaceIamProfileAssociation finds the VM carrying the requested +// AssociationId and swaps it for a new (ARN, AssociationId) pair atomically. +// The new AssociationId is always freshly generated — AWS never reuses IDs +// across replace. Returns nil when no local VM owns the old ID or when the +// caller's account doesn't own the matching VM. The gateway has already +// resolved IamInstanceProfile.Arn to a canonical ARN before publishing. +func (s *InstanceServiceImpl) ReplaceIamProfileAssociation(input *ec2.ReplaceIamInstanceProfileAssociationInput, accountID string) (*ec2.IamInstanceProfileAssociation, error) { + if input == nil || input.AssociationId == nil || *input.AssociationId == "" || + input.IamInstanceProfile == nil || aws.StringValue(input.IamInstanceProfile.Arn) == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + oldID := *input.AssociationId + newArn := *input.IamInstanceProfile.Arn + + owner, ok := s.findInstanceByAssociationID(oldID, accountID) + if !ok { + return nil, nil + } + + newID := utils.GenerateResourceID("iip-assoc") + timestamp := time.Now().UTC() + var swapped bool + _, err := s.vmMgr.UpdateAndPersist(owner, func(v *vm.VM) bool { + if v.IamInstanceProfileAssociationId != oldID { + return false + } + v.IamInstanceProfileArn = newArn + v.IamInstanceProfileAssociationId = newID + swapped = true + return true + }) + if err != nil { + slog.Error("ReplaceIamProfileAssociation: persist failed", "instanceId", owner, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + if !swapped { + return nil, nil + } + + slog.Info("ReplaceIamProfileAssociation: replaced", + "instanceId", owner, "oldAssociationId", oldID, "newAssociationId", newID, "profileArn", newArn) + return &ec2.IamInstanceProfileAssociation{ + AssociationId: aws.String(newID), + InstanceId: aws.String(owner), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(newArn)}, + State: aws.String(ec2.IamInstanceProfileAssociationStateAssociated), + Timestamp: aws.Time(timestamp), + }, nil +} + +// DescribeIamProfileAssociations walks this daemon's vmMgr and returns every +// live association visible to the caller's account matching the supplied +// filters (empty filters match all). Empty result is a valid response: the +// gateway aggregates per-daemon slices, so missing daemons just yield fewer +// rows. Filter names other than "instance-id" / "state" surface as +// InvalidParameterValue, short-circuiting the fan-out at the gateway. +func (s *InstanceServiceImpl) DescribeIamProfileAssociations(input *ec2.DescribeIamInstanceProfileAssociationsInput, accountID string) (*ec2.DescribeIamInstanceProfileAssociationsOutput, error) { + assocFilter := stringPtrSliceToSet(input.AssociationIds) + instFilter := make(map[string]bool) + stateFilter := make(map[string]bool) + for _, f := range input.Filters { + if f == nil || f.Name == nil { + continue + } + switch *f.Name { + case "instance-id": + for _, v := range f.Values { + if v != nil && *v != "" { + instFilter[*v] = true + } + } + case "state": + for _, v := range f.Values { + if v != nil && *v != "" { + stateFilter[*v] = true + } + } + default: + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + } + + out := &ec2.DescribeIamInstanceProfileAssociationsOutput{} + s.vmMgr.ForEach(func(v *vm.VM) { + if v.IamInstanceProfileArn == "" || v.IamInstanceProfileAssociationId == "" { + return + } + if !IsInstanceVisible(accountID, v.AccountID) { + return + } + if len(assocFilter) > 0 && !assocFilter[v.IamInstanceProfileAssociationId] { + return + } + if len(instFilter) > 0 && !instFilter[v.ID] { + return + } + // v1 only emits "associated" — there are no async transitions, so any + // state filter that excludes "associated" yields an empty result. + const liveState = ec2.IamInstanceProfileAssociationStateAssociated + if len(stateFilter) > 0 && !stateFilter[liveState] { + return + } + out.IamInstanceProfileAssociations = append(out.IamInstanceProfileAssociations, &ec2.IamInstanceProfileAssociation{ + AssociationId: aws.String(v.IamInstanceProfileAssociationId), + InstanceId: aws.String(v.ID), + IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(v.IamInstanceProfileArn)}, + State: aws.String(liveState), + }) + }) + return out, nil +} + +// findInstanceByAssociationID is the lock-protected lookup used by the +// fan-out mutators. It snapshots the matching instance ID under the manager +// lock and returns (id, true) only when the caller's account owns the VM, +// so cross-tenant Disassociate / Replace cannot reach into another account's +// instance. The actual mutation re-validates inside UpdateAndPersist to close +// the read/mutate race window. +func (s *InstanceServiceImpl) findInstanceByAssociationID(associationID, accountID string) (string, bool) { + var owner string + s.vmMgr.ForEach(func(v *vm.VM) { + if owner != "" || v.IamInstanceProfileAssociationId != associationID { + return + } + if !IsInstanceVisible(accountID, v.AccountID) { + return + } + owner = v.ID + }) + if owner == "" { + return "", false + } + return owner, true +} + +func stringPtrSliceToSet(in []*string) map[string]bool { + if len(in) == 0 { + return nil + } + out := make(map[string]bool, len(in)) + for _, s := range in { + if s != nil && *s != "" { + out[*s] = true + } + } + return out +} + func (s *InstanceServiceImpl) GenerateVolumes(input *ec2.RunInstancesInput, instance *vm.VM) ([]VolumeInfo, error) { p := parseVolumeParams(input) if input.ImageId != nil { diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go index 963299123..b7addd222 100644 --- a/spinifex/handlers/ec2/instance/service_impl_test.go +++ b/spinifex/handlers/ec2/instance/service_impl_test.go @@ -110,6 +110,66 @@ func TestRunInstance_Success(t *testing.T) { assert.NotNil(t, ec2Instance.LaunchTime) } +func TestRunInstance_WithIamInstanceProfile(t *testing.T) { + const profileARN = "arn:aws:iam::111122223333:instance-profile/app-profile" + svc := &InstanceServiceImpl{ + instanceTypes: map[string]*ec2.InstanceTypeInfo{"t3.micro": {InstanceType: aws.String("t3.micro")}}, + } + input := &ec2.RunInstancesInput{ + ImageId: aws.String("ami-012345"), + InstanceType: aws.String("t3.micro"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Arn: aws.String(profileARN)}, + } + + instance, ec2Instance, err := svc.RunInstance(input) + require.NoError(t, err) + + assert.Equal(t, profileARN, instance.IamInstanceProfileArn, + "vm.VM must record the canonical ARN supplied by the gateway") + assert.True(t, strings.HasPrefix(instance.IamInstanceProfileAssociationId, "iip-assoc-"), + "daemon must generate an AWS-style association ID at launch") + require.NotNil(t, ec2Instance.IamInstanceProfile) + assert.Equal(t, profileARN, aws.StringValue(ec2Instance.IamInstanceProfile.Arn)) + // Id is deliberately left empty here — daemons have no IAM access, the + // gateway enriches Id from the resolved profile. + assert.Nil(t, ec2Instance.IamInstanceProfile.Id, + "daemon must not emit InstanceProfileID — that is the gateway's responsibility") +} + +func TestRunInstance_IamInstanceProfileEmptyARNIgnored(t *testing.T) { + // AWS SDKs sometimes round-trip an IamInstanceProfile with both fields + // empty. Treat that as "no profile attached" rather than persisting a + // half-baked binding. + svc := &InstanceServiceImpl{ + instanceTypes: map[string]*ec2.InstanceTypeInfo{"t3.micro": {InstanceType: aws.String("t3.micro")}}, + } + input := &ec2.RunInstancesInput{ + ImageId: aws.String("ami-012345"), + InstanceType: aws.String("t3.micro"), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Arn: aws.String("")}, + } + instance, ec2Instance, err := svc.RunInstance(input) + require.NoError(t, err) + assert.Empty(t, instance.IamInstanceProfileArn) + assert.Empty(t, instance.IamInstanceProfileAssociationId) + assert.Nil(t, ec2Instance.IamInstanceProfile) +} + +func TestRunInstance_NoIamInstanceProfile(t *testing.T) { + svc := &InstanceServiceImpl{ + instanceTypes: map[string]*ec2.InstanceTypeInfo{"t3.micro": {InstanceType: aws.String("t3.micro")}}, + } + input := &ec2.RunInstancesInput{ + ImageId: aws.String("ami-012345"), + InstanceType: aws.String("t3.micro"), + } + instance, ec2Instance, err := svc.RunInstance(input) + require.NoError(t, err) + assert.Empty(t, instance.IamInstanceProfileArn) + assert.Empty(t, instance.IamInstanceProfileAssociationId) + assert.Nil(t, ec2Instance.IamInstanceProfile) +} + func TestRunInstance_NoKeyName(t *testing.T) { instanceTypes := map[string]*ec2.InstanceTypeInfo{ "t3.micro": {InstanceType: aws.String("t3.micro")}, diff --git a/spinifex/handlers/iam/instance_profiles.go b/spinifex/handlers/iam/instance_profiles.go index 400725130..a4b86ead1 100644 --- a/spinifex/handlers/iam/instance_profiles.go +++ b/spinifex/handlers/iam/instance_profiles.go @@ -251,6 +251,60 @@ func (s *IAMServiceImpl) ListInstanceProfilesForRole(accountID string, input *ia }, nil } +// ResolveInstanceProfile dereferences an instance-profile reference (the +// {Name, Arn} shape from RunInstancesInput.IamInstanceProfile and related +// EC2 association ops) to the canonical InstanceProfile record. Accepts +// either the friendly name or the full ARN. +// +// When given an ARN, the embedded account ID must match the caller — this +// is a defence-in-depth check; the gateway also rejects cross-account +// references with AccessDenied before reaching this helper. +func (s *IAMServiceImpl) ResolveInstanceProfile(accountID, nameOrARN string) (*InstanceProfile, error) { + if nameOrARN == "" { + return nil, errors.New(awserrors.ErrorIAMInvalidInput) + } + + if !strings.HasPrefix(nameOrARN, "arn:") { + return s.getInstanceProfile(accountID, nameOrARN) + } + + profileAccountID, profileName, err := parseInstanceProfileARN(nameOrARN) + if err != nil { + return nil, err + } + if profileAccountID != accountID { + return nil, errors.New(awserrors.ErrorAccessDenied) + } + return s.getInstanceProfile(accountID, profileName) +} + +// parseInstanceProfileARN extracts the account ID and profile name from an +// IAM instance-profile ARN of the form +// `arn:aws:iam:::instance-profile//` (path optional). +// Returns InvalidIamInstanceProfileArn.Malformed for any deviation. +func parseInstanceProfileARN(arn string) (accountID, name string, err error) { + parts := strings.SplitN(arn, ":", 6) + if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "iam" || parts[3] != "" { + return "", "", errors.New(awserrors.ErrorInvalidIamInstanceProfileArnMalformed) + } + resource := parts[5] + const prefix = "instance-profile/" + if !strings.HasPrefix(resource, prefix) { + return "", "", errors.New(awserrors.ErrorInvalidIamInstanceProfileArnMalformed) + } + pathAndName := resource[len(prefix):] + slash := strings.LastIndex(pathAndName, "/") + if slash == -1 { + name = pathAndName + } else { + name = pathAndName[slash+1:] + } + if name == "" { + return "", "", errors.New(awserrors.ErrorInvalidIamInstanceProfileArnMalformed) + } + return parts[4], name, nil +} + func (s *IAMServiceImpl) getInstanceProfile(accountID, profileName string) (*InstanceProfile, error) { entry, err := s.instanceProfilesBucket.Get(accountID + "." + profileName) if err != nil { diff --git a/spinifex/handlers/iam/instance_profiles_test.go b/spinifex/handlers/iam/instance_profiles_test.go index af7a52dd2..2e95c1908 100644 --- a/spinifex/handlers/iam/instance_profiles_test.go +++ b/spinifex/handlers/iam/instance_profiles_test.go @@ -407,6 +407,97 @@ func TestListInstanceProfilesForRole_None(t *testing.T) { // Account Scoping // ============================================================================ +// ============================================================================ +// ResolveInstanceProfile +// ============================================================================ + +func TestResolveInstanceProfile_ByName(t *testing.T) { + svc := setupTestIAMService(t) + created := createTestInstanceProfile(t, svc, "by-name-profile") + + profile, err := svc.ResolveInstanceProfile(testAccountID, "by-name-profile") + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, "by-name-profile", profile.InstanceProfileName) + assert.Equal(t, *created.Arn, profile.ARN) +} + +func TestResolveInstanceProfile_ByARN(t *testing.T) { + svc := setupTestIAMService(t) + created := createTestInstanceProfile(t, svc, "by-arn-profile") + + profile, err := svc.ResolveInstanceProfile(testAccountID, *created.Arn) + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, "by-arn-profile", profile.InstanceProfileName) +} + +func TestResolveInstanceProfile_ByARNWithPath(t *testing.T) { + svc := setupTestIAMService(t) + out, err := svc.CreateInstanceProfile(testAccountID, &iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String("nested-profile"), + Path: aws.String("/service-profiles/team-a/"), + }) + require.NoError(t, err) + + profile, err := svc.ResolveInstanceProfile(testAccountID, *out.InstanceProfile.Arn) + require.NoError(t, err) + assert.Equal(t, "nested-profile", profile.InstanceProfileName) + assert.Equal(t, "/service-profiles/team-a/", profile.Path) +} + +func TestResolveInstanceProfile_CrossAccountARNRejected(t *testing.T) { + svc := setupTestIAMService(t) + createTestInstanceProfile(t, svc, "shadow-profile") + + otherAccountARN := "arn:aws:iam::999999999999:instance-profile/shadow-profile" + _, err := svc.ResolveInstanceProfile(testAccountID, otherAccountARN) + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorAccessDenied) +} + +func TestResolveInstanceProfile_NameNotFound(t *testing.T) { + svc := setupTestIAMService(t) + + _, err := svc.ResolveInstanceProfile(testAccountID, "ghost-profile") + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +func TestResolveInstanceProfile_ARNNotFound(t *testing.T) { + svc := setupTestIAMService(t) + + arn := "arn:aws:iam::" + testAccountID + ":instance-profile/ghost" + _, err := svc.ResolveInstanceProfile(testAccountID, arn) + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +func TestResolveInstanceProfile_MalformedARN(t *testing.T) { + svc := setupTestIAMService(t) + + cases := []string{ + "arn:aws:iam::" + testAccountID + ":role/not-a-profile", + "arn:aws:iam::" + testAccountID + ":instance-profile/", + "arn:aws:s3:::bucket/key", + "arn:bogus", + } + for _, arn := range cases { + _, err := svc.ResolveInstanceProfile(testAccountID, arn) + require.Error(t, err, "expected error for %q", arn) + assert.Contains(t, err.Error(), awserrors.ErrorInvalidIamInstanceProfileArnMalformed, + "expected malformed-ARN error for %q", arn) + } +} + +func TestResolveInstanceProfile_EmptyReference(t *testing.T) { + svc := setupTestIAMService(t) + + _, err := svc.ResolveInstanceProfile(testAccountID, "") + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) +} + func TestInstanceProfiles_AccountScoping(t *testing.T) { svc := setupTestIAMService(t) diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go index 00e9069b3..257e7a0e7 100644 --- a/spinifex/handlers/iam/service.go +++ b/spinifex/handlers/iam/service.go @@ -54,6 +54,12 @@ type IAMService interface { AddRoleToInstanceProfile(accountID string, input *iam.AddRoleToInstanceProfileInput) (*iam.AddRoleToInstanceProfileOutput, error) RemoveRoleFromInstanceProfile(accountID string, input *iam.RemoveRoleFromInstanceProfileInput) (*iam.RemoveRoleFromInstanceProfileOutput, error) + // ResolveInstanceProfile dereferences a RunInstancesInput.IamInstanceProfile + // reference (name or ARN) to the canonical InstanceProfile record. Used by + // EC2 paths only. Cross-account ARNs are rejected as a defence-in-depth + // check; the gateway also enforces this and returns AccessDenied. + ResolveInstanceProfile(accountID, nameOrARN string) (*InstanceProfile, error) + // Policy evaluation (internal — used by gateway enforcement) GetUserPolicies(accountID, userName string) ([]PolicyDocument, error) diff --git a/spinifex/types/ec2.go b/spinifex/types/ec2.go index 417191f80..bb9b7f0f7 100644 --- a/spinifex/types/ec2.go +++ b/spinifex/types/ec2.go @@ -4,20 +4,22 @@ package types // (stop, terminate, start, attach-volume, detach-volume). // It replaces direct use of qmp.Command on the gateway→daemon boundary. type EC2InstanceCommand struct { - ID string `json:"id"` - Attributes EC2CommandAttributes `json:"attributes"` - AttachVolumeData *AttachVolumeData `json:"attach_volume_data,omitempty"` - DetachVolumeData *DetachVolumeData `json:"detach_volume_data,omitempty"` + ID string `json:"id"` + Attributes EC2CommandAttributes `json:"attributes"` + AttachVolumeData *AttachVolumeData `json:"attach_volume_data,omitempty"` + DetachVolumeData *DetachVolumeData `json:"detach_volume_data,omitempty"` + IamProfileAssociationData *IamProfileAssociationData `json:"iam_profile_association_data,omitempty"` } // EC2CommandAttributes indicates which action the daemon should perform. type EC2CommandAttributes struct { - StopInstance bool `json:"stop_instance"` - TerminateInstance bool `json:"delete_instance"` - StartInstance bool `json:"start_instance"` - AttachVolume bool `json:"attach_volume"` - DetachVolume bool `json:"detach_volume"` - RebootInstance bool `json:"reboot_instance"` + StopInstance bool `json:"stop_instance"` + TerminateInstance bool `json:"delete_instance"` + StartInstance bool `json:"start_instance"` + AttachVolume bool `json:"attach_volume"` + DetachVolume bool `json:"detach_volume"` + RebootInstance bool `json:"reboot_instance"` + AssociateIamInstanceProfile bool `json:"associate_iam_instance_profile,omitempty"` } // AttachVolumeData carries parameters for an attach-volume command. @@ -32,3 +34,12 @@ type DetachVolumeData struct { Device string `json:"device,omitempty"` Force bool `json:"force,omitempty"` } + +// IamProfileAssociationData carries parameters for an associate-iam-instance-profile +// command. The gateway has already resolved the profile reference to a canonical +// ARN and enforced iam:PassRole — the daemon only needs the ARN to persist on +// vm.VM. The new AssociationId is generated daemon-side under the vmMgr lock, +// so it does not appear in the request payload. +type IamProfileAssociationData struct { + InstanceProfileArn string `json:"instance_profile_arn"` +} diff --git a/spinifex/vm/vm.go b/spinifex/vm/vm.go index efa34b16d..208f92637 100644 --- a/spinifex/vm/vm.go +++ b/spinifex/vm/vm.go @@ -119,6 +119,17 @@ type VM struct { // filters out tagged VMs from customer-facing listings. ManagedBy string `json:"managed_by,omitempty"` + // IamInstanceProfileArn is the ARN of the instance profile attached at + // launch or via AssociateIamInstanceProfile. Empty when no profile is + // attached. Auto-cleared on TerminateInstances; preserved across stop/start. + IamInstanceProfileArn string `json:"iam_instance_profile_arn,omitempty"` + + // IamInstanceProfileAssociationId is the synthesized AWS-style association + // ID (iip-assoc-...) used by DescribeIamInstanceProfileAssociations and + // Disassociate/Replace. Empty when no profile is attached. Regenerated on + // every Associate/Replace — never reused. + IamInstanceProfileAssociationId string `json:"iam_instance_profile_association_id,omitempty"` + // DirectBoot signals that this VM was configured with a pre-built // vm.Config by the launcher (microvm direct-boot path). When true, // startQEMU skips buildBaseVMConfig and uses the existing Config diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh index 0c65000e4..b8731f0c7 100755 --- a/tests/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -1914,6 +1914,315 @@ fi echo " Policies cleaned up" echo "IAM Phase 7 passed" + +# IAM Phase 8: Roles & Instance Profiles (CRUD + lifecycle guards) +# Runs against the admin root profile after Phase 7 cleanup, so the only +# pre-existing policy is bootstrap AdministratorAccess. +echo "" +echo "IAM Phase 8: Roles & Instance Profiles" + +ROLE_NAME="app-role" +PROFILE_NAME="app-profile" +OTHER_PROFILE_NAME="other-profile" +ADMIN_POLICY_ARN="arn:aws:iam::${ADMIN_ACCOUNT}:policy/AdministratorAccess" +ROLE_ARN="arn:aws:iam::${ADMIN_ACCOUNT}:role/${ROLE_NAME}" + +TRUST_POLICY_DOC='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' +TRUST_POLICY_V2_DOC='{"Version":"2012-10-17","Statement":[{"Sid":"v2","Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' + +# CreateRole — valid trust policy +echo " Creating role ${ROLE_NAME}..." +ROLE_OUT=$(aws iam create-role --role-name "${ROLE_NAME}" \ + --assume-role-policy-document "${TRUST_POLICY_DOC}" \ + --description "E2E test role") +echo "${ROLE_OUT}" | jq -e ".Role.RoleName == \"${ROLE_NAME}\"" > /dev/null +echo "${ROLE_OUT}" | jq -e ".Role.Arn == \"${ROLE_ARN}\"" > /dev/null +echo " Created role: ${ROLE_ARN}" + +# CreateRole — duplicate +echo " Creating duplicate role (expect EntityAlreadyExists)..." +expect_error "EntityAlreadyExists" aws iam create-role \ + --role-name "${ROLE_NAME}" --assume-role-policy-document "${TRUST_POLICY_DOC}" + +# CreateRole — malformed trust policy +echo " Creating role with malformed trust policy (expect MalformedPolicyDocument)..." +expect_error "MalformedPolicyDocument" aws iam create-role \ + --role-name "bad-role" --assume-role-policy-document '{not valid json' + +# GetRole +echo " Getting role ${ROLE_NAME}..." +aws iam get-role --role-name "${ROLE_NAME}" \ + | jq -e ".Role.RoleName == \"${ROLE_NAME}\"" > /dev/null + +# GetRole — missing +expect_error "NoSuchEntity" aws iam get-role --role-name "ghost-role" + +# ListRoles (>= 1) +ROLE_COUNT=$(aws iam list-roles | jq '.Roles | length') +if [ "${ROLE_COUNT}" -lt 1 ]; then + echo " ERROR: Expected >= 1 role, got ${ROLE_COUNT}" + exit 1 +fi + +# ListRoles with path-prefix +aws iam list-roles --path-prefix / | jq -e '.Roles | length >= 1' > /dev/null + +# UpdateRole — description + max-session-duration +echo " Updating role description and max-session-duration..." +aws iam update-role --role-name "${ROLE_NAME}" \ + --description "updated" --max-session-duration 7200 +UPDATED_DESC=$(aws iam get-role --role-name "${ROLE_NAME}" | jq -r '.Role.Description') +if [ "${UPDATED_DESC}" != "updated" ]; then + echo " ERROR: Expected description 'updated', got '${UPDATED_DESC}'" + exit 1 +fi + +# UpdateRole — out-of-range MaxSessionDuration is enforced by the AWS CLI +# client-side (min 3600) before the request leaves the host. Server-side +# range validation (900-43200) is exercised in handlers/iam/roles_test.go. + +# UpdateAssumeRolePolicy +echo " Updating trust policy..." +aws iam update-assume-role-policy --role-name "${ROLE_NAME}" \ + --policy-document "${TRUST_POLICY_V2_DOC}" + +# AttachRolePolicy + ListAttachedRolePolicies +echo " Attaching AdministratorAccess to ${ROLE_NAME}..." +aws iam attach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}" +ATTACHED=$(aws iam list-attached-role-policies --role-name "${ROLE_NAME}" \ + | jq '.AttachedPolicies | length') +if [ "${ATTACHED}" -ne 1 ]; then + echo " ERROR: Expected 1 attached policy, got ${ATTACHED}" + exit 1 +fi + +# Idempotent re-attach +aws iam attach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}" +RE_ATTACHED=$(aws iam list-attached-role-policies --role-name "${ROLE_NAME}" \ + | jq '.AttachedPolicies | length') +if [ "${RE_ATTACHED}" -ne 1 ]; then + echo " ERROR: Idempotent re-attach grew count to ${RE_ATTACHED}" + exit 1 +fi + +# CreateInstanceProfile — two profiles so Replace has a target in Phase 9 +echo " Creating instance profile ${PROFILE_NAME}..." +aws iam create-instance-profile --instance-profile-name "${PROFILE_NAME}" \ + | jq -e ".InstanceProfile.InstanceProfileName == \"${PROFILE_NAME}\"" > /dev/null + +echo " Creating instance profile ${OTHER_PROFILE_NAME}..." +aws iam create-instance-profile --instance-profile-name "${OTHER_PROFILE_NAME}" > /dev/null + +# CreateInstanceProfile — duplicate +expect_error "EntityAlreadyExists" aws iam create-instance-profile \ + --instance-profile-name "${PROFILE_NAME}" + +# GetInstanceProfile / ListInstanceProfiles +aws iam get-instance-profile --instance-profile-name "${PROFILE_NAME}" \ + | jq -e ".InstanceProfile.InstanceProfileName == \"${PROFILE_NAME}\"" > /dev/null +aws iam list-instance-profiles | jq -e '.InstanceProfiles | length >= 2' > /dev/null + +# AddRoleToInstanceProfile +echo " Adding ${ROLE_NAME} to ${PROFILE_NAME}..." +aws iam add-role-to-instance-profile \ + --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" + +# AddRoleToInstanceProfile — second role rejected (one-role limit) +expect_error "LimitExceeded" aws iam add-role-to-instance-profile \ + --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" + +# ListInstanceProfilesForRole — reverse lookup +PROFILES_FOR_ROLE=$(aws iam list-instance-profiles-for-role --role-name "${ROLE_NAME}" \ + | jq '.InstanceProfiles | length') +if [ "${PROFILES_FOR_ROLE}" -ne 1 ]; then + echo " ERROR: Expected 1 profile for role, got ${PROFILES_FOR_ROLE}" + exit 1 +fi + +# DeleteRole — refused while attached policies + still in profile +expect_error "DeleteConflict" aws iam delete-role --role-name "${ROLE_NAME}" + +# DeleteInstanceProfile — refused while role attached +expect_error "DeleteConflict" aws iam delete-instance-profile \ + --instance-profile-name "${PROFILE_NAME}" + +# Add role to OTHER_PROFILE_NAME so Phase 9 has a Replace target +aws iam add-role-to-instance-profile \ + --instance-profile-name "${OTHER_PROFILE_NAME}" --role-name "${ROLE_NAME}" + +echo "IAM Phase 8 passed" + +# IAM Phase 9: EC2 IAM Instance Profile Association +# Uses the long-lived +# singleton $INSTANCE_ID for the Associate/Disassociate/Replace lifecycle, +# then a dedicated short-lived VM to exercise auto-disassociate on terminate. +echo "" +echo "IAM Phase 9: EC2 IAM Instance Profile Association" + +# Sanity: singleton must still be running. +SINGLETON_STATE=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ + --query 'Reservations[0].Instances[0].State.Name' --output text) +if [ "${SINGLETON_STATE}" != "running" ]; then + echo " ERROR: Expected singleton ${INSTANCE_ID} running, got ${SINGLETON_STATE}" + exit 1 +fi + +# Associate-iam-instance-profile (post-launch) +echo " Associating ${PROFILE_NAME} -> ${INSTANCE_ID}..." +ASSOC_OUT=$(aws ec2 associate-iam-instance-profile \ + --instance-id "${INSTANCE_ID}" \ + --iam-instance-profile "Name=${PROFILE_NAME}") +ASSOC_ID=$(echo "${ASSOC_OUT}" | jq -r '.IamInstanceProfileAssociation.AssociationId') +if [[ ! "${ASSOC_ID}" =~ ^iip-assoc- ]]; then + echo " ERROR: AssociationId malformed: ${ASSOC_ID}" + exit 1 +fi +echo " Got ${ASSOC_ID}" + +# Associate again — already associated +expect_error "IamInstanceProfileAlreadyAssociated" aws ec2 associate-iam-instance-profile \ + --instance-id "${INSTANCE_ID}" --iam-instance-profile "Name=${PROFILE_NAME}" + +# DescribeInstances surfaces IamInstanceProfile +PROFILE_ARN_FROM_DESC=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ + --query 'Reservations[0].Instances[0].IamInstanceProfile.Arn' --output text) +if [[ "${PROFILE_ARN_FROM_DESC}" != *":instance-profile/${PROFILE_NAME}" ]]; then + echo " ERROR: describe-instances IamInstanceProfile.Arn = '${PROFILE_ARN_FROM_DESC}'" + exit 1 +fi + +# DescribeIamInstanceProfileAssociations +DESC_OUT=$(aws ec2 describe-iam-instance-profile-associations \ + --association-ids "${ASSOC_ID}") +echo "${DESC_OUT}" | jq -e ".IamInstanceProfileAssociations[0].InstanceId == \"${INSTANCE_ID}\"" > /dev/null + +# DeleteInstanceProfile while in-use by a live instance — refused +expect_error "DeleteConflict" aws iam delete-instance-profile \ + --instance-profile-name "${PROFILE_NAME}" + +# Replace association → other-profile (yields a fresh association id) +echo " Replacing association -> ${OTHER_PROFILE_NAME}..." +REPLACE_OUT=$(aws ec2 replace-iam-instance-profile-association \ + --association-id "${ASSOC_ID}" \ + --iam-instance-profile "Name=${OTHER_PROFILE_NAME}") +NEW_ASSOC_ID=$(echo "${REPLACE_OUT}" | jq -r '.IamInstanceProfileAssociation.AssociationId') +if [ "${NEW_ASSOC_ID}" = "${ASSOC_ID}" ] || [[ ! "${NEW_ASSOC_ID}" =~ ^iip-assoc- ]]; then + echo " ERROR: Replace must mint a new AssociationId (old=${ASSOC_ID}, new=${NEW_ASSOC_ID})" + exit 1 +fi + +# Replace with stale id +expect_error "NoSuchAssociation" aws ec2 replace-iam-instance-profile-association \ + --association-id "${ASSOC_ID}" --iam-instance-profile "Name=${PROFILE_NAME}" + +# Disassociate via the current (new) id +echo " Disassociating ${NEW_ASSOC_ID}..." +aws ec2 disassociate-iam-instance-profile --association-id "${NEW_ASSOC_ID}" > /dev/null + +# Disassociate again with stale id +expect_error "NoSuchAssociation" aws ec2 disassociate-iam-instance-profile \ + --association-id "${NEW_ASSOC_ID}" + +# DescribeInstances no longer carries the profile after Disassociate. +# AWS CLI --output text prints "None" when the field is null. +POST_PROFILE=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ + --query 'Reservations[0].Instances[0].IamInstanceProfile.Arn' --output text) +if [ "${POST_PROFILE}" != "None" ]; then + echo " ERROR: IamInstanceProfile.Arn still set after disassociate: '${POST_PROFILE}'" + exit 1 +fi + +# RunInstances --iam-instance-profile at launch + auto-disassociate on terminate. +# Reuse the AMI/type/key/subnet/SG the singleton was launched against in one +# describe-instances round trip. +EPH_DESC=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ + --query 'Reservations[0].Instances[0].[ImageId,InstanceType,KeyName,SubnetId,SecurityGroups[0].GroupId]' \ + --output text) +EPH_AMI=$(echo "${EPH_DESC}" | awk '{print $1}') +EPH_TYPE=$(echo "${EPH_DESC}" | awk '{print $2}') +EPH_KEY=$(echo "${EPH_DESC}" | awk '{print $3}') +EPH_SUBNET=$(echo "${EPH_DESC}" | awk '{print $4}') +EPH_SG=$(echo "${EPH_DESC}" | awk '{print $5}') + +echo " Launching ephemeral VM with --iam-instance-profile ${PROFILE_NAME}..." +EPH_RUN=$(aws ec2 run-instances \ + --image-id "${EPH_AMI}" --instance-type "${EPH_TYPE}" \ + --key-name "${EPH_KEY}" --subnet-id "${EPH_SUBNET}" \ + --security-group-ids "${EPH_SG}" \ + --iam-instance-profile "Name=${PROFILE_NAME}" \ + --count 1) +EPH_ID=$(echo "${EPH_RUN}" | jq -r '.Instances[0].InstanceId') +if [[ ! "${EPH_ID}" =~ ^i- ]]; then + echo " ERROR: Failed to launch ephemeral VM (got '${EPH_ID}')" + exit 1 +fi +echo " Ephemeral VM: ${EPH_ID}" + +# Poll until running so DescribeInstances surfaces a stable profile field. +for i in $(seq 1 60); do + EPH_STATE=$(aws ec2 describe-instances --instance-ids "${EPH_ID}" \ + --query 'Reservations[0].Instances[0].State.Name' --output text) + if [ "${EPH_STATE}" = "running" ]; then break; fi + sleep 2 +done +if [ "${EPH_STATE}" != "running" ]; then + echo " ERROR: Ephemeral VM never reached running (last=${EPH_STATE})" + aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null || true + exit 1 +fi + +EPH_ASSOC_ID=$(aws ec2 describe-iam-instance-profile-associations \ + --filters "Name=instance-id,Values=${EPH_ID}" \ + --query 'IamInstanceProfileAssociations[0].AssociationId' --output text) +if [[ ! "${EPH_ASSOC_ID}" =~ ^iip-assoc- ]]; then + echo " ERROR: ephemeral VM has no association (got '${EPH_ASSOC_ID}')" + aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null || true + exit 1 +fi + +# Terminate → auto-disassociate +echo " Terminating ${EPH_ID} (expect auto-disassociate)..." +aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null +for i in $(seq 1 60); do + EPH_STATE=$(aws ec2 describe-instances --instance-ids "${EPH_ID}" \ + --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "gone") + if [ "${EPH_STATE}" = "terminated" ] || [ "${EPH_STATE}" = "gone" ]; then break; fi + sleep 2 +done + +# Auto-disassociate is flushed from the daemon's terminate handler — wait +# until the binding is cleared (up to 30s) so the NoSuchAssociation assertion +# is deterministic. Matches the Eventually(30s, 1s) loop in the Go port. +AUTO_DISASSOC_OK=0 +for i in $(seq 1 30); do + if aws ec2 disassociate-iam-instance-profile --association-id "${EPH_ASSOC_ID}" 2>&1 \ + | grep -q "NoSuchAssociation"; then + AUTO_DISASSOC_OK=1 + break + fi + sleep 1 +done +if [ "${AUTO_DISASSOC_OK}" -ne 1 ]; then + echo " ERROR: ${EPH_ASSOC_ID} never auto-disassociated after terminate" + exit 1 +fi +echo " Auto-disassociated ${EPH_ASSOC_ID}" + +# Cleanup: profiles + role + policy. Each step is best-effort so a partial +# failure further up doesn't cascade — matches IAM Phase 7 style. +aws iam remove-role-from-instance-profile \ + --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" || true +aws iam remove-role-from-instance-profile \ + --instance-profile-name "${OTHER_PROFILE_NAME}" --role-name "${ROLE_NAME}" || true +aws iam delete-instance-profile --instance-profile-name "${PROFILE_NAME}" || true +aws iam delete-instance-profile --instance-profile-name "${OTHER_PROFILE_NAME}" || true +aws iam detach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}" || true +aws iam delete-role --role-name "${ROLE_NAME}" || true + +# Verify role gone +expect_error "NoSuchEntity" aws iam get-role --role-name "${ROLE_NAME}" + +echo "IAM Phase 9 passed" echo "" echo "IAM E2E Tests Completed Successfully" diff --git a/tests/e2e/single/iam_roles_test.go b/tests/e2e/single/iam_roles_test.go new file mode 100644 index 000000000..da5eb7882 --- /dev/null +++ b/tests/e2e/single/iam_roles_test.go @@ -0,0 +1,314 @@ +//go:build e2e + +package single + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/require" +) + +// Cross-phase identifiers for IAM Role / InstanceProfile tests. The names +// mirror the bash driver (run-e2e.sh IAM Phase 8 / 9) so a future +// side-by-side diff stays greppable. +const ( + iamRoleAppName = "app-role" + iamProfileAppName = "app-profile" + iamProfileOtherName = "other-profile" + iamPolicyAdministrator = "AdministratorAccess" + iamTrustPolicyEC2Standard = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}` + iamTrustPolicyEC2V2 = `{"Version":"2012-10-17","Statement":[{"Sid":"v2","Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}` +) + +// runIAMRolesAndProfiles ports run-e2e.sh IAM Phase 8: Role + InstanceProfile +// CRUD, attach-role-policy, add-role-to-instance-profile, the one-role and +// delete-while-referenced guards, and full teardown. Runs sequentially under +// TestIAMRolesAndProfiles so cleanup never collides with TestIAMInstanceProfileAssociation +// which recreates the same names. +func runIAMRolesAndProfiles(t *testing.T, fix *Fixture) { + harness.Phase(t, "Single — IAM Roles & Instance Profiles") + adminAccount := iamEnsureAdminAccountID(t, fix) + roleARN := iamRoleARN(adminAccount, iamRoleAppName) + adminPolicyARN := iamPolicyARN(adminAccount, iamPolicyAdministrator) + + // Defensive sweep — previous failed run may have left fragments. Order + // matters: detach role from profile before delete-instance-profile, + // detach policy from role before delete-role. + iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName, + []string{iamProfileAppName, iamProfileOtherName}, adminPolicyARN) + fix.Harness.RegisterCleanup(func() { + iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName, + []string{iamProfileAppName, iamProfileOtherName}, adminPolicyARN) + }) + + // CreateRole — happy path with non-default description. + harness.Step(t, "create-role %q", iamRoleAppName) + createOut, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ + RoleName: aws.String(iamRoleAppName), + AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard), + Description: aws.String("E2E test role"), + }) + require.NoError(t, err, "create-role") + require.Equal(t, iamRoleAppName, aws.StringValue(createOut.Role.RoleName)) + require.Equal(t, roleARN, aws.StringValue(createOut.Role.Arn), + "role ARN must follow arn:aws:iam:::role/") + harness.Detail(t, "role", iamRoleAppName, "arn", roleARN) + + // Duplicate. + harness.Step(t, "create-role duplicate (expect EntityAlreadyExists)") + harness.ExpectError(t, "EntityAlreadyExists", func() error { + _, e := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ + RoleName: aws.String(iamRoleAppName), + AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard), + }) + return e + }) + + // Malformed trust policy → MalformedPolicyDocument. + harness.Step(t, "create-role malformed trust policy (expect MalformedPolicyDocument)") + harness.ExpectError(t, "MalformedPolicyDocument", func() error { + _, e := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ + RoleName: aws.String("bad-role"), + AssumeRolePolicyDocument: aws.String(`{not valid json`), + }) + return e + }) + + // GetRole + NoSuchEntity probe. + harness.Step(t, "get-role %q", iamRoleAppName) + got, err := fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String(iamRoleAppName)}) + require.NoError(t, err, "get-role") + require.Equal(t, iamRoleAppName, aws.StringValue(got.Role.RoleName)) + + harness.Step(t, "get-role nonexistent (expect NoSuchEntity)") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String("ghost-role")}) + return e + }) + + // ListRoles + PathPrefix scan. + harness.Step(t, "list-roles (>= 1)") + listed, err := fix.AWS.IAM.ListRoles(&iam.ListRolesInput{}) + require.NoError(t, err, "list-roles") + require.GreaterOrEqual(t, len(listed.Roles), 1, + "expected >= 1 role, got %d", len(listed.Roles)) + + harness.Step(t, "list-roles --path-prefix /") + pp, err := fix.AWS.IAM.ListRoles(&iam.ListRolesInput{PathPrefix: aws.String("/")}) + require.NoError(t, err, "list-roles --path-prefix /") + require.GreaterOrEqual(t, len(pp.Roles), 1, "path-prefix / must surface roles at /") + + // UpdateRole — description + MaxSessionDuration round-trip + range guard. + harness.Step(t, "update-role description + max-session-duration=7200") + _, err = fix.AWS.IAM.UpdateRole(&iam.UpdateRoleInput{ + RoleName: aws.String(iamRoleAppName), + Description: aws.String("updated"), + MaxSessionDuration: aws.Int64(7200), + }) + require.NoError(t, err, "update-role") + got, err = fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String(iamRoleAppName)}) + require.NoError(t, err, "get-role after update") + require.Equal(t, "updated", aws.StringValue(got.Role.Description)) + require.Equal(t, int64(7200), aws.Int64Value(got.Role.MaxSessionDuration)) + + // Server-side MaxSessionDuration range guard (900-43200) isn't reachable + // via the AWS SDK: UpdateRoleInput carries min:"3600" so SDK.Validate() + // blocks values < 3600 before dispatch, and botocore enforces the upper + // bound the same way. The gateway guard is covered by + // handlers/iam/roles_test.go TestCreateRole_MaxSessionDuration_TooSmall. + + // UpdateAssumeRolePolicy — swap document, no enforcement yet. + harness.Step(t, "update-assume-role-policy") + _, err = fix.AWS.IAM.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{ + RoleName: aws.String(iamRoleAppName), + PolicyDocument: aws.String(iamTrustPolicyEC2V2), + }) + require.NoError(t, err, "update-assume-role-policy") + + // AttachRolePolicy — idempotent re-attach must not grow the count. + harness.Step(t, "attach-role-policy %s <- %s", iamRoleAppName, iamPolicyAdministrator) + _, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ + RoleName: aws.String(iamRoleAppName), + PolicyArn: aws.String(adminPolicyARN), + }) + require.NoError(t, err, "attach-role-policy") + + attached, err := fix.AWS.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{ + RoleName: aws.String(iamRoleAppName), + }) + require.NoError(t, err, "list-attached-role-policies") + require.Len(t, attached.AttachedPolicies, 1) + + _, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ + RoleName: aws.String(iamRoleAppName), + PolicyArn: aws.String(adminPolicyARN), + }) + require.NoError(t, err, "idempotent re-attach") + reAttached, err := fix.AWS.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{ + RoleName: aws.String(iamRoleAppName), + }) + require.NoError(t, err) + require.Len(t, reAttached.AttachedPolicies, 1, "re-attach must be idempotent") + + // Attach unknown policy → NoSuchEntity. + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ + RoleName: aws.String(iamRoleAppName), + PolicyArn: aws.String(iamPolicyARN(adminAccount, "Ghost")), + }) + return e + }) + + // CreateInstanceProfile — primary + replace-target. + harness.Step(t, "create-instance-profile %q", iamProfileAppName) + prim, err := fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + }) + require.NoError(t, err, "create-instance-profile primary") + require.Equal(t, iamProfileAppName, aws.StringValue(prim.InstanceProfile.InstanceProfileName)) + + harness.Step(t, "create-instance-profile %q", iamProfileOtherName) + _, err = fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileOtherName), + }) + require.NoError(t, err, "create-instance-profile other") + + harness.Step(t, "create-instance-profile duplicate (expect EntityAlreadyExists)") + harness.ExpectError(t, "EntityAlreadyExists", func() error { + _, e := fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + }) + return e + }) + + // GetInstanceProfile / ListInstanceProfiles. + gotProf, err := fix.AWS.IAM.GetInstanceProfile(&iam.GetInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + }) + require.NoError(t, err, "get-instance-profile") + require.Equal(t, iamProfileAppName, aws.StringValue(gotProf.InstanceProfile.InstanceProfileName)) + + listedProf, err := fix.AWS.IAM.ListInstanceProfiles(&iam.ListInstanceProfilesInput{}) + require.NoError(t, err, "list-instance-profiles") + require.GreaterOrEqual(t, len(listedProf.InstanceProfiles), 2) + + // AddRoleToInstanceProfile — primary; second add rejected by one-role limit. + harness.Step(t, "add-role-to-instance-profile %s <- %s", iamProfileAppName, iamRoleAppName) + _, err = fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + RoleName: aws.String(iamRoleAppName), + }) + require.NoError(t, err, "add-role-to-instance-profile primary") + + harness.Step(t, "add-role-to-instance-profile second role (expect LimitExceeded)") + harness.ExpectError(t, "LimitExceeded", func() error { + _, e := fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + RoleName: aws.String(iamRoleAppName), + }) + return e + }) + + // ListInstanceProfilesForRole — reverse lookup. + rev, err := fix.AWS.IAM.ListInstanceProfilesForRole(&iam.ListInstanceProfilesForRoleInput{ + RoleName: aws.String(iamRoleAppName), + }) + require.NoError(t, err, "list-instance-profiles-for-role") + require.Len(t, rev.InstanceProfiles, 1, + "reverse lookup should see only the profile we added") + + // DeleteRole guards: attached policy + still referenced by a profile. + harness.Step(t, "delete-role while attached (expect DeleteConflict)") + harness.ExpectError(t, "DeleteConflict", func() error { + _, e := fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(iamRoleAppName)}) + return e + }) + + // DeleteInstanceProfile guard: role still attached. + harness.Step(t, "delete-instance-profile while role attached (expect DeleteConflict)") + harness.ExpectError(t, "DeleteConflict", func() error { + _, e := fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + }) + return e + }) + + // Full teardown so TestIAMInstanceProfileAssociation gets a clean slate. + // remove-role then delete-profile, detach-policy then delete-role, in + // that order — same as iamDeleteRoleAndProfilesBestEffort but asserted. + harness.Step(t, "remove-role-from-instance-profile %s", iamProfileAppName) + _, err = fix.AWS.IAM.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + RoleName: aws.String(iamRoleAppName), + }) + require.NoError(t, err, "remove-role-from-instance-profile") + + _, err = fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + }) + require.NoError(t, err, "delete-instance-profile primary") + + _, err = fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileOtherName), + }) + require.NoError(t, err, "delete-instance-profile other") + + _, err = fix.AWS.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{ + RoleName: aws.String(iamRoleAppName), + PolicyArn: aws.String(adminPolicyARN), + }) + require.NoError(t, err, "detach-role-policy") + + _, err = fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(iamRoleAppName)}) + require.NoError(t, err, "delete-role") + + harness.Step(t, "get-role after delete (expect NoSuchEntity)") + harness.ExpectError(t, "NoSuchEntity", func() error { + _, e := fix.AWS.IAM.GetRole(&iam.GetRoleInput{RoleName: aws.String(iamRoleAppName)}) + return e + }) +} + +// iamRoleARN constructs arn:aws:iam:::role/. +func iamRoleARN(account, name string) string { + return "arn:aws:iam::" + account + ":role/" + name +} + +// iamDeleteRoleAndProfilesBestEffort tears down every fragment of a role + +// profile graph the suite might have left behind. Each step swallows errors +// so a missing fragment doesn't cascade. Used both as a pre-test sweep and +// as a fixture-teardown cleanup. +func iamDeleteRoleAndProfilesBestEffort(fix *Fixture, roleName string, profileNames []string, policyARNs ...string) { + for _, p := range profileNames { + // Drop role-from-profile binding (idempotent). + _, _ = fix.AWS.IAM.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{ + InstanceProfileName: aws.String(p), + RoleName: aws.String(roleName), + }) + _, _ = fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ + InstanceProfileName: aws.String(p), + }) + } + for _, arn := range policyARNs { + _, _ = fix.AWS.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyArn: aws.String(arn), + }) + } + // Defensive: pull any other attached policies we don't know about so the + // final DeleteRole isn't blocked by a stray attach from a partial run. + if attached, err := fix.AWS.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{ + RoleName: aws.String(roleName), + }); err == nil { + for _, p := range attached.AttachedPolicies { + _, _ = fix.AWS.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyArn: p.PolicyArn, + }) + } + } + _, _ = fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)}) +} diff --git a/tests/e2e/single/instance_iam_profile_test.go b/tests/e2e/single/instance_iam_profile_test.go new file mode 100644 index 000000000..483cd514d --- /dev/null +++ b/tests/e2e/single/instance_iam_profile_test.go @@ -0,0 +1,279 @@ +//go:build e2e + +package single + +import ( + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/require" +) + +// runIAMInstanceProfileAssociation ports run-e2e.sh IAM Phase 9: EC2 IAM +// instance-profile association lifecycle. Reuses the singleton VM for the +// post-launch Associate/Replace/Disassociate cycle, then launches a single +// short-lived VM whose sole purpose is to exercise (a) RunInstances with +// --iam-instance-profile and (b) auto-disassociate on terminate. +// +// Pre-state: TestIAMRolesAndProfiles already tore everything down — this +// test recreates the role + two profiles, asserts the EC2 surface, then +// cleans up so subsequent tests see no residue. +func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) { + harness.Phase(t, "Single — EC2 IAM Instance Profile Association") + + adminAccount := iamEnsureAdminAccountID(t, fix) + adminPolicyARN := iamPolicyARN(adminAccount, iamPolicyAdministrator) + + // Defensive cleanup of stale state from a prior run. + iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName, + []string{iamProfileAppName, iamProfileOtherName}, adminPolicyARN) + + // Singleton instance — reused for the post-launch Associate/Replace + // cycle. Must be running before we proceed. + inst, _ := needInstance(t, fix) + singletonID := aws.StringValue(inst.InstanceId) + require.Equal(t, "running", aws.StringValue(inst.State.Name), + "singleton must be running for IAM association lifecycle test") + + // Build role + two profiles. The second profile is the Replace target. + harness.Step(t, "setup: create-role + 2 instance profiles") + _, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{ + RoleName: aws.String(iamRoleAppName), + AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard), + }) + require.NoError(t, err, "create-role") + + // Even though root bypasses iam:PassRole, attach AdministratorAccess + // to match production usage and exercise the PassRole code path. + _, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{ + RoleName: aws.String(iamRoleAppName), + PolicyArn: aws.String(adminPolicyARN), + }) + require.NoError(t, err, "attach AdministratorAccess to role") + + for _, p := range []string{iamProfileAppName, iamProfileOtherName} { + _, err = fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String(p), + }) + require.NoError(t, err, "create-instance-profile %s", p) + _, err = fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{ + InstanceProfileName: aws.String(p), + RoleName: aws.String(iamRoleAppName), + }) + require.NoError(t, err, "add-role-to-instance-profile %s", p) + } + + // Register fixture-level cleanup. LIFO order: profile/role teardown runs + // AFTER the ephemeral VM auto-terminates and AFTER any singleton-VM + // disassociate this test ends with. The cleanup also tolerates partial + // state from a mid-test failure. + fix.Harness.RegisterCleanup(func() { + // Disassociate anything still bound to the singleton. + assocs, err := fix.AWS.EC2.DescribeIamInstanceProfileAssociations( + &ec2.DescribeIamInstanceProfileAssociationsInput{ + Filters: []*ec2.Filter{{ + Name: aws.String("instance-id"), Values: []*string{aws.String(singletonID)}, + }}, + }) + if err == nil { + for _, a := range assocs.IamInstanceProfileAssociations { + _, _ = fix.AWS.EC2.DisassociateIamInstanceProfile( + &ec2.DisassociateIamInstanceProfileInput{AssociationId: a.AssociationId}) + } + } + iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName, + []string{iamProfileAppName, iamProfileOtherName}, adminPolicyARN) + }) + + // ---- Associate (post-launch) on singleton ---- + harness.Step(t, "associate-iam-instance-profile %s -> %s", iamProfileAppName, singletonID) + assocOut, err := fix.AWS.EC2.AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{ + InstanceId: aws.String(singletonID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileAppName)}, + }) + require.NoError(t, err, "associate-iam-instance-profile") + require.NotNil(t, assocOut.IamInstanceProfileAssociation, "missing association in response") + assocID := aws.StringValue(assocOut.IamInstanceProfileAssociation.AssociationId) + require.True(t, strings.HasPrefix(assocID, "iip-assoc-"), + "AssociationId %q must use iip-assoc- prefix per AWS shape", assocID) + harness.Detail(t, "association", assocID) + + // Associate again — must surface IamInstanceProfileAlreadyAssociated. + harness.Step(t, "associate-iam-instance-profile twice (expect IamInstanceProfileAlreadyAssociated)") + harness.ExpectError(t, "IamInstanceProfileAlreadyAssociated", func() error { + _, e := fix.AWS.EC2.AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{ + InstanceId: aws.String(singletonID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileAppName)}, + }) + return e + }) + + // DescribeInstances must surface the bound profile ARN. + harness.Step(t, "describe-instances should expose IamInstanceProfile.Arn") + desc, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(singletonID)}, + }) + require.NoError(t, err, "describe-instances") + require.NotEmpty(t, desc.Reservations) + require.NotEmpty(t, desc.Reservations[0].Instances) + ip := desc.Reservations[0].Instances[0].IamInstanceProfile + require.NotNil(t, ip, "DescribeInstances must populate IamInstanceProfile when bound") + require.True(t, + strings.HasSuffix(aws.StringValue(ip.Arn), ":instance-profile/"+iamProfileAppName), + "unexpected IamInstanceProfile.Arn %q", aws.StringValue(ip.Arn)) + + // DescribeIamInstanceProfileAssociations by id. + harness.Step(t, "describe-iam-instance-profile-associations --association-ids %s", assocID) + descAssoc, err := fix.AWS.EC2.DescribeIamInstanceProfileAssociations( + &ec2.DescribeIamInstanceProfileAssociationsInput{ + AssociationIds: []*string{aws.String(assocID)}, + }) + require.NoError(t, err, "describe-iam-instance-profile-associations") + require.Len(t, descAssoc.IamInstanceProfileAssociations, 1) + require.Equal(t, singletonID, + aws.StringValue(descAssoc.IamInstanceProfileAssociations[0].InstanceId)) + + // DeleteInstanceProfile is refused while the profile is bound to a live + // instance — the gateway live-instance fan-out gate from iam-roles-v1-ec2. + harness.Step(t, "delete-instance-profile while bound (expect DeleteConflict)") + harness.ExpectError(t, "DeleteConflict", func() error { + _, e := fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{ + InstanceProfileName: aws.String(iamProfileAppName), + }) + return e + }) + + // Replace → other-profile, must mint a new assoc id. + harness.Step(t, "replace-iam-instance-profile-association -> %s", iamProfileOtherName) + replaceOut, err := fix.AWS.EC2.ReplaceIamInstanceProfileAssociation( + &ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String(assocID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileOtherName)}, + }) + require.NoError(t, err, "replace-iam-instance-profile-association") + newAssocID := aws.StringValue(replaceOut.IamInstanceProfileAssociation.AssociationId) + require.True(t, strings.HasPrefix(newAssocID, "iip-assoc-")) + require.NotEqual(t, assocID, newAssocID, "Replace must mint a fresh AssociationId") + + // Replace with the stale id surfaces NoSuchAssociation. + harness.Step(t, "replace with stale id (expect NoSuchAssociation)") + harness.ExpectError(t, "NoSuchAssociation", func() error { + _, e := fix.AWS.EC2.ReplaceIamInstanceProfileAssociation( + &ec2.ReplaceIamInstanceProfileAssociationInput{ + AssociationId: aws.String(assocID), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileAppName)}, + }) + return e + }) + + // Disassociate. + harness.Step(t, "disassociate-iam-instance-profile %s", newAssocID) + _, err = fix.AWS.EC2.DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ + AssociationId: aws.String(newAssocID), + }) + require.NoError(t, err, "disassociate-iam-instance-profile") + + // Disassociate again with stale id. + harness.ExpectError(t, "NoSuchAssociation", func() error { + _, e := fix.AWS.EC2.DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ + AssociationId: aws.String(newAssocID), + }) + return e + }) + + // DescribeInstances no longer carries the profile. + descAfter, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(singletonID)}, + }) + require.NoError(t, err, "describe-instances after disassociate") + if ip := descAfter.Reservations[0].Instances[0].IamInstanceProfile; ip != nil { + require.Empty(t, aws.StringValue(ip.Arn), + "DescribeInstances must clear IamInstanceProfile.Arn after Disassociate") + } + + // ---- RunInstances --iam-instance-profile + auto-disassociate on terminate ---- + // Reuse the singleton's launch attributes so we don't need to rediscover + // AMI / type / key / subnet / SG — guarantees the ephemeral VM lands on a + // known-good combination on the local single-node fixture. + ephID := runInstanceWithProfile(t, fix, inst, iamProfileAppName) + defer func() { + // Belt-and-braces: even if the auto-disassociate assertion fails, we + // must not leave a VM running. Terminate is idempotent. + _, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(ephID)}, + }) + }() + + // Lookup the association the daemon synthesised at launch. + ephAssocs, err := fix.AWS.EC2.DescribeIamInstanceProfileAssociations( + &ec2.DescribeIamInstanceProfileAssociationsInput{ + Filters: []*ec2.Filter{{ + Name: aws.String("instance-id"), Values: []*string{aws.String(ephID)}, + }}, + }) + require.NoError(t, err, "describe-iam-instance-profile-associations --instance-id %s", ephID) + require.Len(t, ephAssocs.IamInstanceProfileAssociations, 1, + "RunInstances --iam-instance-profile must auto-create an association") + ephAssocID := aws.StringValue(ephAssocs.IamInstanceProfileAssociations[0].AssociationId) + require.True(t, strings.HasPrefix(ephAssocID, "iip-assoc-")) + + // DescribeInstances on the ephemeral VM surfaces the profile. + ephDesc, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(ephID)}, + }) + require.NoError(t, err, "describe-instances ephemeral") + require.NotEmpty(t, ephDesc.Reservations) + require.NotEmpty(t, ephDesc.Reservations[0].Instances) + require.NotNil(t, ephDesc.Reservations[0].Instances[0].IamInstanceProfile, + "RunInstances --iam-instance-profile must be visible on DescribeInstances") + + // Terminate → wait for auto-disassociate. + harness.Step(t, "terminate ephemeral %s (auto-disassociate)", ephID) + _, err = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(ephID)}, + }) + require.NoError(t, err, "terminate-instances ephemeral") + harness.WaitForInstanceState(t, fix.AWS, ephID, "terminated") + + // Allow a brief window for the daemon's auto-disassociate to drain to KV. + // The disassociate is synchronous on the same handler that flips state, so + // the wait above is usually sufficient — bound a short retry just in case. + require.Eventually(t, func() bool { + _, e := fix.AWS.EC2.DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ + AssociationId: aws.String(ephAssocID), + }) + return harness.ErrorCodeIs(e, "NoSuchAssociation") + }, 30*time.Second, 1*time.Second, + "terminated instance %s must auto-disassociate %s", ephID, ephAssocID) +} + +// runInstanceWithProfile launches a single short-lived VM mirroring src's +// launch attributes, with the named instance profile attached. Polls to +// running; returns the new instance ID. Caller owns terminate. +func runInstanceWithProfile(t *testing.T, fix *Fixture, src *ec2.Instance, profileName string) string { + t.Helper() + require.NotEmpty(t, src.SecurityGroups, "source instance has no security groups") + out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{ + ImageId: src.ImageId, + InstanceType: src.InstanceType, + KeyName: src.KeyName, + SubnetId: src.SubnetId, + SecurityGroupIds: []*string{src.SecurityGroups[0].GroupId}, + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + IamInstanceProfile: &ec2.IamInstanceProfileSpecification{ + Name: aws.String(profileName), + }, + }) + require.NoError(t, err, "run-instances --iam-instance-profile") + require.NotEmpty(t, out.Instances, "RunInstances returned 0 instances") + id := aws.StringValue(out.Instances[0].InstanceId) + require.True(t, strings.HasPrefix(id, "i-"), "unexpected InstanceId %q", id) + harness.WaitForInstanceState(t, fix.AWS, id, "running") + return id +} diff --git a/tests/e2e/single/tests_test.go b/tests/e2e/single/tests_test.go index 327684f20..7b14b7046 100644 --- a/tests/e2e/single/tests_test.go +++ b/tests/e2e/single/tests_test.go @@ -202,6 +202,22 @@ func TestIAMCleanup(t *testing.T) { runIAMCleanup(t, requireSingleNodeFixture(t)) } +// TestIAMRolesAndProfiles + TestIAMInstanceProfileAssociation form the +// companion-plan suite (docs/development/archive/feature/iam-roles-v1.md and +// docs/development/feature/iam-roles-v1-ec2.md). Sequential: the second test +// recreates the same role/profile names the first tore down, so concurrent +// runs would race the EntityAlreadyExists / NoSuchEntity boundaries. +func TestIAMRolesAndProfiles(t *testing.T) { + runIAMRolesAndProfiles(t, requireSingleNodeFixture(t)) +} + +// TestIAMInstanceProfileAssociation also briefly mutates the singleton VM +// (Associate/Disassociate), so it can't share the parallel bucket with +// instance-mutation tests. +func TestIAMInstanceProfileAssociation(t *testing.T) { + runIAMInstanceProfileAssociation(t, requireSingleNodeFixture(t)) +} + // TestFinalClusterStats runs as the last sequential test (parallel bucket // runs afterwards, but those Tests are read-only / own-everything so a // later cluster-stats snapshot would still be valid).