Repository: getmoto/moto

Issue:
RevokeSecurityGroupEgress does not match AWS behavior, preventing moto_server usage with Terraform
Hi,

I've checked for duplicate issues regarding RevokeSecurityGroupEgress and/or Terraform and have not found any.

This one is relatively simple it would seem that With moto (via `moto_server`), `RevokeSecurityGroupEgress` fails to delete the default egress security group rule (which is `IpProtocol=-1`) when the arguments for `ToPort` and `FromPort` are specified. This makes sense in that `IpProtocol=-1` does not care about ports. However, the behavior mismatches AWS, which appears to ignore the `ToPort` and `FromPort` arguments.

I discovered this when attempting to use moto_server with Terraform for testing some security group rule management. I'm sure it could be argued that Terraform is not using the AWS EC2 API correctly, however it would be nice to have moto match these subtle behaviors of the AWS EC2 API.

## Background / notes

This is the Terraform module:

```
provider "aws" {
  region                      = "us-east-1"
  access_key                  = "mock_access_key"
  secret_key                  = "mock_secret_key"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true
  endpoints {
    ec2            = "http://localhost:5000"
  }
}

# Moto does not support describe_vpc_classic_link yet, and TF expects this API to exist when creating a VPC
variable "vpc_id" {
  type        = "string"
  description = "The VPC id from `aws --endpoint-url http://localhost:5000/ ec2 create-vpc --cidr-block '10.0.0.0/16'`"
}

resource "aws_security_group" "test" {
  name        = "test-sg"
  description = "Test Security Group"
  vpc_id      = "${var.vpc_id}"
}
```

When executing TF that gives this result:

```
jmeichle@jmeichle-XPS-15-9570:~$ terraform apply
var.vpc_id
  The VPC id from `aws --endpoint-url http://localhost:5000/ ec2 create-vpc --cidr-block '10.0.0.0/16'`

  Enter a value: vpc-b37b44c6

aws_security_group.test: Refreshing state... (ID: sg-f2dc38f6)

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + aws_security_group.test
      id:                     <computed>
      arn:                    <computed>
      description:            "Test Security Group"
      egress.#:               <computed>
      ingress.#:              <computed>
      name:                   "test-sg"
      owner_id:               <computed>
      revoke_rules_on_delete: "false"
      vpc_id:                 "vpc-b37b44c6"


Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_security_group.test: Creating...
  arn:                    "" => "<computed>"
  description:            "" => "Test Security Group"
  egress.#:               "" => "<computed>"
  ingress.#:              "" => "<computed>"
  name:                   "" => "test-sg"
  owner_id:               "" => "<computed>"
  revoke_rules_on_delete: "" => "false"
  vpc_id:                 "" => "vpc-b37b44c6"

Error: Error applying plan:

1 error(s) occurred:

* aws_security_group.test: 1 error(s) occurred:

* aws_security_group.test: Error revoking default egress rule for Security Group (sg-34e6d95d): InvalidPermission.NotFound: The specified rule does not exist in this security group
    status code: 400, request id: 7a62c49f-347e-4fc4-9331-6e8eEXAMPLE

Terraform does not automatically rollback in the face of errors.
Instead, your Terraform state file has been partially updated with
any resources that successfully completed. Please address the error
above and apply again to incrementally change your infrastructure.
```

With debug logging enabled in terraform, I see this call to moto_server:

```
2019-11-10T22:43:29.502-0500 [DEBUG] plugin.terraform-provider-aws_v2.35.0_x4: Action=RevokeSecurityGroupEgress&GroupId=sg-11568247&IpPermissions.1.FromPort=0&IpPermissions.1.IpProtocol=-1&IpPermissions.1.IpRanges.1.CidrIp=0.0.0.0%2F0&IpPermissions.1.ToPort=0&Version=2016-11-15
2019-11-10T22:43:29.502-0500 [DEBUG] plugin.terraform-provider-aws_v2.35.0_x4: -----------------------------------------------------
2019-11-10T22:43:29.507-0500 [DEBUG] plugin.terraform-provider-aws_v2.35.0_x4: 2019/11/10 22:43:29 [DEBUG] [aws-sdk-go] DEBUG: Response ec2/RevokeSecurityGroupEgress Details:
2019-11-10T22:43:29.507-0500 [DEBUG] plugin.terraform-provider-aws_v2.35.0_x4: ---[ RESPONSE ]--------------------------------------
2019-11-10T22:43:29.507-0500 [DEBUG] plugin.terraform-provider-aws_v2.35.0_x4: HTTP/1.0 400 BAD REQUEST
```
When inspecting the API request, we confirm that Terraform is indeed passing ToPort and FromPort (and this works against the AWS EC2 API).

To reproduce the issue with awscli:

```
jmeichle@jmeichle-XPS-15-9570:~$ aws --endpoint-url http://localhost:5000/ ec2 revoke-security-group-egress --group-id sg-11568247 --ip-permissions FromPort=0,IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}],ToPort=0

