From 78e66812bdb39a3ee2910fc7f94c67a0b8e04bc0 Mon Sep 17 00:00:00 2001 From: Laurens Knoll Date: Thu, 27 Feb 2020 11:57:22 +0100 Subject: [PATCH 1/6] Added resource MySQLUserGrant. Moved sources to package cfn_mysql_user_provider; Upgraded to python3.7; Added LambdaVersion to cloudformation to simplify make deploy. --- .gitignore | 4 +- .release | 4 +- .vscode/project.env | 1 + .vscode/settings.json | 8 + Dockerfile.lambda | 13 +- Makefile | 70 ++--- cloudformation/cfn-resource-provider.yaml | 7 +- cloudformation/demo-stack.yaml | 77 +++--- docs/MySQLUserGrant.md | 48 ++++ mysql_user_provider.py | 14 + requirements.txt | 5 - setup.cfg | 20 ++ setup.py | 2 + src/cfn_mysql_user_provider/__init__.py | 0 .../mysql_database_provider.py | 76 ++++++ .../mysql_user_grant_provider.py | 219 ++++++++++++++++ .../mysql_user_provider.py | 77 +----- .../test-requirements.txt | 2 +- tests/test_mysql_user_grant_provider.py | 244 ++++++++++++++++++ tests/test_mysql_user_provider.py | 3 +- 20 files changed, 749 insertions(+), 145 deletions(-) create mode 100644 .vscode/project.env create mode 100644 .vscode/settings.json create mode 100644 docs/MySQLUserGrant.md create mode 100644 mysql_user_provider.py delete mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/cfn_mysql_user_provider/__init__.py create mode 100644 src/cfn_mysql_user_provider/mysql_database_provider.py create mode 100644 src/cfn_mysql_user_provider/mysql_user_grant_provider.py rename src/{ => cfn_mysql_user_provider}/mysql_user_provider.py (81%) rename test-requirements.txt => tests/test-requirements.txt (50%) create mode 100644 tests/test_mysql_user_grant_provider.py diff --git a/.gitignore b/.gitignore index 23c77e6..7fe280f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,9 @@ .noseids *.zip deps/ -.vscode/ +.vscode/* +!.vscode/settings.json +!.vscode/project.env # Byte-compiled / optimized / DLL files .pytest_cache/ __pycache__/ diff --git a/.release b/.release index 793bc01..8239b53 100644 --- a/.release +++ b/.release @@ -1,3 +1,3 @@ -release=0.2.3 -tag=v0.2.3 +release=0.2.4 +tag=v0.2.4 pre_tag_command=sed -i '' -e 's/lambdas\/cfn-mysql-user-provider.*\.zip/lambdas\/cfn-mysql-user-provider-@@RELEASE@@.zip/g' cloudformation/cfn-resource-provider.yaml README.md diff --git a/.vscode/project.env b/.vscode/project.env new file mode 100644 index 0000000..1d3b509 --- /dev/null +++ b/.vscode/project.env @@ -0,0 +1 @@ +PYTHONPATH=./src:${PYTHONPATH} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7f76ae3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.pythonPath": "${workspaceFolder}/.venv/bin/python3", + "python.envFile": "${workspaceFolder}/.vscode/project.env", + "python.testing.pytestArgs": [], + "python.testing.unittestEnabled": false, + "python.testing.nosetestsEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/Dockerfile.lambda b/Dockerfile.lambda index 3c8cc1c..e79b0b8 100644 --- a/Dockerfile.lambda +++ b/Dockerfile.lambda @@ -1,11 +1,16 @@ -FROM python:3.6 +FROM python:3.7 + RUN apt-get update && apt-get install -y zip + WORKDIR /lambda -ADD requirements.txt /tmp -RUN pip install --quiet -t /lambda -r /tmp/requirements.txt +ADD setup.cfg /tmp +ADD setup.py /tmp +ADD src/ /tmp/src +RUN pip install --quiet -t /lambda /tmp + +ADD mysql_user_provider.py /lambda/ -ADD src/ /lambda/ RUN find /lambda -type d | xargs -n 1 -I {} chmod ugo+rx "{}" && \ find /lambda -type f | xargs -n 1 -I {} chmod ugo+r "{}" diff --git a/Makefile b/Makefile index a00a917..c3bd573 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ help: @echo 'make release - builds a zip file and deploys it to s3.' @echo 'make clean - the workspace.' @echo 'make test - execute the tests, requires a working AWS connection.' - @echo 'make deploy - lambda to bucket $(S3_BUCKET)' + @echo 'make deploy - lambda to bucket $(S3_BUCKET)' @echo 'make deploy-all-regions - lambda to all regions with bucket prefix $(S3_BUCKET_PREFIX)' @echo 'make deploy-provider - deploys the provider.' @echo 'make delete-provider - deletes the provider.' @@ -20,12 +20,12 @@ help: @echo 'make delete-demo - deletes the demo cloudformation stack.' deploy: target/$(NAME)-$(VERSION).zip - aws s3 --region $(AWS_REGION) \ - cp --acl \ - public-read target/$(NAME)-$(VERSION).zip \ + aws s3 cp \ + --acl public-read \ + target/$(NAME)-$(VERSION).zip \ s3://$(S3_BUCKET)/lambdas/$(NAME)-$(VERSION).zip - aws s3 --region $(AWS_REGION) \ - cp --acl public-read \ + aws s3 cp \ + --acl public-read \ s3://$(S3_BUCKET)/lambdas/$(NAME)-$(VERSION).zip \ s3://$(S3_BUCKET)/lambdas/$(NAME)-latest.zip @@ -46,7 +46,7 @@ do-push: deploy do-build: target/$(NAME)-$(VERSION).zip -target/$(NAME)-$(VERSION).zip: src/*.py requirements.txt +target/$(NAME)-$(VERSION).zip: mysql_user_provider.py mkdir -p target docker build --build-arg ZIPFILE=$(NAME)-$(VERSION).zip -t $(NAME)-lambda:$(VERSION) -f Dockerfile.lambda . && \ ID=$$(docker create $(NAME)-lambda:$(VERSION) /bin/true) && \ @@ -54,39 +54,39 @@ target/$(NAME)-$(VERSION).zip: src/*.py requirements.txt docker rm -f $$ID && \ chmod ugo+r target/$(NAME)-$(VERSION).zip -venv: requirements.txt - virtualenv -p python3 venv && \ - . ./venv/bin/activate && \ +venv: + python3 -m venv .venv && \ + . ./.venv/bin/activate && \ pip install --quiet --upgrade pip && \ - pip install --quiet -r requirements.txt + pip install --quiet -e . clean: - rm -rf venv target + rm -rf .venv target rm -rf src/*.pyc tests/*.pyc test: venv for i in $$PWD/cloudformation/*; do \ aws cloudformation validate-template --template-body file://$$i > /dev/null || exit 1; \ done - . ./venv/bin/activate && \ - pip install --quiet -r requirements.txt -r test-requirements.txt && \ - cd src && \ - PYTHONPATH=$(PWD)/src pytest ../tests/test*.py + + . ./.venv/bin/activate && \ + pip install --quiet -r tests/test-requirements.txt && \ + py.test tests autopep: - autopep8 --experimental --in-place --max-line-length 132 src/*.py tests/*.py + autopep8 --experimental --in-place --max-line-length 132 mysql_user_provider.py src/*.py tests/*.py -deploy-provider: +deploy-provider: deploy @set -x ;if aws cloudformation get-template-summary --stack-name $(NAME) >/dev/null 2>&1 ; then \ export CFN_COMMAND=update; \ else \ export CFN_COMMAND=create; \ fi ;\ - export VPC_ID=$$(aws ec2 --output text --query 'Vpcs[?IsDefault].VpcId' describe-vpcs) ; \ - export SUBNET_IDS=$$(aws ec2 --output text --query 'RouteTables[?Routes[?GatewayId == null]].Associations[].SubnetId' \ - describe-route-tables --filters Name=vpc-id,Values=$$VPC_ID | tr '\t' ','); \ - export SG_ID=$$(aws ec2 --output text --query "SecurityGroups[*].GroupId" \ - describe-security-groups --group-names default --filters Name=vpc-id,Values=$$VPC_ID); \ + export VPC_ID=$$(aws ec2 describe-vpcs --output text --query 'Vpcs[?IsDefault].VpcId') ; \ + export SUBNET_IDS=$$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$$VPC_ID \ + --output text --query 'Subnets[].SubnetId' | tr '\t' ','); \ + export SG_ID=$$(aws ec2 describe-security-groups --group-names default --filters Name=vpc-id,Values=$$VPC_ID \ + --output text --query "SecurityGroups[*].GroupId"); \ ([[ -z $$VPC_ID ]] || [[ -z $$SUBNET_IDS ]] || [[ -z $$SG_ID ]]) && \ echo "Either there is no default VPC in your account, less then two subnets or no default security group available in the default VPC" && exit 1 ; \ echo "$$CFN_COMMAND provider in default VPC $$VPC_ID, subnets $$SUBNET_IDS using security group $$SG_ID." ; \ @@ -94,9 +94,11 @@ deploy-provider: --capabilities CAPABILITY_IAM \ --stack-name $(NAME) \ --template-body file://cloudformation/cfn-resource-provider.yaml \ - --parameters ParameterKey=VPC,ParameterValue=$$VPC_ID \ - ParameterKey=Subnets,ParameterValue=\"$$SUBNET_IDS\" \ - ParameterKey=SecurityGroup,ParameterValue=$$SG_ID ;\ + --parameters ParameterKey=VPC,ParameterValue=\"$$VPC_ID\" \ + ParameterKey=Subnets,ParameterValue=\"$$SUBNET_IDS\" \ + ParameterKey=SecurityGroup,ParameterValue=\"$$SG_ID\" \ + ParameterKey=LambdaS3Bucket,ParameterValue=\"${S3_BUCKET}\" \ + ParameterKey=LambdaVersion,ParameterValue=\"${VERSION}\" ;\ aws cloudformation wait stack-$$CFN_COMMAND-complete --stack-name $(NAME) ; delete-provider: @@ -107,22 +109,22 @@ demo: @if aws cloudformation get-template-summary --stack-name $(NAME)-demo >/dev/null 2>&1 ; then \ export CFN_COMMAND=update; export CFN_TIMEOUT="" ;\ else \ - export CFN_COMMAND=create; export CFN_TIMEOUT="--timeout-in-minutes 10" ;\ + export CFN_COMMAND=create; export CFN_TIMEOUT="--timeout-in-minutes 30" ;\ fi ;\ - export VPC_ID=$$(aws ec2 --output text --query 'Vpcs[?IsDefault].VpcId' describe-vpcs) ; \ - export SUBNET_IDS=$$(aws ec2 --output text --query 'RouteTables[?Routes[?GatewayId == null]].Associations[].SubnetId' \ - describe-route-tables --filters Name=vpc-id,Values=$$VPC_ID | tr '\t' ','); \ - export SG_ID=$$(aws ec2 --output text --query "SecurityGroups[*].GroupId" \ - describe-security-groups --group-names default --filters Name=vpc-id,Values=$$VPC_ID); \ + export VPC_ID=$$(aws ec2 describe-vpcs --output text --query 'Vpcs[?IsDefault].VpcId') ; \ + export SUBNET_IDS=$$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$$VPC_ID \ + --output text --query 'Subnets[].SubnetId' | tr '\t' ','); \ + export SG_ID=$$(aws ec2 describe-security-groups --group-names default --filters Name=vpc-id,Values=$$VPC_ID \ + --output text --query "SecurityGroups[*].GroupId"); \ echo "$$CFN_COMMAND demo in default VPC $$VPC_ID, subnets $$SUBNET_IDS using security group $$SG_ID." ; \ ([[ -z $$VPC_ID ]] || [[ -z $$SUBNET_IDS ]] || [[ -z $$SG_ID ]]) && \ echo "Either there is no default VPC in your account, no two subnets or no default security group available in the default VPC" && exit 1 ; \ aws cloudformation $$CFN_COMMAND-stack --stack-name $(NAME)-demo \ --template-body file://cloudformation/demo-stack.yaml \ $$CFN_TIMEOUT \ - --parameters ParameterKey=VPC,ParameterValue=$$VPC_ID \ + --parameters ParameterKey=VPC,ParameterValue=\"$$VPC_ID\" \ ParameterKey=Subnets,ParameterValue=\"$$SUBNET_IDS\" \ - ParameterKey=SecurityGroup,ParameterValue=$$SG_ID ;\ + ParameterKey=SecurityGroup,ParameterValue=\"$$SG_ID\" ;\ aws cloudformation wait stack-$$CFN_COMMAND-complete --stack-name $(NAME)-demo ; delete-demo: diff --git a/cloudformation/cfn-resource-provider.yaml b/cloudformation/cfn-resource-provider.yaml index 617de79..c1411a5 100644 --- a/cloudformation/cfn-resource-provider.yaml +++ b/cloudformation/cfn-resource-provider.yaml @@ -10,6 +10,9 @@ Parameters: LambdaS3Bucket: Type: String Default: '' + LambdaVersion: + Type: String + Default: '0.2.3' Conditions: UsePublicBucket: !Equals - !Ref 'LambdaS3Bucket' @@ -66,7 +69,7 @@ Resources: - UsePublicBucket - !Sub 'binxio-public-${AWS::Region}' - !Ref 'LambdaS3Bucket' - S3Key: lambdas/cfn-mysql-user-provider-0.2.3.zip + S3Key: !Sub 'lambdas/cfn-mysql-user-provider-${LambdaVersion}.zip' VpcConfig: SecurityGroupIds: - !Ref 'SecurityGroup' @@ -75,4 +78,4 @@ Resources: Handler: mysql_user_provider.handler MemorySize: 128 Role: !GetAtt 'LambdaRole.Arn' - Runtime: python3.6 + Runtime: python3.7 diff --git a/cloudformation/demo-stack.yaml b/cloudformation/demo-stack.yaml index 79be835..7dc92fa 100644 --- a/cloudformation/demo-stack.yaml +++ b/cloudformation/demo-stack.yaml @@ -7,36 +7,13 @@ Parameters: Type: List SecurityGroup: Type: AWS::EC2::SecurityGroup::Id + Resources: DBSubnetGroup: Type: AWS::RDS::DBSubnetGroup Properties: DBSubnetGroupDescription: Subnets available for the RDS DB Instance SubnetIds: !Ref 'Subnets' - Database: - Type: AWS::RDS::DBInstance - Properties: - AllocatedStorage: 10 - DBInstanceClass: db.t2.micro - Engine: mysql - EngineVersion: 5.7.21 - VPCSecurityGroups: - - !Ref 'DatabaseSecurityGroup' - DBName: root - MasterUsername: root - MasterUserPassword: !GetAtt 'DBPassword.Secret' - MultiAZ: 'false' - Port: '3306' - PubliclyAccessible: 'false' - DBSubnetGroupName: !Ref 'DBSubnetGroup' - DBParameterGroupName: !Ref 'DatabaseParameterGroup' - DeletionPolicy: Snapshot - DatabaseParameterGroup: - Type: AWS::RDS::DBParameterGroup - Properties: - Description: Parameters for MySQL - Family: MySQL5.7 - Parameters: {} DatabaseSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: @@ -58,16 +35,36 @@ Resources: Alphabet: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ReturnSecret: true ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-secret-provider' + Database: + Type: AWS::RDS::DBInstance + Properties: + AllocatedStorage: 30 + DBInstanceClass: db.t3.medium + Engine: mysql + EngineVersion: 5.7.28 + VPCSecurityGroups: + - !Ref 'DatabaseSecurityGroup' + DBName: root + MasterUsername: root + MasterUserPassword: !GetAtt 'DBPassword.Secret' + MultiAZ: 'false' + Port: '3306' + PubliclyAccessible: 'false' + DBSubnetGroupName: !Ref 'DBSubnetGroup' + DBParameterGroupName: !Ref 'DatabaseParameterGroup' + DeletionPolicy: Snapshot + DatabaseParameterGroup: + Type: AWS::RDS::DBParameterGroup + Properties: + Description: Parameters for MySQL + Family: mysql5.7 + Parameters: {} + KongPassword: Type: Custom::Secret Properties: Name: !Sub '/${AWS::StackName}/mysql/kong/PGPASSWORD' ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-secret-provider' - KongReaderPassword: - Type: Custom::Secret - Properties: - Name: !Sub '/${AWS::StackName}/mysql/kongreader/PGPASSWORD' - ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-secret-provider' KongUser: Type: Custom::MySQLUser DependsOn: @@ -84,6 +81,12 @@ Resources: DBName: root PasswordParameterName: !Sub '/${AWS::StackName}/mysql/root/PGPASSWORD' ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-mysql-user-provider-${VPC}' + + KongReaderPassword: + Type: Custom::Secret + Properties: + Name: !Sub '/${AWS::StackName}/mysql/kongreader/PGPASSWORD' + ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-secret-provider' KongReaderUser: Type: Custom::MySQLUser DependsOn: @@ -101,3 +104,19 @@ Resources: DBName: root PasswordParameterName: !Sub '/${AWS::StackName}/mysql/root/PGPASSWORD' ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-mysql-user-provider-${VPC}' + KongReaderUserGrant: + Type: Custom::MySQLUserGrant + DependsOn: + - KongReaderUser + Properties: + Grant: + - 'Select' + 'On': 'kong.*' + User: kongreader + Database: + User: root + Host: !GetAtt 'Database.Endpoint.Address' + Port: !GetAtt 'Database.Endpoint.Port' + DBName: root + PasswordParameterName: !Sub '/${AWS::StackName}/mysql/root/PGPASSWORD' + ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-mysql-user-provider-${VPC}' diff --git a/docs/MySQLUserGrant.md b/docs/MySQLUserGrant.md new file mode 100644 index 0000000..9dce308 --- /dev/null +++ b/docs/MySQLUserGrant.md @@ -0,0 +1,48 @@ +# Custom::MySQLUserGrant +The `Custom::MySQLUserGrant`resource grants a MySQL user with or without grant options. + + +## Syntax +To declare this entity in your AWS CloudFormation template, use the following syntax: + +```yaml +Type: Custom::MySQLUserGrant +Properties: + Grant: [STRING] + On: STRING + User: STRING + WithGrantOption: true|false + Database: + Host: STRING + Port: INTEGER + Database: STRING + User: STRING + Password: STRING + PasswordParameterName: STRING + PasswordSecretName: STRING + ServiceToken: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:binxio-cfn-mysql-provider-vpc-${AppVPC}' +``` + +By default WithGrantOption is set to `false`. This means the user is unable to grant other users. To allow the user to grant other users, specify `true`. + +## Properties +You can specify the following properties: + +- `Grant` - the privileges to grant +- `On` - the privilege level to grant, use *.* for global grants +- `User` - the user to grant, use user@host-syntax +- `WithGrantOption` - if the user is allows to grant others, defaults to `false` +- `Database` - to create the user grant in + - `Host` - the database server is listening on. + - `Port` - port the database server is listening on. + - `Database` - name to connect to. + - `User` - name of the database owner. + - `Password` - to identify the user with. + - `PasswordParameterName` - name of the ssm parameter containing the password of the user + - `PasswordSecretName` - friendly name or the ARN of the secret in secrets manager containing the password of the user + +Either `Password`, `PasswordParameterName` or `PasswordSecretName` is required. + +## Return values +There are no return values from this resources. + diff --git a/mysql_user_provider.py b/mysql_user_provider.py new file mode 100644 index 0000000..729ec02 --- /dev/null +++ b/mysql_user_provider.py @@ -0,0 +1,14 @@ +import logging +import os + +from cfn_mysql_user_provider import mysql_user_provider, mysql_user_grant_provider + +logging.getLogger().setLevel(os.getenv("LOG_LEVEL", "INFO")) + +def handler(request, context): + if request['ResourceType'] == mysql_user_provider.request_resource: + return mysql_user_provider.handler(request, context) + elif request['ResourceType'] == mysql_user_grant_provider.request_resource: + return mysql_user_grant_provider.handler(request, context) + else: + return mysql_user_provider.handler(request, context) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 46eb375..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -boto3 -mysql-connector-python -requests -jsonschema -cfn_resource_provider>=1.0.1 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4fc2dde --- /dev/null +++ b/setup.cfg @@ -0,0 +1,20 @@ +[metadata] +name=cfn_mysql_user_provider +version=0.2.4 +author=Mark van Holsteijn & Laurens Knoll @ binx.io +author_email=binx@binx.io +project_urls= + Source Code = "https://github.com/binxio/cfn-mysql-user-provider" +requires-dist=setuptools + +[options] +package_dir= + =src +packages=find: +install_requires= + boto3 + mysql-connector-python + cfn_resource_provider>=1.0.4 + +[options.packages.find] +where=src \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..864b617 --- /dev/null +++ b/setup.py @@ -0,0 +1,2 @@ +import setuptools +setuptools.setup() \ No newline at end of file diff --git a/src/cfn_mysql_user_provider/__init__.py b/src/cfn_mysql_user_provider/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cfn_mysql_user_provider/mysql_database_provider.py b/src/cfn_mysql_user_provider/mysql_database_provider.py new file mode 100644 index 0000000..5a6a656 --- /dev/null +++ b/src/cfn_mysql_user_provider/mysql_database_provider.py @@ -0,0 +1,76 @@ +import logging + +import random +import string +import boto3 +from hashlib import sha1 +import mysql.connector +from botocore.exceptions import ClientError +from cfn_resource_provider import ResourceProvider + +log = logging.getLogger() + +class MySQLDatabaseProvider(ResourceProvider): + + def __init__(self): + super(MySQLDatabaseProvider, self).__init__() + self.ssm = boto3.client('ssm') + self.secretsmanager = boto3.client('secretsmanager') + self.connection = None + + @property + def dbowner_password(self): + db = self.get('Database') + if 'Password' in db: + return db.get('Password') + elif 'PasswordParameterName' in db: + return self.get_ssm_password(db['PasswordParameterName']) + else: + return self.get_sm_password(db['PasswordSecretName']) + + @property + def host(self): + return self.get('Database', {}).get('Host', None) + + @property + def port(self): + return self.get('Database', {}).get('Port', 3306) + + @property + def dbname(self): + return self.get('Database', {}).get('DBName', 'mysql') + + @property + def dbowner(self): + return self.get('Database', {}).get('User', None) + + @property + def connect_info(self): + return {'host': self.host, 'port': self.port, 'database': self.dbname, + 'user': self.dbowner, 'password': self.dbowner_password} + + def connect(self): + log.info('connecting to database %s on port %d as user %s', self.host, self.port, self.dbowner) + try: + self.connection = mysql.connector.connect(**self.connect_info) + except Exception as e: + raise ValueError('Failed to connect, %s' % e) + + def close(self): + if self.connection: + self.connection.close() + self.connection = None + + def get_ssm_password(self, name): + try: + response = self.ssm.get_parameter(Name=name, WithDecryption=True) + return response['Parameter']['Value'] + except ClientError as e: + raise ValueError('Could not obtain password using name {}, {}'.format(name, e)) + + def get_sm_password(self, name): + try: + response = self.secretsmanager.get_secret_value(SecretId=name) + return response['SecretString'] + except ClientError as e: + raise ValueError('Could not obtain password using name {}, {}'.format(name, e)) \ No newline at end of file diff --git a/src/cfn_mysql_user_provider/mysql_user_grant_provider.py b/src/cfn_mysql_user_provider/mysql_user_grant_provider.py new file mode 100644 index 0000000..b1ee2e8 --- /dev/null +++ b/src/cfn_mysql_user_provider/mysql_user_grant_provider.py @@ -0,0 +1,219 @@ +import logging + +import random +import string +import boto3 +from hashlib import sha1 +import mysql.connector +from botocore.exceptions import ClientError + +from cfn_mysql_user_provider.mysql_database_provider import MySQLDatabaseProvider + +log = logging.getLogger() + +request_resource = 'Custom::MySQLUserGrant' +request_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "oneOf": [ + {"required": ["Database", "Grant", "On", "User"]} + ], + "properties": { + "Database": {"$ref": "#/definitions/connection"}, + "Grant": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the privilege(s) to grant" + }, + "On": { + "type": "string", + "description": "specifies the level on which the privilege is granted" + }, + "User": { + "type": "string", + "pattern": "^[_$A-Za-z][A-Za-z0-9_$]*(@[.A-Za-z0-9%_$\\-]+)?$", + "maxLength": 32, + "description": "the user receiving the grant" + }, + "WithGrantOption": { + "type": "boolean", + "default": False, + "description": "allow the user to grant the grants" + }, + }, + "definitions": { + "connection": { + "type": "object", + "oneOf": [ + {"required": ["DBName", "Host", "Port", "User", "Password"]}, + {"required": ["DBName", "Host", "Port", "User", "PasswordParameterName"]}, + {"required": ["DBName", "Host", "Port", "User", "PasswordSecretName"]} + ], + "properties": { + "DBName": { + "type": "string", + "default": "mysql", + "description": "the name of the database" + }, + "Host": { + "type": "string", + "description": "the host of the database" + }, + "Port": { + "type": "integer", + "default": 3306, + "description": "the network port of the database" + }, + "User": { + "type": "string", + "maxLength": 32, + "description": "the username of the database owner" + }, + "Password": { + "type": "string", + "maxLength": 32, + "description": "the password of the database owner" + }, + "PasswordParameterName": { + "type": "string", + "description": "the name of the database owner password in the Parameter Store." + }, + "PasswordSecretName": { + "type": "string", + "description": "the name of the database owner password in the Secrets Manager." + } + } + } + } +} + +class MySQLUserGrantProvider(MySQLDatabaseProvider): + + def __init__(self): + super(MySQLUserGrantProvider, self).__init__() + self.request_schema = request_schema + + def convert_property_types(self): + self.heuristic_convert_property_types(self.properties) + + @property + def grant_set(self): + return self.get('Grant') + + @property + def grant_level(self): + return self.get('On') + + @property + def user(self): + return self.get('User') + + @property + def with_grant_option(self): + return self.get('WithGrantOption', False) + + @property + def url(self): + return "mysql:%s:%s:%s::%s:%s:%s:%r" % ( + self.host, self.port, self.dbname, + self.to_resource_format(self.grant_set), self.grant_level, self.user, + self.with_grant_option) + + def mysql_user(self, user): + return user.split('@')[0] + + def mysql_user_host(self, user): + parts = user.split('@') + return parts[1] if len(parts) > 1 else '%' + + def to_resource_format(self, grants): + return '+'.join(grants) + + def from_resource_format(self, grants): + return grants.split('+') + + def to_sql_format(self, grants): + return ','.join(grants) + + def grant_user(self): + log.info('granting %s on %s to %s (with_grant_option=%s)', + self.to_resource_format(self.grant_set), self.grant_level, self.user, + self.with_grant_option) + cursor = self.connection.cursor() + try: + if self.with_grant_option: + query = "GRANT %s ON %s TO '%s'@'%s' WITH GRANT OPTION" % ( + self.to_sql_format(self.grant_set), self.grant_level, + self.mysql_user(self.user), self.mysql_user_host(self.user)) + else: + query = "GRANT %s ON %s TO '%s'@'%s'" % ( + self.to_sql_format(self.grant_set), self.grant_level, + self.mysql_user(self.user), self.mysql_user_host(self.user)) + + cursor.execute(query) + finally: + cursor.close() + + def revoke_user(self): + _,_,_,_,_,res_grant_set,res_grant_level,res_user,res_with_grant_options = self.physical_resource_id.split(':') + log.info('revoking %s on %s to %s (with_grant_option=%s)', + res_grant_set, res_grant_level, res_user, + res_with_grant_options) + cursor = self.connection.cursor() + try: + query = "REVOKE %s ON %s FROM '%s'@'%s'" % ( + self.to_sql_format(self.from_resource_format(res_grant_set)), res_grant_level, + self.mysql_user(res_user), self.mysql_user_host(res_user)) + cursor.execute(query) + finally: + cursor.close() + + def create(self): + try: + self.connect() + self.grant_user() + self.physical_resource_id = self.url + except Exception as e: + self.physical_resource_id = 'could-not-create' + self.fail('Failed to grant user, %s' % e) + finally: + self.close() + + def update(self): + if self.url == self.physical_resource_id: + return + + try: + self.connect() + self.revoke_user() + self.grant_user() + self.physical_resource_id = self.url + except Exception as e: + self.fail('Failed to grant the user, %s' % e) + finally: + self.close() + + def delete(self): + if self.physical_resource_id == 'could-not-create': + self.success('user was never granted') + return + + try: + self.connect() + self.revoke_user() + except Exception as e: + return self.fail('Failed to revoke the user grant, %s' % e) + finally: + self.close() + + def is_supported_resource_type(self): + return self.resource_type == request_resource + +provider = MySQLUserGrantProvider() + + +def handler(request, context): + return provider.handle(request, context) + \ No newline at end of file diff --git a/src/mysql_user_provider.py b/src/cfn_mysql_user_provider/mysql_user_provider.py similarity index 81% rename from src/mysql_user_provider.py rename to src/cfn_mysql_user_provider/mysql_user_provider.py index 5bccf5e..9d335b1 100644 --- a/src/mysql_user_provider.py +++ b/src/cfn_mysql_user_provider/mysql_user_provider.py @@ -1,5 +1,4 @@ import logging -import os import random import string @@ -7,11 +6,12 @@ from hashlib import sha1 import mysql.connector from botocore.exceptions import ClientError -from cfn_resource_provider import ResourceProvider + +from cfn_mysql_user_provider.mysql_database_provider import MySQLDatabaseProvider log = logging.getLogger() -log.setLevel(os.environ.get("LOG_LEVEL", "INFO")) +request_resource = 'Custom::MySQLUser' request_schema = { "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", @@ -113,47 +113,23 @@ def mysql_password(passwd): return "*" + pass2.upper() -class MySQLUser(ResourceProvider): +class MySQLUserProvider(MySQLDatabaseProvider): def __init__(self): - super(MySQLUser, self).__init__() - self.ssm = boto3.client('ssm') - self.secretsmanager = boto3.client('secretsmanager') - self.connection = None + super(MySQLUserProvider, self).__init__() self.request_schema = request_schema def convert_property_types(self): self.heuristic_convert_property_types(self.properties) - def get_password(self, name): - try: - if 'PasswordParameterName' in self.properties: - response = self.ssm.get_parameter(Name=name, WithDecryption=True) - return response['Parameter']['Value'] - else: - response = self.secretsmanager.get_secret_value(SecretId=name) - return response['SecretString'] - except ClientError as e: - raise ValueError('Could not obtain password using name {}, {}'.format(name, e)) - @property def user_password(self): if 'Password' in self.properties: return self.get('Password') elif 'PasswordParameterName' in self.properties: - return self.get_password(self.get('PasswordParameterName')) - else: - return self.get_password(self.get('PasswordSecretName')) - - @property - def dbowner_password(self): - db = self.get('Database') - if 'Password' in db: - return db.get('Password') - elif 'PasswordParameterName' in db: - return self.get_password(db['PasswordParameterName']) + return self.get_ssm_password(self.get('PasswordParameterName')) else: - return self.get_password(db['PasswordSecretName']) + return self.get_sm_password(self.get('PasswordSecretName')) @property def user(self): @@ -168,35 +144,14 @@ def mysql_user_host(self): parts = self.user.split('@') return parts[1] if len(parts) > 1 else '%' - @property - def host(self): - return self.get('Database', {}).get('Host', None) - - @property - def port(self): - return self.get('Database', {}).get('Port', 3306) - - @property - def dbname(self): - return self.get('Database', {}).get('DBName', 'mysql') - - @property - def dbowner(self): - return self.get('Database', {}).get('User', None) - @property def with_database(self): - return self.get('WithDatabase', False) + return self.get('WithDatabase', True) @property def deletion_policy(self): return self.get('DeletionPolicy') - @property - def connect_info(self): - return {'host': self.host, 'port': self.port, 'database': self.dbname, - 'user': self.dbowner, 'password': self.dbowner_password} - @property def allow_update(self): return self.url == self.physical_resource_id @@ -208,18 +163,6 @@ def url(self): else: return 'mysql:%s:%s:%s::%s' % (self.host, self.port, self.dbname, self.user) - def connect(self): - log.info('connecting to database %s on port %d as user %s', self.host, self.port, self.dbowner) - try: - self.connection = mysql.connector.connect(**self.connect_info) - except Exception as e: - raise ValueError('Failed to connect, %s' % e) - - def close(self): - if self.connection: - self.connection.close() - self.connection = None - def db_exists(self): cursor = self.connection.cursor() try: @@ -376,8 +319,10 @@ def delete(self): finally: self.close() + def is_supported_resource_type(self): + return self.resource_type == request_resource -provider = MySQLUser() +provider = MySQLUserProvider() def handler(request, context): diff --git a/test-requirements.txt b/tests/test-requirements.txt similarity index 50% rename from test-requirements.txt rename to tests/test-requirements.txt index 9903e43..088a6eb 100644 --- a/test-requirements.txt +++ b/tests/test-requirements.txt @@ -1,2 +1,2 @@ awscli -pytest +pytest \ No newline at end of file diff --git a/tests/test_mysql_user_grant_provider.py b/tests/test_mysql_user_grant_provider.py new file mode 100644 index 0000000..53e2dd2 --- /dev/null +++ b/tests/test_mysql_user_grant_provider.py @@ -0,0 +1,244 @@ +import pytest +import uuid +import mysql.connector +import boto3 +import logging + +from cfn_mysql_user_provider.mysql_user_provider import handler + +logging.basicConfig(level=logging.INFO) + + +def nothing(self): + return self + + +def close_it(self, exception_type, exception_value, callback): + self.close() + return self + +mysql.connector.CMySQLConnection.__enter__ = nothing +mysql.connector.CMySQLConnection.__exit__ = close_it + +database_ports = [6033, 7033] +def get_database(port): + return { + 'User': 'root', + 'Password': 'password', + 'Host': 'localhost', + 'Port': port, + 'DBName': 'mysql' + } + +def get_database_connection(port): + db = get_database(port) + args = { + 'host': db['Host'], + 'port': db['Port'], + 'database': db['DBName'], + 'user': db['User'], + 'password': db['Password'] + } + result = mysql.connector.connect(**args) + return result + + +class UserGrantEvent(dict): + def __init__(self, request_type, grant_set, grant_level, user, physical_resource_id=None, port=None, with_grant_options=False): + self.update({ + 'RequestType': request_type, + 'ResponseURL': 'https://httpbin.org/put', + 'StackId': 'arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid', + 'RequestId': 'request-%s' % str(uuid.uuid4()), + 'ResourceType': 'Custom::MySQLUserGrant', + 'LogicalResourceId': 'Whatever', + 'ResourceProperties': { + 'Grant': grant_set, + 'On': grant_level, + 'User': user, + 'WithGrantOption': with_grant_options, + 'Database': get_database(port) + }}) + if physical_resource_id is not None: + self['PhysicalResourceId'] = physical_resource_id + + +class UserEvent(dict): + def __init__(self, request_type, user, physical_resource_id=None, port=None): + self.update({ + 'RequestType': request_type, + 'ResponseURL': 'https://httpbin.org/put', + 'StackId': 'arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid', + 'RequestId': 'request-%s' % str(uuid.uuid4()), + 'ResourceType': 'Custom::MySQLUser', + 'LogicalResourceId': 'Whatever', + 'ResourceProperties': { + 'User': user, + 'Password': 'password', + 'Database': get_database(port) + }}) + if physical_resource_id is not None: + self['PhysicalResourceId'] = physical_resource_id + + +def create_user(user, database_port): + event = UserEvent('Create', user, port=database_port) + response = handler(event, {}) + assert response['Status'] == 'SUCCESS', response['Reason'] + return response['PhysicalResourceId'] + + +def delete_user(resource, user, database_port): + event = UserEvent('Delete', user, physical_resource_id=resource, port=database_port) + response = handler(event, {}) + assert response['Status'] == 'SUCCESS', response['Reason'] + return response + + +@pytest.mark.parametrize("database_port", database_ports) +def test_create_grant(database_port): + user = 'singlegrant' + user_resource = create_user(user, database_port) + + event = UserGrantEvent('Create', [ 'All' ], '*.*', user, port=database_port) + response = handler(event, {}) + assert response['Status'] == 'SUCCESS', response['Reason'] + + with get_database_connection(database_port) as connection: + cursor = connection.cursor() + try: + cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) + rows = cursor.fetchall() + assert len(rows) != 0, 'User %s wasn''t granted' % user + assert len(rows) == 1, 'User %s has multiple grants' % user + + expected_grant = 'GRANT ALL PRIVILEGES ON *.* TO \'%s\'@\'%%\'' % user + + raw_user_grant, = rows[0] + user_grant_identified_by_index = raw_user_grant.index(" IDENTIFIED BY PASSWORD") + user_grant = raw_user_grant[0:user_grant_identified_by_index] + assert expected_grant == user_grant, 'User %s has no ALL PRIVILEGE on *.*. Grant=%s' % (user, user_grant) + finally: + cursor.close() + + delete_user(user_resource, user, database_port) + + +@pytest.mark.parametrize("database_port", database_ports) +def test_create_multiple_grant(database_port): + user = 'multigrant' + user_resource = create_user(user, database_port) + + event = UserGrantEvent('Create', [ 'Select', 'Insert' ], '*.*', user, port=database_port) + response = handler(event, {}) + assert response['Status'] == 'SUCCESS', response['Reason'] + + with get_database_connection(database_port) as connection: + cursor = connection.cursor() + try: + cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) + rows = cursor.fetchall() + assert len(rows) != 0, 'User %s wasn''t granted' % user + assert len(rows) == 1, 'User %s has multiple grants' % user + + expected_grant = 'GRANT SELECT, INSERT ON *.* TO \'%s\'@\'%%\'' % user + + raw_user_grant, = rows[0] + user_grant_identified_by_index = raw_user_grant.index(" IDENTIFIED BY PASSWORD") + user_grant = raw_user_grant[0:user_grant_identified_by_index] + assert expected_grant == user_grant, 'User %s has no SELECT,INSERT on *.*. Grant=%s' % (user, user_grant) + finally: + cursor.close() + + delete_user(user_resource, user, database_port) + + +@pytest.mark.parametrize("database_port", database_ports) +def test_update_grant(database_port): + user = 'updategrant' + user_resource = create_user(user, database_port) + + create_event = UserGrantEvent('Create', [ 'Select' ], '*.*', user, port=database_port) + create_response = handler(create_event, {}) + assert create_response['Status'] == 'SUCCESS', create_response['Reason'] + assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" + + update_event = UserGrantEvent('Update', [ 'Insert' ], '*.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) + update_response = handler(update_event, {}) + assert update_response['Status'] == 'SUCCESS', update_response['Reason'] + assert 'PhysicalResourceId' in update_response, "PhysicalResourceId not provided after Update" + assert create_response['PhysicalResourceId'] != update_response['PhysicalResourceId'], "Expected updated PhysicalResourceId" + + with get_database_connection(database_port) as connection: + cursor = connection.cursor() + try: + cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) + rows = cursor.fetchall() + assert len(rows) != 0, 'User %s wasn''t granted' % user + assert len(rows) == 1, 'User %s has multiple grants' % user + + expected_grant = 'GRANT INSERT ON *.* TO \'%s\'@\'%%\'' % user + + raw_user_grant, = rows[0] + user_grant_identified_by_index = raw_user_grant.index(" IDENTIFIED BY PASSWORD") + user_grant = raw_user_grant[0:user_grant_identified_by_index] + assert expected_grant == user_grant, 'User %s has no INSERT on *.*. Grant=%s' % (user, user_grant) + finally: + cursor.close() + + delete_user(user_resource, user, database_port) + + +@pytest.mark.parametrize("database_port", database_ports) +def test_delete_grant(database_port): + user = 'deletegrant' + user_resource = create_user(user, database_port) + + create_event = UserGrantEvent('Create', [ 'Select' ], '*.*', user, port=database_port) + create_response = handler(create_event, {}) + assert create_response['Status'] == 'SUCCESS', create_response['Reason'] + assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" + + delete_event = UserGrantEvent('Delete', [ 'Select' ], '*.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) + delete_response = handler(delete_event, {}) + assert delete_response['Status'] == 'SUCCESS', delete_response['Reason'] + + with get_database_connection(database_port) as connection: + cursor = connection.cursor() + try: + cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) + rows = cursor.fetchall() + assert len(rows) == 0, 'User %s is still granted' % user + finally: + cursor.close() + + delete_user(user_resource, user, database_port) + + +@pytest.mark.parametrize("database_port", database_ports) +def test_delete_grant_all(database_port): + user = 'deletegrant_all' + user_resource = create_user(user, database_port) + + create_event = UserGrantEvent('Create', [ 'All' ], '*.*', user, port=database_port) + create_response = handler(create_event, {}) + assert create_response['Status'] == 'SUCCESS', create_response['Reason'] + assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" + + delete_event = UserGrantEvent('Delete', [ 'All' ], '*.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) + delete_response = handler(delete_event, {}) + assert delete_response['Status'] == 'SUCCESS', delete_response['Reason'] + + with get_database_connection(database_port) as connection: + cursor = connection.cursor() + try: + cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) + rows = cursor.fetchall() + assert len(rows) == 0, 'User %s is still granted' % user + finally: + cursor.close() + + delete_user(user_resource, user, database_port) \ No newline at end of file diff --git a/tests/test_mysql_user_provider.py b/tests/test_mysql_user_provider.py index cab8a2a..7fd95da 100644 --- a/tests/test_mysql_user_provider.py +++ b/tests/test_mysql_user_provider.py @@ -3,7 +3,8 @@ import mysql.connector import boto3 import logging -from mysql_user_provider import handler, request_schema + +from cfn_mysql_user_provider.mysql_user_provider import handler logging.basicConfig(level=logging.INFO) From 86175066718184e2283e7392cfc34623199723f6 Mon Sep 17 00:00:00 2001 From: Laurens Knoll Date: Thu, 27 Feb 2020 12:02:34 +0100 Subject: [PATCH 2/6] Reformatted with autopep --- mysql_user_provider.py | 3 +- .../mysql_database_provider.py | 7 +++-- .../mysql_user_grant_provider.py | 17 +++++------ .../mysql_user_provider.py | 2 +- tests/test_mysql_user_grant_provider.py | 28 +++++++++++-------- tests/test_mysql_user_provider.py | 2 ++ 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/mysql_user_provider.py b/mysql_user_provider.py index 729ec02..73f63fe 100644 --- a/mysql_user_provider.py +++ b/mysql_user_provider.py @@ -5,10 +5,11 @@ logging.getLogger().setLevel(os.getenv("LOG_LEVEL", "INFO")) + def handler(request, context): if request['ResourceType'] == mysql_user_provider.request_resource: return mysql_user_provider.handler(request, context) elif request['ResourceType'] == mysql_user_grant_provider.request_resource: return mysql_user_grant_provider.handler(request, context) else: - return mysql_user_provider.handler(request, context) \ No newline at end of file + return mysql_user_provider.handler(request, context) diff --git a/src/cfn_mysql_user_provider/mysql_database_provider.py b/src/cfn_mysql_user_provider/mysql_database_provider.py index 5a6a656..9fb7a5e 100644 --- a/src/cfn_mysql_user_provider/mysql_database_provider.py +++ b/src/cfn_mysql_user_provider/mysql_database_provider.py @@ -10,6 +10,7 @@ log = logging.getLogger() + class MySQLDatabaseProvider(ResourceProvider): def __init__(self): @@ -60,17 +61,17 @@ def close(self): if self.connection: self.connection.close() self.connection = None - + def get_ssm_password(self, name): try: response = self.ssm.get_parameter(Name=name, WithDecryption=True) return response['Parameter']['Value'] except ClientError as e: raise ValueError('Could not obtain password using name {}, {}'.format(name, e)) - + def get_sm_password(self, name): try: response = self.secretsmanager.get_secret_value(SecretId=name) return response['SecretString'] except ClientError as e: - raise ValueError('Could not obtain password using name {}, {}'.format(name, e)) \ No newline at end of file + raise ValueError('Could not obtain password using name {}, {}'.format(name, e)) diff --git a/src/cfn_mysql_user_provider/mysql_user_grant_provider.py b/src/cfn_mysql_user_provider/mysql_user_grant_provider.py index b1ee2e8..16feea5 100644 --- a/src/cfn_mysql_user_provider/mysql_user_grant_provider.py +++ b/src/cfn_mysql_user_provider/mysql_user_grant_provider.py @@ -89,6 +89,7 @@ } } + class MySQLUserGrantProvider(MySQLDatabaseProvider): def __init__(self): @@ -130,7 +131,7 @@ def mysql_user_host(self, user): def to_resource_format(self, grants): return '+'.join(grants) - + def from_resource_format(self, grants): return grants.split('+') @@ -138,9 +139,9 @@ def to_sql_format(self, grants): return ','.join(grants) def grant_user(self): - log.info('granting %s on %s to %s (with_grant_option=%s)', - self.to_resource_format(self.grant_set), self.grant_level, self.user, - self.with_grant_option) + log.info('granting %s on %s to %s (with_grant_option=%s)', + self.to_resource_format(self.grant_set), self.grant_level, self.user, + self.with_grant_option) cursor = self.connection.cursor() try: if self.with_grant_option: @@ -157,10 +158,10 @@ def grant_user(self): cursor.close() def revoke_user(self): - _,_,_,_,_,res_grant_set,res_grant_level,res_user,res_with_grant_options = self.physical_resource_id.split(':') + _, _, _, _, _, res_grant_set, res_grant_level, res_user, res_with_grant_options = self.physical_resource_id.split(':') log.info('revoking %s on %s to %s (with_grant_option=%s)', - res_grant_set, res_grant_level, res_user, - res_with_grant_options) + res_grant_set, res_grant_level, res_user, + res_with_grant_options) cursor = self.connection.cursor() try: query = "REVOKE %s ON %s FROM '%s'@'%s'" % ( @@ -211,9 +212,9 @@ def delete(self): def is_supported_resource_type(self): return self.resource_type == request_resource + provider = MySQLUserGrantProvider() def handler(request, context): return provider.handle(request, context) - \ No newline at end of file diff --git a/src/cfn_mysql_user_provider/mysql_user_provider.py b/src/cfn_mysql_user_provider/mysql_user_provider.py index 9d335b1..d949a43 100644 --- a/src/cfn_mysql_user_provider/mysql_user_provider.py +++ b/src/cfn_mysql_user_provider/mysql_user_provider.py @@ -322,9 +322,9 @@ def delete(self): def is_supported_resource_type(self): return self.resource_type == request_resource + provider = MySQLUserProvider() def handler(request, context): return provider.handle(request, context) - \ No newline at end of file diff --git a/tests/test_mysql_user_grant_provider.py b/tests/test_mysql_user_grant_provider.py index 53e2dd2..ecc3b1b 100644 --- a/tests/test_mysql_user_grant_provider.py +++ b/tests/test_mysql_user_grant_provider.py @@ -17,10 +17,13 @@ def close_it(self, exception_type, exception_value, callback): self.close() return self + mysql.connector.CMySQLConnection.__enter__ = nothing mysql.connector.CMySQLConnection.__exit__ = close_it database_ports = [6033, 7033] + + def get_database(port): return { 'User': 'root', @@ -30,6 +33,7 @@ def get_database(port): 'DBName': 'mysql' } + def get_database_connection(port): db = get_database(port) args = { @@ -100,7 +104,7 @@ def test_create_grant(database_port): user = 'singlegrant' user_resource = create_user(user, database_port) - event = UserGrantEvent('Create', [ 'All' ], '*.*', user, port=database_port) + event = UserGrantEvent('Create', ['All'], '*.*', user, port=database_port) response = handler(event, {}) assert response['Status'] == 'SUCCESS', response['Reason'] @@ -129,7 +133,7 @@ def test_create_multiple_grant(database_port): user = 'multigrant' user_resource = create_user(user, database_port) - event = UserGrantEvent('Create', [ 'Select', 'Insert' ], '*.*', user, port=database_port) + event = UserGrantEvent('Create', ['Select', 'Insert'], '*.*', user, port=database_port) response = handler(event, {}) assert response['Status'] == 'SUCCESS', response['Reason'] @@ -158,13 +162,13 @@ def test_update_grant(database_port): user = 'updategrant' user_resource = create_user(user, database_port) - create_event = UserGrantEvent('Create', [ 'Select' ], '*.*', user, port=database_port) + create_event = UserGrantEvent('Create', ['Select'], '*.*', user, port=database_port) create_response = handler(create_event, {}) assert create_response['Status'] == 'SUCCESS', create_response['Reason'] assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" - update_event = UserGrantEvent('Update', [ 'Insert' ], '*.*', user, port=database_port, - physical_resource_id=create_response['PhysicalResourceId']) + update_event = UserGrantEvent('Update', ['Insert'], '*.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) update_response = handler(update_event, {}) assert update_response['Status'] == 'SUCCESS', update_response['Reason'] assert 'PhysicalResourceId' in update_response, "PhysicalResourceId not provided after Update" @@ -195,13 +199,13 @@ def test_delete_grant(database_port): user = 'deletegrant' user_resource = create_user(user, database_port) - create_event = UserGrantEvent('Create', [ 'Select' ], '*.*', user, port=database_port) + create_event = UserGrantEvent('Create', ['Select'], '*.*', user, port=database_port) create_response = handler(create_event, {}) assert create_response['Status'] == 'SUCCESS', create_response['Reason'] assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" - delete_event = UserGrantEvent('Delete', [ 'Select' ], '*.*', user, port=database_port, - physical_resource_id=create_response['PhysicalResourceId']) + delete_event = UserGrantEvent('Delete', ['Select'], '*.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) delete_response = handler(delete_event, {}) assert delete_response['Status'] == 'SUCCESS', delete_response['Reason'] @@ -222,13 +226,13 @@ def test_delete_grant_all(database_port): user = 'deletegrant_all' user_resource = create_user(user, database_port) - create_event = UserGrantEvent('Create', [ 'All' ], '*.*', user, port=database_port) + create_event = UserGrantEvent('Create', ['All'], '*.*', user, port=database_port) create_response = handler(create_event, {}) assert create_response['Status'] == 'SUCCESS', create_response['Reason'] assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" - delete_event = UserGrantEvent('Delete', [ 'All' ], '*.*', user, port=database_port, - physical_resource_id=create_response['PhysicalResourceId']) + delete_event = UserGrantEvent('Delete', ['All'], '*.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) delete_response = handler(delete_event, {}) assert delete_response['Status'] == 'SUCCESS', delete_response['Reason'] @@ -241,4 +245,4 @@ def test_delete_grant_all(database_port): finally: cursor.close() - delete_user(user_resource, user, database_port) \ No newline at end of file + delete_user(user_resource, user, database_port) diff --git a/tests/test_mysql_user_provider.py b/tests/test_mysql_user_provider.py index 7fd95da..f197f38 100644 --- a/tests/test_mysql_user_provider.py +++ b/tests/test_mysql_user_provider.py @@ -17,6 +17,7 @@ def close_it(self, exception_type, exception_value, callback): self.close() return self + mysql.connector.CMySQLConnection.__enter__ = nothing mysql.connector.CMySQLConnection.__exit__ = close_it @@ -60,6 +61,7 @@ def test_user_connection(self, password=None): raise return result + database_ports = [6033, 7033] From f4371486e94fbaf056b6c05c41dda9bf9b73a3a0 Mon Sep 17 00:00:00 2001 From: Laurens Knoll Date: Thu, 27 Feb 2020 12:11:09 +0100 Subject: [PATCH 3/6] make autopep now uses the virtual environment. --- Makefile | 9 ++++++--- requirements.txt | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 requirements.txt diff --git a/Makefile b/Makefile index c3bd573..09d9075 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,8 @@ venv: clean: rm -rf .venv target - rm -rf src/*.pyc tests/*.pyc + find src/ -name '*.pyc' -delete + find tests/ -name '*.pyc' -delete test: venv for i in $$PWD/cloudformation/*; do \ @@ -73,8 +74,10 @@ test: venv pip install --quiet -r tests/test-requirements.txt && \ py.test tests -autopep: - autopep8 --experimental --in-place --max-line-length 132 mysql_user_provider.py src/*.py tests/*.py +autopep: venv + . ./.venv/bin/activate && \ + pip install --quiet -r requirements.txt && \ + autopep8 --experimental --in-place --max-line-length 132 mysql_user_provider.py src/cfn_mysql_user_provider/*.py tests/*.py deploy-provider: deploy @set -x ;if aws cloudformation get-template-summary --stack-name $(NAME) >/dev/null 2>&1 ; then \ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..833e39e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +awscli +autopep8 \ No newline at end of file From cef5d4b62ea5682e17e439340db9849498b36134 Mon Sep 17 00:00:00 2001 From: Laurens Knoll Date: Thu, 27 Feb 2020 12:40:55 +0100 Subject: [PATCH 4/6] Fixed a bug: Log level was not applied in module loggers --- Makefile | 11 +++++------ mysql_user_provider.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 09d9075..9e1c517 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ NAME=cfn-mysql-user-provider AWS_REGION=eu-central-1 S3_BUCKET_PREFIX=binxio-public S3_BUCKET=$(S3_BUCKET_PREFIX)-$(AWS_REGION) +S3_COPY_ARGS="--acl public-read" ALL_REGIONS=$(shell printf "import boto3\nprint('\\\n'.join(map(lambda r: r['RegionName'], boto3.client('ec2').describe_regions()['Regions'])))\n" | python | grep -v '^$(AWS_REGION)$$') @@ -20,12 +21,10 @@ help: @echo 'make delete-demo - deletes the demo cloudformation stack.' deploy: target/$(NAME)-$(VERSION).zip - aws s3 cp \ - --acl public-read \ + aws s3 cp ${S3_COPY_ARGS} \ target/$(NAME)-$(VERSION).zip \ s3://$(S3_BUCKET)/lambdas/$(NAME)-$(VERSION).zip - aws s3 cp \ - --acl public-read \ + aws s3 cp ${S3_COPY_ARGS} \ s3://$(S3_BUCKET)/lambdas/$(NAME)-$(VERSION).zip \ s3://$(S3_BUCKET)/lambdas/$(NAME)-latest.zip @@ -33,11 +32,11 @@ deploy-all-regions: deploy @for REGION in $(ALL_REGIONS); do \ echo "copying to region $$REGION.." ; \ aws s3 --region $(AWS_REGION) \ - cp --acl public-read \ + cp ${S3_COPY_ARGS} \ s3://$(S3_BUCKET_PREFIX)-$(AWS_REGION)/lambdas/$(NAME)-$(VERSION).zip \ s3://$(S3_BUCKET_PREFIX)-$$REGION/lambdas/$(NAME)-$(VERSION).zip; \ aws s3 --region $$REGION \ - cp --acl public-read \ + cp ${S3_COPY_ARGS} \ s3://$(S3_BUCKET_PREFIX)-$$REGION/lambdas/$(NAME)-$(VERSION).zip \ s3://$(S3_BUCKET_PREFIX)-$$REGION/lambdas/$(NAME)-latest.zip; \ done diff --git a/mysql_user_provider.py b/mysql_user_provider.py index 73f63fe..d70f711 100644 --- a/mysql_user_provider.py +++ b/mysql_user_provider.py @@ -3,7 +3,7 @@ from cfn_mysql_user_provider import mysql_user_provider, mysql_user_grant_provider -logging.getLogger().setLevel(os.getenv("LOG_LEVEL", "INFO")) +logging.root.setLevel(os.getenv("LOG_LEVEL", "INFO")) def handler(request, context): From 94070981b855af4751fa726b227e14aa384d1ac6 Mon Sep 17 00:00:00 2001 From: Laurens Knoll Date: Tue, 3 Mar 2020 17:31:02 +0100 Subject: [PATCH 5/6] Included documentation on resource replacement. Updated tests accordingly. --- docs/MySQLUserGrant.md | 6 +- .../mysql_database_provider.py | 12 ++ .../mysql_user_grant_provider.py | 74 +++++++--- tests/test_mysql_user_grant_provider.py | 130 ++++++++++++------ 4 files changed, 157 insertions(+), 65 deletions(-) diff --git a/docs/MySQLUserGrant.md b/docs/MySQLUserGrant.md index 9dce308..1e3a5d0 100644 --- a/docs/MySQLUserGrant.md +++ b/docs/MySQLUserGrant.md @@ -29,10 +29,10 @@ By default WithGrantOption is set to `false`. This means the user is unable to g You can specify the following properties: - `Grant` - the privileges to grant -- `On` - the privilege level to grant, use *.* for global grants -- `User` - the user to grant, use user@host-syntax +- `On` - the privilege level to grant, use *.* for global grants. Update requires replacement. +- `User` - the user to grant, use user@host-syntax. Update requires replacement. - `WithGrantOption` - if the user is allows to grant others, defaults to `false` -- `Database` - to create the user grant in +- `Database` - to create the user grant in. Update requires replacement. - `Host` - the database server is listening on. - `Port` - port the database server is listening on. - `Database` - name to connect to. diff --git a/src/cfn_mysql_user_provider/mysql_database_provider.py b/src/cfn_mysql_user_provider/mysql_database_provider.py index 9fb7a5e..0550ca6 100644 --- a/src/cfn_mysql_user_provider/mysql_database_provider.py +++ b/src/cfn_mysql_user_provider/mysql_database_provider.py @@ -32,15 +32,27 @@ def dbowner_password(self): @property def host(self): return self.get('Database', {}).get('Host', None) + + @property + def host_old(self): + return self.get_old('Database', {}).get('Host', None) @property def port(self): return self.get('Database', {}).get('Port', 3306) + @property + def port_old(self): + return self.get_old('Database', {}).get('Port', 3306) + @property def dbname(self): return self.get('Database', {}).get('DBName', 'mysql') + @property + def dbname_old(self): + return self.get_old('Database', {}).get('DBName', 'mysql') + @property def dbowner(self): return self.get('Database', {}).get('User', None) diff --git a/src/cfn_mysql_user_provider/mysql_user_grant_provider.py b/src/cfn_mysql_user_provider/mysql_user_grant_provider.py index 16feea5..f073628 100644 --- a/src/cfn_mysql_user_provider/mysql_user_grant_provider.py +++ b/src/cfn_mysql_user_provider/mysql_user_grant_provider.py @@ -29,13 +29,13 @@ }, "On": { "type": "string", - "description": "specifies the level on which the privilege is granted" + "description": "specifies the level on which the privilege is granted, update requires replacement." }, "User": { "type": "string", "pattern": "^[_$A-Za-z][A-Za-z0-9_$]*(@[.A-Za-z0-9%_$\\-]+)?$", "maxLength": 32, - "description": "the user receiving the grant" + "description": "the user receiving the grant, update requires replacement." }, "WithGrantOption": { "type": "boolean", @@ -102,25 +102,38 @@ def convert_property_types(self): @property def grant_set(self): return self.get('Grant') + + @property + def grant_set_old(self): + return self.get_old('Grant') @property def grant_level(self): return self.get('On') + @property + def grant_level_old(self): + return self.get_old('On') + @property def user(self): return self.get('User') + def user_old(self): + return self.get_old('User') + @property def with_grant_option(self): return self.get('WithGrantOption', False) + @property + def with_grant_option_old(self): + return self.get_old('WithGrantOption', False) + @property def url(self): - return "mysql:%s:%s:%s::%s:%s:%s:%r" % ( - self.host, self.port, self.dbname, - self.to_resource_format(self.grant_set), self.grant_level, self.user, - self.with_grant_option) + return "mysql:%s:grants:%s:%s" % ( + self.dbname, self.user, self.grant_level) def mysql_user(self, user): return user.split('@')[0] @@ -129,18 +142,15 @@ def mysql_user_host(self, user): parts = user.split('@') return parts[1] if len(parts) > 1 else '%' - def to_resource_format(self, grants): - return '+'.join(grants) - - def from_resource_format(self, grants): - return grants.split('+') + def to_log_format(self, grants): + return ', '.join(grants) def to_sql_format(self, grants): return ','.join(grants) def grant_user(self): log.info('granting %s on %s to %s (with_grant_option=%s)', - self.to_resource_format(self.grant_set), self.grant_level, self.user, + self.to_log_format(self.grant_set), self.grant_level, self.user, self.with_grant_option) cursor = self.connection.cursor() try: @@ -158,15 +168,29 @@ def grant_user(self): cursor.close() def revoke_user(self): - _, _, _, _, _, res_grant_set, res_grant_level, res_user, res_with_grant_options = self.physical_resource_id.split(':') log.info('revoking %s on %s to %s (with_grant_option=%s)', - res_grant_set, res_grant_level, res_user, - res_with_grant_options) + self.to_log_format(self.grant_set), self.grant_level, self.user, + self.with_grant_option) + cursor = self.connection.cursor() try: query = "REVOKE %s ON %s FROM '%s'@'%s'" % ( - self.to_sql_format(self.from_resource_format(res_grant_set)), res_grant_level, - self.mysql_user(res_user), self.mysql_user_host(res_user)) + self.to_sql_format(self.grant_set), self.grant_level, + self.mysql_user(self.user), self.mysql_user_host(self.user)) + cursor.execute(query) + finally: + cursor.close() + + def revoke_user_old(self): + log.info('revoking %s on %s to %s (with_grant_option=%s)', + self.to_log_format(self.grant_set_old), self.grant_level_old, self.user_old, + self.with_grant_option_old) + + cursor = self.connection.cursor() + try: + query = "REVOKE %s ON %s FROM '%s'@'%s'" % ( + self.to_sql_format(self.grant_set_old), self.grant_level_old, + self.mysql_user(self.user_old), self.mysql_user_host(self.user_old)) cursor.execute(query) finally: cursor.close() @@ -183,14 +207,22 @@ def create(self): self.close() def update(self): - if self.url == self.physical_resource_id: - return + if (self.dbname != self.dbname_old or + self.user != self.user_old or + self.grant_level != self.grant_level_old): + # Major change, recreate.. + return self.create() + + if (self.grant_level == self.grant_level_old and + self.grant_set == self.grant_set_old and + self.with_grant_option == self.with_grant_option_old): + # Unchanged, nothing to do.. + return try: self.connect() - self.revoke_user() + self.revoke_user_old() self.grant_user() - self.physical_resource_id = self.url except Exception as e: self.fail('Failed to grant the user, %s' % e) finally: diff --git a/tests/test_mysql_user_grant_provider.py b/tests/test_mysql_user_grant_provider.py index ecc3b1b..fbc4671 100644 --- a/tests/test_mysql_user_grant_provider.py +++ b/tests/test_mysql_user_grant_provider.py @@ -4,7 +4,7 @@ import boto3 import logging -from cfn_mysql_user_provider.mysql_user_provider import handler +from cfn_mysql_user_provider import mysql_user_provider, mysql_user_grant_provider logging.basicConfig(level=logging.INFO) @@ -79,6 +79,7 @@ def __init__(self, request_type, user, physical_resource_id=None, port=None): 'ResourceProperties': { 'User': user, 'Password': 'password', + 'WithDatabase': False, 'Database': get_database(port) }}) if physical_resource_id is not None: @@ -87,14 +88,14 @@ def __init__(self, request_type, user, physical_resource_id=None, port=None): def create_user(user, database_port): event = UserEvent('Create', user, port=database_port) - response = handler(event, {}) + response = mysql_user_provider.handler(event, {}) assert response['Status'] == 'SUCCESS', response['Reason'] return response['PhysicalResourceId'] def delete_user(resource, user, database_port): event = UserEvent('Delete', user, physical_resource_id=resource, port=database_port) - response = handler(event, {}) + response = mysql_user_provider.handler(event, {}) assert response['Status'] == 'SUCCESS', response['Reason'] return response @@ -105,7 +106,7 @@ def test_create_grant(database_port): user_resource = create_user(user, database_port) event = UserGrantEvent('Create', ['All'], '*.*', user, port=database_port) - response = handler(event, {}) + response = mysql_user_grant_provider.handler(event, {}) assert response['Status'] == 'SUCCESS', response['Reason'] with get_database_connection(database_port) as connection: @@ -113,18 +114,15 @@ def test_create_grant(database_port): try: cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) rows = cursor.fetchall() - assert len(rows) != 0, 'User %s wasn''t granted' % user - assert len(rows) == 1, 'User %s has multiple grants' % user - - expected_grant = 'GRANT ALL PRIVILEGES ON *.* TO \'%s\'@\'%%\'' % user - - raw_user_grant, = rows[0] - user_grant_identified_by_index = raw_user_grant.index(" IDENTIFIED BY PASSWORD") - user_grant = raw_user_grant[0:user_grant_identified_by_index] - assert expected_grant == user_grant, 'User %s has no ALL PRIVILEGE on *.*. Grant=%s' % (user, user_grant) finally: cursor.close() + assert len(rows) != 0, 'User %s isn''t granted' % user + + sql_grants = [parse_grant(r) for r in rows] + expected_grant = 'GRANT ALL PRIVILEGES ON *.* TO \'%s\'@\'%%\'' % user + assert expected_grant in sql_grants, 'User %s has no ALL PRIVILEGE on *.*' % (user) + delete_user(user_resource, user, database_port) @@ -134,7 +132,7 @@ def test_create_multiple_grant(database_port): user_resource = create_user(user, database_port) event = UserGrantEvent('Create', ['Select', 'Insert'], '*.*', user, port=database_port) - response = handler(event, {}) + response = mysql_user_grant_provider.handler(event, {}) assert response['Status'] == 'SUCCESS', response['Reason'] with get_database_connection(database_port) as connection: @@ -142,17 +140,54 @@ def test_create_multiple_grant(database_port): try: cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) rows = cursor.fetchall() - assert len(rows) != 0, 'User %s wasn''t granted' % user - assert len(rows) == 1, 'User %s has multiple grants' % user + finally: + cursor.close() + + assert len(rows) != 0, 'User %s isn''t granted' % user + + sql_grants = [parse_grant(r) for r in rows] + expected_grant = 'GRANT SELECT, INSERT ON *.* TO \'%s\'@\'%%\'' % user + assert expected_grant in sql_grants, 'User %s has no SELECT, INSERT on *.*' % (user) + + delete_user(user_resource, user, database_port) - expected_grant = 'GRANT SELECT, INSERT ON *.* TO \'%s\'@\'%%\'' % user - raw_user_grant, = rows[0] - user_grant_identified_by_index = raw_user_grant.index(" IDENTIFIED BY PASSWORD") - user_grant = raw_user_grant[0:user_grant_identified_by_index] - assert expected_grant == user_grant, 'User %s has no SELECT,INSERT on *.*. Grant=%s' % (user, user_grant) +@pytest.mark.parametrize("database_port", database_ports) +def test_recreate_grant(database_port): + user = 'recreategrant' + user_resource = create_user(user, database_port) + + create_event = UserGrantEvent('Create', ['Select'], '*.*', user, port=database_port) + create_response = mysql_user_grant_provider.handler(create_event, {}) + assert create_response['Status'] == 'SUCCESS', create_response['Reason'] + assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" + + update_event = UserGrantEvent('Update', ['Select','Insert'], 'test.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) + update_response = mysql_user_grant_provider.handler(update_event, {}) + assert update_response['Status'] == 'SUCCESS', update_response['Reason'] + assert 'PhysicalResourceId' in update_response, "PhysicalResourceId not provided after Update" + assert create_response['PhysicalResourceId'] != update_response['PhysicalResourceId'], "Expected updated PhysicalResourceId" + + # Sending Delete to match recreate flow.. + delete_event = UserGrantEvent('Delete', ['Select'], '*.*', user, port=database_port, + physical_resource_id=create_response['PhysicalResourceId']) + delete_response = mysql_user_grant_provider.handler(delete_event, {}) + assert delete_response['Status'] == 'SUCCESS', delete_response['Reason'] + + with get_database_connection(database_port) as connection: + cursor = connection.cursor() + try: + cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) + rows = cursor.fetchall() finally: cursor.close() + + assert len(rows) != 0, 'User %s isn''t granted' % user + + sql_grants = [parse_grant(r) for r in rows] + expected_grant = 'GRANT SELECT, INSERT ON `test`.* TO \'%s\'@\'%%\'' % user + assert expected_grant in sql_grants, 'User %s has no SELECT, INSERT on `test`.*' % (user) delete_user(user_resource, user, database_port) @@ -163,36 +198,32 @@ def test_update_grant(database_port): user_resource = create_user(user, database_port) create_event = UserGrantEvent('Create', ['Select'], '*.*', user, port=database_port) - create_response = handler(create_event, {}) + create_response = mysql_user_grant_provider.handler(create_event, {}) assert create_response['Status'] == 'SUCCESS', create_response['Reason'] assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" - update_event = UserGrantEvent('Update', ['Insert'], '*.*', user, port=database_port, + update_event = UserGrantEvent('Update', ['Select','Insert'], '*.*', user, port=database_port, physical_resource_id=create_response['PhysicalResourceId']) - update_response = handler(update_event, {}) + update_response = mysql_user_grant_provider.handler(update_event, {}) assert update_response['Status'] == 'SUCCESS', update_response['Reason'] assert 'PhysicalResourceId' in update_response, "PhysicalResourceId not provided after Update" - assert create_response['PhysicalResourceId'] != update_response['PhysicalResourceId'], "Expected updated PhysicalResourceId" + assert create_response['PhysicalResourceId'] == update_response['PhysicalResourceId'], "PhysicalResourceId changed after Update" with get_database_connection(database_port) as connection: cursor = connection.cursor() try: cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) rows = cursor.fetchall() - assert len(rows) != 0, 'User %s wasn''t granted' % user - assert len(rows) == 1, 'User %s has multiple grants' % user - - expected_grant = 'GRANT INSERT ON *.* TO \'%s\'@\'%%\'' % user - - raw_user_grant, = rows[0] - user_grant_identified_by_index = raw_user_grant.index(" IDENTIFIED BY PASSWORD") - user_grant = raw_user_grant[0:user_grant_identified_by_index] - assert expected_grant == user_grant, 'User %s has no INSERT on *.*. Grant=%s' % (user, user_grant) finally: cursor.close() + + assert len(rows) != 0, 'User %s isn''t granted' % user - delete_user(user_resource, user, database_port) + sql_grants = [parse_grant(r) for r in rows] + expected_grant = 'GRANT SELECT, INSERT ON *.* TO \'%s\'@\'%%\'' % user + assert expected_grant in sql_grants, 'User %s has no SELECT, INSERT on *.*' % (user) + delete_user(user_resource, user, database_port) @pytest.mark.parametrize("database_port", database_ports) def test_delete_grant(database_port): @@ -200,13 +231,13 @@ def test_delete_grant(database_port): user_resource = create_user(user, database_port) create_event = UserGrantEvent('Create', ['Select'], '*.*', user, port=database_port) - create_response = handler(create_event, {}) + create_response = mysql_user_grant_provider.handler(create_event, {}) assert create_response['Status'] == 'SUCCESS', create_response['Reason'] assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" delete_event = UserGrantEvent('Delete', ['Select'], '*.*', user, port=database_port, physical_resource_id=create_response['PhysicalResourceId']) - delete_response = handler(delete_event, {}) + delete_response = mysql_user_grant_provider.handler(delete_event, {}) assert delete_response['Status'] == 'SUCCESS', delete_response['Reason'] with get_database_connection(database_port) as connection: @@ -214,10 +245,14 @@ def test_delete_grant(database_port): try: cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) rows = cursor.fetchall() - assert len(rows) == 0, 'User %s is still granted' % user finally: cursor.close() + if len(rows) != 0: + sql_grants = [parse_grant(r) for r in rows] + expected_grant = 'GRANT USAGE ON *.* TO \'%s\'@\'%%\'' % user + assert expected_grant in sql_grants, 'User %s is still granted.' % (user) + delete_user(user_resource, user, database_port) @@ -227,13 +262,13 @@ def test_delete_grant_all(database_port): user_resource = create_user(user, database_port) create_event = UserGrantEvent('Create', ['All'], '*.*', user, port=database_port) - create_response = handler(create_event, {}) + create_response = mysql_user_grant_provider.handler(create_event, {}) assert create_response['Status'] == 'SUCCESS', create_response['Reason'] assert 'PhysicalResourceId' in create_response, "PhysicalResourceId not provided after Create" delete_event = UserGrantEvent('Delete', ['All'], '*.*', user, port=database_port, physical_resource_id=create_response['PhysicalResourceId']) - delete_response = handler(delete_event, {}) + delete_response = mysql_user_grant_provider.handler(delete_event, {}) assert delete_response['Status'] == 'SUCCESS', delete_response['Reason'] with get_database_connection(database_port) as connection: @@ -241,8 +276,21 @@ def test_delete_grant_all(database_port): try: cursor.execute("SHOW GRANTS FOR %s@'%'", [user]) rows = cursor.fetchall() - assert len(rows) == 0, 'User %s is still granted' % user finally: cursor.close() + + if len(rows) != 0: + sql_grants = [parse_grant(r) for r in rows] + expected_grant = 'GRANT USAGE ON *.* TO \'%s\'@\'%%\'' % user + assert expected_grant in sql_grants, 'User %s is still granted.' % (user) delete_user(user_resource, user, database_port) + + +def parse_grant(sql_grant): + grant = sql_grant[0] + identified_by_index = grant.find(" IDENTIFIED BY PASSWORD") + if identified_by_index != -1: + return grant[0:identified_by_index] + + return grant From 71547907a1d7860deadf662655d91fb5d119a11e Mon Sep 17 00:00:00 2001 From: Laurens Knoll Date: Wed, 4 Mar 2020 10:42:29 +0100 Subject: [PATCH 6/6] Simplified vscode python settings. The .env file is not needed as the pip install -e will add the src folder to the environment path. --- .gitignore | 1 - .vscode/project.env | 1 - .vscode/settings.json | 6 +++--- Makefile | 3 ++- 4 files changed, 5 insertions(+), 6 deletions(-) delete mode 100644 .vscode/project.env diff --git a/.gitignore b/.gitignore index 7fe280f..d383faa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ deps/ .vscode/* !.vscode/settings.json -!.vscode/project.env # Byte-compiled / optimized / DLL files .pytest_cache/ __pycache__/ diff --git a/.vscode/project.env b/.vscode/project.env deleted file mode 100644 index 1d3b509..0000000 --- a/.vscode/project.env +++ /dev/null @@ -1 +0,0 @@ -PYTHONPATH=./src:${PYTHONPATH} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7f76ae3..1f136e5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,8 @@ { - "python.pythonPath": "${workspaceFolder}/.venv/bin/python3", - "python.envFile": "${workspaceFolder}/.vscode/project.env", + "python.venvPath": "${workspaceFolder}/.venv", "python.testing.pytestArgs": [], "python.testing.unittestEnabled": false, "python.testing.nosetestsEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "python.pythonPath": ".venv/bin/python3" } \ No newline at end of file diff --git a/Makefile b/Makefile index 9e1c517..359d7fb 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,8 @@ venv: python3 -m venv .venv && \ . ./.venv/bin/activate && \ pip install --quiet --upgrade pip && \ - pip install --quiet -e . + pip install --quiet -e . && \ + pip install --quiet -r tests/test-requirements.txt clean: rm -rf .venv target