diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/backend/.DS_Store differ diff --git a/backend/create_user.py b/backend/create_user.py new file mode 100644 index 0000000..560d8ff --- /dev/null +++ b/backend/create_user.py @@ -0,0 +1,29 @@ +import json +import boto3 + +dynamodb = boto3.resource('dynamodb', region_name = 'us-east-1') +table = dynamodb.Table('infra-admin-api') + +def create_user(netid, roleStr, permStr): + netid = netid.strip() + + roles = roleStr.split(",") + roles = [x.strip() for x in roles] + + perms = permStr.split(",") + perms = [x.strip() for x in perms] + + user = { + "netid": netid, + "roles": roles, + "permissions": perms + } + + user_json = json.loads(json.dumps(user, indent=4)) + + response = table.put_item( + Item={ + "netid": netid, + "value": user_json + } + ) diff --git a/backend/delete_user.py b/backend/delete_user.py new file mode 100644 index 0000000..992a0cf --- /dev/null +++ b/backend/delete_user.py @@ -0,0 +1,8 @@ +import json +import boto3 + +dynamodb = boto3.resource('dynamodb', region_name = 'us-east-1') +table = dynamodb.Table('infra-admin-api') + +def delete_user(netid): + response = table.delete_item(Key={"netid": netid}) diff --git a/backend/get_user.py b/backend/get_user.py new file mode 100644 index 0000000..66d174d --- /dev/null +++ b/backend/get_user.py @@ -0,0 +1,9 @@ +import json +import boto3 + +dynamodb = boto3.resource('dynamodb', region_name = 'us-east-1') +table = dynamodb.Table('infra-admin-api') + +def get_user(netid): + response = table.get_item(Key={"netid": netid}) + return response.get("Item", "Does Not Exist") diff --git a/backend/update_user.py b/backend/update_user.py new file mode 100644 index 0000000..4caf613 --- /dev/null +++ b/backend/update_user.py @@ -0,0 +1,35 @@ +import json +import boto3 + +dynamodb = boto3.resource('dynamodb', region_name = 'us-east-1') +table = dynamodb.Table('infra-admin-api') + +def update_user(netid, newRoles, newPerms): + if (get_user(netid) == "Does Not Exist"): + return "User does not exist" + + nRoles = newRoles.split(",") + nRoles = [x.strip() for x in nRoles] + nRoles = get_user(netid).get("value").get("roles") + nRoles + + nPerms = newPerms.split(",") + nPerms = [x.strip() for x in nPerms] + nPerms = get_user(netid).get("value").get("permissions") + nPerms + + user = { + "netid": netid, + "roles": nRoles, + "permissions": nPerms + } + + user_json = json.loads(json.dumps(user, indent=4)) + + response = table.update_item( + Key={"netid": netid}, + AttributeUpdates={"value": + {"Value": user_json, + "Action": "PUT"} + } + ) + + return get_user(netid) diff --git a/backend/user_funs.py b/backend/user_funs.py new file mode 100644 index 0000000..02abeb9 --- /dev/null +++ b/backend/user_funs.py @@ -0,0 +1,230 @@ +import json, boto3 +from decimal import Decimal + +dynamodb = boto3.resource('dynamodb', region_name = 'us-east-1') +table = dynamodb.Table('infra-admin-api') + +def create_user(netid, roleStr, permStr): + netid = netid.strip() + + roles = roleStr.split(",") + roles = [x.strip() for x in roles] + + perms = permStr.split(",") + perms = [x.strip() for x in perms] + + user = { + "netid": netid, + "roles": roles, + "permissions": perms + } + + user_json = json.loads(json.dumps(user, indent=4)) + + response = table.put_item( + Item={ + "netid": netid, + "value": user_json + } + ) + +def get_user(netid): + response = table.get_item(Key={"netid": netid}) + return response.get("Item", "Does Not Exist") + +def delete_user(netid): + response = table.delete_item(Key={"netid": netid}) + +def update_user(netid, newRoles, newPerms): + if (get_user(netid) == "Does Not Exist"): + return "User does not exist" + + nRoles = newRoles.split(",") + nRoles = [x.strip() for x in nRoles] + nRoles = get_user(netid).get("value").get("roles") + nRoles + + nPerms = newPerms.split(",") + nPerms = [x.strip() for x in nPerms] + nPerms = get_user(netid).get("value").get("permissions") + nPerms + + user = { + "netid": netid, + "roles": nRoles, + "permissions": nPerms + } + + user_json = json.loads(json.dumps(user, indent=4)) + + response = table.update_item( + Key={"netid": netid}, + AttributeUpdates={"value": + {"Value": user_json, + "Action": "PUT"} + } + ) + + return get_user(netid) + +def execute(method: str, path: str, queryParams: dict, context: dict) -> dict: + try: + func: function = find_handler[method][path] + return func(context, queryParams) + except KeyError as e: + print(f"ERROR: No handler found for method {method} and path {path}.") + return notImplemented(context, queryParams) + +def healthzHandler(context, queryParams): + return { + "statusCode": 200, + 'headers': {'Access-Control-Allow-Origin': '*'}, + "body": "UP" + } +def notImplemented(context, queryParams): + return { + "statusCode": 404, + 'headers': {'Access-Control-Allow-Origin': '*'}, + "body": "Method not implemented." + } +def serverError(message): + return { + "statusCode": 500, + 'headers': {'Access-Control-Allow-Origin': '*'}, + "body": f"An error occurred - {message}" + } +def badRequest(message): + return { + "statusCode": 400, + 'headers': {'Access-Control-Allow-Origin': '*'}, + "body": f"Bad request - {message}" + } + +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + return float(obj) + return json.JSONEncoder.default(self, obj) + +def createUserHandler(context, queryParams): + try: + netid = queryParams["netid"] + roleStr = queryParams["roleStr"] + permStr = queryParams["permStr"] + except: + return { + 'statusCode': 404, + 'body': "No netid/roles/permissions provided", + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'} + } + try: + create_user(netid, roleStr, permStr) + item = get_user(netid) + return { + 'statusCode': 200, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps(item, cls=DecimalEncoder) + } + except Exception as e: + print(e) + return { + 'statusCode': 500, 'body': json.dumps({'message': 'Error.'}), + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + } + +def getUserHandler(context, queryParams): + try: + netid = queryParams["netid"] + except: + return { + 'statusCode': 404, + 'body': "No netid provided", + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'} + } + try: + item = get_user(netid) + return { + 'statusCode': 200, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps(item, cls=DecimalEncoder) + } + except Exception as e: + print(e) + return { + 'statusCode': 500, 'body': json.dumps({'message': 'Error.'}), + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + } + +def deleteUserHandler(context, queryParams): + try: + netid = queryParams["netid"] + except: + return { + 'statusCode': 404, + 'body': "No netid provided", + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'} + } + try: + item = get_user(netid) + delete_user(netid) + return { + 'statusCode': 200, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps(item, cls=DecimalEncoder) + } + except Exception as e: + print(e) + return { + 'statusCode': 500, 'body': json.dumps({'message': 'Error.'}), + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + } + +def updateUserHandler(context, queryParams): + try: + netid = queryParams["netid"] + newRoles = queryParams["newRoles"] + newPerms = queryParams["newPerms"] + except: + return { + 'statusCode': 404, + 'body': "No netid/roles/permissions provided", + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'} + } + try: + update_user(netid, newRoles, newPerms) + item = get_user(netid) + return { + 'statusCode': 200, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps(item, cls=DecimalEncoder) + } + except Exception as e: + print(e) + return { + 'statusCode': 500, 'body': json.dumps({'message': 'Error.'}), + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + } + +find_handler = { + "GET": { + "/api/v1/healthz": healthzHandler, + "/api/v1/get_user": getUserHandler, + }, + "PUT": { + "/api/v1/create_user": createUserHandler, + "/api/v1/update_user": updateUserHandler, + }, + "DELETE": { + "/api/v1/delete_user": deleteUserHandler, + } +} + +def lambda_handler(event, context): + method = event['httpMethod'] + path = event['path'] + queryParams = event["queryStringParameters"] + if not queryParams: + queryParams = {} + print(f"INFO: Processing request: method {method}, path {path}.") + try: + return execute(method, path, queryParams, event['requestContext']['authorizer']) + except KeyError: + return execute(method, path, queryParams, {}) diff --git a/cloudformation/lambda.yml b/cloudformation/lambda.yml index 522dfac..fb69c3b 100644 --- a/cloudformation/lambda.yml +++ b/cloudformation/lambda.yml @@ -1,7 +1,38 @@ AWSTemplateFormatVersion: '2010-09-09' -Description: CloudFormation Template for DynamoDB Table +Description: Admin API Backend Transform: AWS::Serverless-2016-10-31 +Parameters: + Env: + Description: Environment + Type: String + AllowedValues: [ 'dev', 'prod' ] + + AlertSNSArn: + Description: SNS Queue to send alarm alerts to + Type: String + Default: arn:aws:sns:us-east-1:298118738376:infra-monitor-alerts + + UseCustomDomainName: + Type: String + Default: false + AllowedValues: [ true, false ] + + UserManagementLambdaName: + Type: String + AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$ + Default: infra-admin-api-user-management-lambda + + AdminAPIGWName: + Type: String + AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$ + Default: infra-admin-api-user-management-gw + + +Conditions: + UseCustomDomainNameCond: !Equals [!Ref UseCustomDomainName, true] + IsProd: !Equals [!Ref Env, 'prod'] + Resources: MyDynamoDBTable: Type: AWS::DynamoDB::Table @@ -19,6 +50,150 @@ Resources: PointInTimeRecoverySpecification: PointInTimeRecoveryEnabled: true + AdminAPIUserManagementLambda: + Type: AWS::Serverless::Function + Properties: + CodeUri: ../backend + AutoPublishAlias: live + Runtime: python3.10 + Description: User Management Lambda + FunctionName: !Ref UserManagementLambdaName + Handler: user_funs.lambda_handler + MemorySize: 2048 + Role: !GetAtt AdminAPIUserManagementLambdaIAMRole.Arn + Timeout: 5 + + AdminAPIUserManagementLambdaFunctionErrorAlarm: + Type: 'AWS::Cloudwatch::Alarm' + Condition: IsProd + Properties: + AlarmName: !Sub '${UserManagementLambdaName}-alarm' + AlarmDescription: !Sub 'Alarm if ${UserManagementLambdaName} function errors are detected.' + Namespace: 'AWS/Lambda' + MetricName: 'Errors' + Statistic: 'Sum' + Period: '60' + EvaluationPeriods: '1' + ComparisonOperator: 'GreaterThanThreshold' + Threshold: '0' + AlarmActions: + - !Ref AlertSNSArn + Dimensions: + - Name: 'FunctionName' + Value: !Ref AdminAPIUserManagementLambda + + LambdaFunctionPermission: + Type: "AWS::Lambda::Permission" + Properties: + Action: "lambda:InvokeFunction" + Principal: apigateway.amazonaws.com + FunctionName: !Ref AdminAPIUserManagementLambda + DependsOn: AdminAPIGateway + + AdminAPIUserManagementLambdaIAMRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Policies: + - PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Effect: Allow + Resource: + - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${UserManagementLambdaName}:* + PolicyName: lambda + - PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - dynamodb:GetItem + - dynamodb:Scan + - dynamodb:UpdateItem + - dynamodb:DeleteItem + - dynamodb:PutItem + Effect: Allow + Resource: + - !GetAtt MyDynamoDBTable.Arn + PolicyName: lambda-dynamo + + AdminAPIUserManagementLambdaLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub /aws/lambda/${UserManagementLambdaName} + RetentionInDays: 7 + + AdminAPIGateway: + Type: AWS::Serverless::Api + DependsOn: + - AdminAPIUserManagementLambda + Properties: + Name: !Ref AdminAPIGWName + Description: Admin API Gateway + AlwaysDeploy: True + Cors: + AllowHeaders: "'*'" + AllowMethods: "'*'" + AllowOrigin: "'*'" + DefinitionBody: + Fn::Transform: + Name: AWS::Include + Parameters: + Location: ../docs/swagger.yml + #Domain: !If [UseCustomDomainNameCond, {DomainName: !Ref CustomDomainName, CertificateArn: !Ref CustomCertificateArn}, !Ref 'AWS::NoValue'] + StageName: default + + AdminAPIGatewayLatencyAlarm: + Type: 'AWS::CloudWatch::Alarm' + Condition: IsProd + Properties: + AlarmName: !Sub '${AdminAPIGWName}-latency-alarm' + AlarmDescription: !Sub 'Alarm if ${AdminAPIGWName} API gateway latency is > 2s.' + Namespace: 'AWS/ApiGateway' + MetricName: 'Latency' + Statistic: 'Average' + Period: '60' + EvaluationPeriods: '1' + ComparisonOperator: 'GreaterThanThreshold' + Threshold: '2000' + AlarmActions: + - !Ref AlertSNSArn + Dimensions: + - Name: 'ApiName' + Value: !Ref AdminAPIGWName + + AdminAPIGateway5XXErrorAlarm: + Type: 'AWS::CloudWatch::Alarm' + Condition: IsProd + Properties: + AlarmName: !Sub '${AdminAPIGWName}-5XX-alarm' + AlarmDescription: !Sub 'Alarm if ${AdminAPIGWName} API gateway 5XX errors are detected.' + Namespace: 'AWS/ApiGateway' + MetricName: '5XXError' + Statistic: 'Average' + Period: '60' + EvaluationPeriods: '1' + ComparisonOperator: 'GreaterThanThreshold' + Threshold: '2' + AlarmActions: + - !Ref AlertSNSArn + Dimensions: + - Name: 'ApiName' + Value: !Ref AdminAPIGWName + + + Outputs: DynamoDBTableName: Description: The name of the DynamoDB table diff --git a/deploy-dev.sh b/deploy-dev.sh index 0ceab30..4f2e6e1 100644 --- a/deploy-dev.sh +++ b/deploy-dev.sh @@ -1,3 +1,3 @@ #!/bin/bash sam build --template-file cloudformation/lambda.yml -sam deploy --no-confirm-changeset --no-fail-on-empty-changeset +sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --capabilities CAPABILITY_IAM --parameter-overrides ParameterKey=UseCustomDomainName,ParameterValue=false ParameterKey=Env,ParameterValue=dev diff --git a/docs/swagger.yml b/docs/swagger.yml new file mode 100644 index 0000000..95a629e --- /dev/null +++ b/docs/swagger.yml @@ -0,0 +1,158 @@ +--- +openapi: 3.0.3 +info: + title: ACM UIUC Admin API + version: 1.0.0 + contact: + name: ACM Infrastructure Team + email: infra@acm.illinois.edu +servers: + - url: adminapi.acm.illinois.edu +paths: + /api/v1/healthz: + get: + summary: Ping the API + operationId: ping + responses: + "200": + description: OK + x-amazon-apigateway-auth: + type: NONE + x-amazon-apigateway-integration: + responses: + default: + statusCode: 200 + passthroughBehavior: when_no_match + httpMethod: POST + contentHandling: CONVERT_TO_TEXT + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AdminAPIUserManagementLambda}/invocations + /api/v1/create_user: + put: + summary: Creates a user + operationId: createUser + parameters: + - in: query + name: netid + schema: + type: string + required: true + description: Users NetID + - in: query + name: roleStr + schema: + type: string + required: true + description: indicates role + - in: query + name: permStr + schema: + type: string + required: true + description: perms + responses: + "200": + description: OK + x-amazon-apigateway-auth: + type: NONE + x-amazon-apigateway-integration: + responses: + default: + statusCode: 200 + passthroughBehavior: when_no_match + httpMethod: POST + contentHandling: CONVERT_TO_TEXT + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AdminAPIUserManagementLambda}/invocations + /api/v1/get_user: + get: + summary: Get User + operationId: getUser + parameters: + - in: query + name: netid + schema: + type: string + required: true + description: Users NetID + responses: + "200": + description: OK + x-amazon-apigateway-auth: + type: NONE + x-amazon-apigateway-integration: + responses: + default: + statusCode: 200 + passthroughBehavior: when_no_match + httpMethod: POST + contentHandling: CONVERT_TO_TEXT + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AdminAPIUserManagementLambda}/invocations + /api/v1/delete_user: + delete: + summary: Deletes a User + operationId: deleteUser + parameters: + - in: query + name: netid + schema: + type: string + required: true + description: Users NetID + responses: + "200": + description: OK + x-amazon-apigateway-auth: + type: NONE + x-amazon-apigateway-integration: + responses: + default: + statusCode: 200 + passthroughBehavior: when_no_match + httpMethod: POST + contentHandling: CONVERT_TO_TEXT + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AdminAPIUserManagementLambda}/invocations + /api/v1/update_user: + put: + summary: updates existing user + operationId: updateUser + parameters: + - in: query + name: netid + schema: + type: string + required: true + description: Users NetID + - in: query + name: newRoles + schema: + type: string + required: true + description: updates with new role + - in: query + name: newPerms + schema: + type: string + required: true + description: updates with new permission + responses: + "200": + description: OK + x-amazon-apigateway-auth: + type: NONE + x-amazon-apigateway-integration: + responses: + default: + statusCode: 200 + passthroughBehavior: when_no_match + httpMethod: POST + contentHandling: CONVERT_TO_TEXT + type: aws_proxy + uri: + Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AdminAPIUserManagementLambda}/invocations