An error occurred (InvalidPermission.NotFound) when calling the RevokeSecurityGroupEgress operation: The specified rule does not exist in this security group
```
and if I do not specify `ToPort` and `FromPort`, moto works as expected:

```
jmeichle@jmeichle-XPS-15-9570:~$ aws --endpoint-url http://localhost:5000/ ec2 revoke-security-group-egress --group-id sg-11568247 --ip-permissions IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]
jmeichle@jmeichle-XPS-15-9570:~$
```

For an even simpler way to reproduce, use the awscli to create a security group, then try to delete the egress security group rule created by default while specfying the extra arguments for ToPort or FromPort:

```
jmeichle@jmeichle-XPS-15-9570:~$ aws --endpoint-url http://localhost:5000/ ec2 create-security-group --group-name testsg --description testsg --vpc-id vpc-b37b44c6
{
    "GroupId": "sg-c98111d7"
}
jmeichle@jmeichle-XPS-15-9570:~$ aws --endpoint-url http://localhost:5000/ ec2 revoke-security-group-egress --group-id sg-c98111d7 --ip-permissions FromPort=0,IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}],ToPort=0

An error occurred (InvalidPermission.NotFound) when calling the RevokeSecurityGroupEgress operation: The specified rule does not exist in this security group
jmeichle@jmeichle-XPS-15-9570:~$ aws --endpoint-url http://localhost:5000/ ec2 revoke-security-group-egress --group-id sg-c98111d7 --ip-permissions FromPort=0,IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]

An error occurred (InvalidPermission.NotFound) when calling the RevokeSecurityGroupEgress operation: The specified rule does not exist in this security group
jmeichle@jmeichle-XPS-15-9570:~$ aws --endpoint-url http://localhost:5000/ ec2 revoke-security-group-egress --group-id sg-c98111d7 --ip-permissions IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]
jmeichle@jmeichle-XPS-15-9570:~$
```


Discussion:

Resolving patch:
diff --git a/moto/ec2/models.py b/moto/ec2/models.py
--- a/moto/ec2/models.py
+++ b/moto/ec2/models.py
@@ -1644,23 +1644,27 @@ def get_zone_by_name(self, name):
 class SecurityRule(object):
     def __init__(self, ip_protocol, from_port, to_port, ip_ranges, source_groups):
         self.ip_protocol = ip_protocol
-        self.from_port = from_port
-        self.to_port = to_port
         self.ip_ranges = ip_ranges or []
         self.source_groups = source_groups
 
-    @property
-    def unique_representation(self):
-        return "{0}-{1}-{2}-{3}-{4}".format(
-            self.ip_protocol,
-            self.from_port,
-            self.to_port,
-            self.ip_ranges,
-            self.source_groups,
-        )
+        if ip_protocol != "-1":
+            self.from_port = from_port
+            self.to_port = to_port
 
     def __eq__(self, other):
-        return self.unique_representation == other.unique_representation
+        if self.ip_protocol != other.ip_protocol:
+            return False
+        if self.ip_ranges != other.ip_ranges:
+            return False
+        if self.source_groups != other.source_groups:
+            return False
+        if self.ip_protocol != "-1":
+            if self.from_port != other.from_port:
+                return False
+            if self.to_port != other.to_port:
+                return False
+
+        return True
 
 
 class SecurityGroup(TaggedEC2Resource):
@@ -1670,7 +1674,7 @@ def __init__(self, ec2_backend, group_id, name, description, vpc_id=None):
         self.name = name
         self.description = description
         self.ingress_rules = []
-        self.egress_rules = [SecurityRule(-1, None, None, ["0.0.0.0/0"], [])]
+        self.egress_rules = [SecurityRule("-1", None, None, ["0.0.0.0/0"], [])]
         self.enis = {}
         self.vpc_id = vpc_id
         self.owner_id = OWNER_ID


Test patch:
diff --git a/tests/test_ec2/test_security_groups.py b/tests/test_ec2/test_security_groups.py
--- a/tests/test_ec2/test_security_groups.py
+++ b/tests/test_ec2/test_security_groups.py
@@ -833,3 +833,33 @@ def test_get_all_security_groups_filter_with_same_vpc_id():
     cm.exception.code.should.equal("InvalidGroup.NotFound")
     cm.exception.status.should.equal(400)
     cm.exception.request_id.should_not.be.none
+
+
+@mock_ec2
+def test_revoke_security_group_egress():
+    ec2 = boto3.resource("ec2", "us-east-1")
+    sg = ec2.create_security_group(Description="Test SG", GroupName="test-sg")
+
+    sg.ip_permissions_egress.should.equal(
+        [
+            {
+                "IpProtocol": "-1",
+                "IpRanges": [{"CidrIp": "0.0.0.0/0"}],
+                "UserIdGroupPairs": [],
+            }
+        ]
+    )
+
+    sg.revoke_egress(
+        IpPermissions=[
+            {
+                "FromPort": 0,
+                "IpProtocol": "-1",
+                "IpRanges": [{"CidrIp": "0.0.0.0/0"},],
+                "ToPort": 123,
+            },
+        ]
+    )
+
+    sg.reload()
+    sg.ip_permissions_egress.should.have.length_of(0)