├── .circleci └── config.yml ├── .coverage ├── .github ├── renovate.json └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── CODEOWNERS ├── Dockerfile ├── LICENSE ├── README.md ├── aws_auth.py ├── crds.yaml ├── example └── test.yaml ├── helm ├── aws-auth-operator │ ├── Chart.yaml │ ├── templates │ │ ├── _helpers.yaml │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ ├── rbac.yaml │ │ └── serviceaccount.yaml │ └── values.yaml └── helmfile.yaml ├── lib ├── __init__.py ├── constants.py ├── crd.py ├── mappings.py ├── services.py └── worker.py ├── pyproject.toml ├── requirements.txt └── tests ├── __init__.py ├── pytest.ini ├── test_mapping.py ├── test_operator.py ├── test_services.py └── test_worker.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | aws-ecr: circleci/aws-ecr@7.3.0 5 | 6 | executors: 7 | build-tools: 8 | docker: 9 | - image: ${AWS_ECR_ACCOUNT_URL}/tier/buildtools:v3-latest 10 | 11 | jobs: 12 | pytest: 13 | docker: 14 | - image: cimg/python:3.10 15 | environment: 16 | ENVIRONMENT: test 17 | USE_PROTECTED_MAPPING: true 18 | steps: 19 | - checkout 20 | - run: pip install -r requirements.txt 21 | - run: pip install pytest pytest-mock 22 | - run: pytest tests 23 | 24 | build-and-push-image: 25 | description: "Build and push image to ECR. Tagged as latest- and with " 26 | 27 | executor: build-tools 28 | 29 | working_directory: ~/repo 30 | steps: 31 | - checkout 32 | - setup_remote_docker: # required for running docker commands, creates a remote environment 33 | docker_layer_caching: true # enable caching of docker layers from previous builds to speed up image creation 34 | - aws-ecr/build-and-push-image: 35 | region: AWS_DEFAULT_REGION 36 | create-repo: true 37 | repo: ${REPOSITORY_NAME} 38 | tag: '${CIRCLE_SHA1:0:8},latest-${CIRCLE_BRANCH//\//-}' 39 | - run: 40 | name: "Set Repository name" 41 | command: | 42 | mkdir -p artifacts 43 | ECR_REPOSITORY_URL=${ECR_REPOSITORY_URL:-$(aws ecr describe-repositories --query "repositories[*].repositoryUri" --output text --repository-names ${REPOSITORY_NAME})} 44 | echo $ECR_REPOSITORY_URL > artifacts/repository_url 45 | - persist_to_workspace: 46 | root: . 47 | paths: 48 | - artifacts 49 | 50 | deploy-to-stage: 51 | environment: 52 | ENVIRONMENT: 'stage' 53 | 54 | executor: build-tools 55 | 56 | steps: 57 | - checkout 58 | - attach_workspace: 59 | at: /tmp/workspace 60 | - run: 61 | name: source_environment 62 | command: echo "export ECR_REPOSITORY_URL=$(cat /tmp/workspace/artifacts/repository_url)" >> $BASH_ENV 63 | - run: 64 | name: Deploy 65 | command: | 66 | export IMAGE_TAG=${CIRCLE_SHA1:0:8} 67 | CLUSTERS=( ${STAGING_CLUSTERS} ) 68 | export CLONED_FOLDER=$PWD 69 | for cluster in ${STAGING_CLUSTERS[@]}; do 70 | aws --region ${REGION} eks update-kubeconfig --name ${cluster} 71 | kubectl cluster-info 72 | cd helm && helmfile --log-level debug sync 73 | cd $CLONED_FOLDER 74 | done 75 | 76 | 77 | deploy-to-production: 78 | environment: 79 | ENVIRONMENT: 'production' 80 | 81 | executor: build-tools 82 | 83 | steps: 84 | - checkout 85 | - attach_workspace: 86 | at: /tmp/workspace 87 | - run: 88 | name: source_environment 89 | command: echo "export ECR_REPOSITORY_URL=$(cat /tmp/workspace/artifacts/repository_url)" >> $BASH_ENV 90 | - run: 91 | name: Deploy 92 | command: | 93 | export IMAGE_TAG=${CIRCLE_SHA1:0:8} 94 | CLUSTERS=( ${PRODUCTION_CLUSTERS} ) 95 | export CLONED_FOLDER=$PWD 96 | for cluster in ${PRODUCTION_CLUSTERS[@]}; do 97 | aws --region ${REGION} eks update-kubeconfig --name ${cluster} 98 | kubectl cluster-info 99 | cd helm && helmfile --log-level debug sync 100 | cd $CLONED_FOLDER 101 | done 102 | 103 | workflows: 104 | version: 2 105 | deploy: 106 | jobs: 107 | - pytest 108 | - build-and-push-image: 109 | context: 110 | - global-production 111 | - dockerhub 112 | requires: 113 | - pytest 114 | - deploy-to-stage: 115 | context: global-staging 116 | requires: 117 | - build-and-push-image 118 | filters: 119 | branches: 120 | only: 121 | - master 122 | - deploy-to-production: 123 | context: global-production 124 | requires: 125 | - deploy-to-stage 126 | filters: 127 | branches: 128 | only: 129 | - master 130 | 131 | -------------------------------------------------------------------------------- /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TierMobility/aws-auth-operator/769560505216e91df43592f5e1dc2fcaab26de61/.coverage -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | {"extends": ["github>TierMobility/renovate-config"]} 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '0 0 * * *' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | queries: security-extended # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | # - name: Autobuild 57 | # uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .pytest_cache 3 | Pipfile.lock 4 | poetry.lock 5 | __pycache__ -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @TierMobility/coreinfra 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine 2 | ENV BASEDIR /srv/app/ 3 | 4 | WORKDIR ${BASEDIR} 5 | ADD requirements.txt requirements.txt 6 | RUN apk add --no-cache --virtual .build-deps gcc musl-dev \ 7 | && pip install -r requirements.txt \ 8 | && apk del .build-deps gcc musl-dev 9 | 10 | COPY . . 11 | 12 | RUN addgroup -S app && adduser -S -G app app 13 | USER app 14 | CMD kopf run --standalone --log-format=json --liveness=http://0.0.0.0:$PORT/healthz -n kube-system ./aws_auth.py 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tier Mobility GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-auth-operator 2 | 3 | Customized User/Role mapping from AWS to EKS 4 | 5 | based on Kopf: https://kopf.readthedocs.io/en/latest/ 6 | 7 | # backgound information 8 | 9 | Maps a custom resource definition to an entry in the aws-auth configmap 10 | described here: https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html 11 | 12 | Also logs the changes to the aws-auth configmap to stdout. 13 | 14 | An example crd is in the example folder. 15 | 16 | # installation 17 | 18 | - install minikube 19 | - kubectl apply -f crds.yaml 20 | - pipenv install 21 | - pipenv run kopf run aws-auth.py 22 | 23 | # testing example 24 | 25 | - kubectl apply -f example/test.yaml 26 | - kubectl -n kube-system get cm aws-auth -o yaml 27 | -------------------------------------------------------------------------------- /aws_auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | import kopf 3 | import yaml 4 | import queue 5 | import time 6 | import threading 7 | from kubernetes.client.rest import ApiException 8 | 9 | from lib import ( 10 | AuthMappingList, 11 | get_config_map, 12 | get_protected_mapping, 13 | update_config_map, 14 | write_config_map, 15 | write_protected_mapping, 16 | write_last_handled_mapping, 17 | get_last_handled_mapping, 18 | get_result_message, 19 | Worker, 20 | Event, 21 | EventType 22 | ) 23 | from lib.constants import * 24 | 25 | check_not_protected = lambda body, **_: body["metadata"]["name"] not in SYSTEM_MAPPINGS 26 | cm_is_aws_auth = lambda body, **_: body["metadata"]["name"] == "aws-auth" 27 | last_handled_filter = ( 28 | lambda body, **_: body["metadata"]["name"] == "aws-auth-last-handled" 29 | ) 30 | # kopf.config.WatchersConfig.watcher_retry_delay = 1 31 | 32 | 33 | @kopf.on.startup() 34 | def startup(logger, settings: kopf.OperatorSettings, memo: kopf.Memo, **kwargs): 35 | # set api watching delay to 1s 36 | settings.watching.reconnect_backoff = 1 37 | if os.getenv(USE_PROTECTED_MAPPING) == "true": 38 | kopf.login_via_client(logger=logger, **kwargs) 39 | pm = get_protected_mapping() 40 | if pm is None: 41 | # get current configmap and save values in protected mapping 42 | auth_config_map = get_config_map() 43 | role_mappings = AuthMappingList(data=auth_config_map.data) 44 | logger.info(role_mappings) 45 | write_protected_mapping(logger, role_mappings.get_values()) 46 | logger.info("Startup: {0}".format(pm)) 47 | memo.event_queue = queue.Queue() 48 | memo.event_thread = Worker(memo.event_queue, logger) 49 | memo.event_thread.start() 50 | memo.event_queue.put("Starting Operator ...") 51 | 52 | 53 | @kopf.on.cleanup() 54 | def stop_background_worker(memo: kopf.Memo, **_): 55 | memo.event_queue.put("Finishing background task ...") 56 | memo.event_thread.shutdown_flag.set() 57 | memo.event_thread.join() 58 | 59 | 60 | @kopf.on.create(CRD_GROUP, CRD_VERSION, CRD_NAME, when=check_not_protected) 61 | def create_fn(logger, spec, name, meta, memo: kopf.Memo, **kwargs): 62 | if not spec or "mappings" not in spec: 63 | return get_result_message(f"invalid schema {spec}") 64 | mappings_new = AuthMappingList(spec["mappings"]) 65 | if overwrites_protected_mapping(logger, mappings_new): 66 | return get_result_message("overwriting protected mapping not possible") 67 | memo.event_queue.put( 68 | Event(event_type=EventType.CREATE, object_name=name, mappings=mappings_new) 69 | ) 70 | return get_result_message("Processing") 71 | 72 | 73 | @kopf.on.update(CRD_GROUP, CRD_VERSION, CRD_NAME, when=check_not_protected) 74 | def update_fn(logger, spec, old, new, diff, name, memo: kopf.Memo, **kwargs): 75 | if not new or "spec" not in new: 76 | return get_result_message(f"invalid schema {new}") 77 | if "mappings" not in new["spec"]: 78 | new_role_mappings = AuthMappingList() 79 | else: 80 | new_role_mappings = AuthMappingList(new["spec"]["mappings"]) 81 | if not old or "spec" not in old or "mappings" not in old["spec"]: 82 | old_role_mappings = AuthMappingList() 83 | else: 84 | old_role_mappings = AuthMappingList(old["spec"]["mappings"]) 85 | 86 | if overwrites_protected_mapping(logger, new_role_mappings): 87 | raise kopf.PermanentError("Overwriting protected mapping not possible!") 88 | memo.event_queue.put( 89 | Event( 90 | event_type=EventType.UPDATE, 91 | object_name=name, 92 | mappings=new_role_mappings, 93 | old_mappings=old_role_mappings, 94 | ) 95 | ) 96 | return get_result_message("Processing") 97 | 98 | 99 | @kopf.on.delete(CRD_GROUP, CRD_VERSION, CRD_NAME, when=check_not_protected) 100 | def delete_fn(logger, spec, meta, name, memo: kopf.Memo, **kwarg): 101 | if not spec or "mappings" not in spec: 102 | return get_result_message(f"invalid schema {spec}") 103 | mappings_delete = AuthMappingList(spec["mappings"]) 104 | if overwrites_protected_mapping(logger, mappings_delete): 105 | raise kopf.PermanentError("Overwriting protected mapping not possible!") 106 | memo.event_queue.put( 107 | Event(event_type=EventType.DELETE, object_name=name, mappings=mappings_delete) 108 | ) 109 | return get_result_message("Processing") 110 | 111 | 112 | @kopf.on.event( 113 | "", 114 | "v1", 115 | "configmaps", 116 | when=cm_is_aws_auth, 117 | ) 118 | def log_config_map_change(logger, body, **kwargs): 119 | lm = get_last_handled_mapping() 120 | if lm is not None: 121 | old_mappings = AuthMappingList(lm["spec"]["mappings"]) 122 | new_mappings = AuthMappingList(data=body["data"]) 123 | change = list(old_mappings.diff(new_mappings)) 124 | logger.info(f"Change to aws-auth configmap: {change}") 125 | else: 126 | logger.error(f"last mapping not found: {body}") 127 | 128 | 129 | def overwrites_protected_mapping(logger, check_mapping: AuthMappingList) -> bool: 130 | if os.getenv(USE_PROTECTED_MAPPING) == "true": 131 | pm = get_protected_mapping() 132 | logger.info(f"Protected mapping: {pm}") 133 | if pm is not None: 134 | protected_mapping = AuthMappingList(pm["spec"]["mappings"]) 135 | if check_mapping in protected_mapping: 136 | logger.error("Overiding protected Entries not allowed!") 137 | return True 138 | return False 139 | -------------------------------------------------------------------------------- /crds.yaml: -------------------------------------------------------------------------------- 1 | # A demo CRD for the Kopf example operators. 2 | apiVersion: apiextensions.k8s.io/v1beta1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: awsauthmappings.tier.app 6 | spec: 7 | scope: Cluster 8 | group: tier.app 9 | versions: 10 | - name: v1 11 | served: true 12 | storage: true 13 | names: 14 | kind: AwsAuthMapping 15 | plural: awsauthmappings 16 | singular: awsauthmapping 17 | shortNames: 18 | - awsmapping 19 | - awsm 20 | validation: 21 | openAPIV3Schema: 22 | type: object 23 | properties: 24 | spec: 25 | properties: 26 | mappings: 27 | type: array 28 | items: 29 | type: object 30 | properties: 31 | arn: 32 | type: string 33 | username: 34 | type: string 35 | usertype: 36 | type: string 37 | enum: [Role, User] 38 | groups: 39 | type: array 40 | items: 41 | type: string 42 | additionalPrinterColumns: 43 | - name: Message 44 | type: string 45 | priority: 0 46 | JSONPath: .status.create_fn.message 47 | description: As returned from the handler (sometimes). 48 | -------------------------------------------------------------------------------- /example/test.yaml: -------------------------------------------------------------------------------- 1 | # A demo custom resource for the Kopf example operators. 2 | apiVersion: tier.app/v1 3 | kind: AwsAuthMapping 4 | metadata: 5 | name: role-example-1 6 | labels: 7 | somelabel: somevalue 8 | annotations: 9 | someannotation: somevalue 10 | spec: 11 | mappings: 12 | - arn: arn:aws:iam::6666:role/test-role-1 13 | username: test-role-1 14 | usertype: Role 15 | groups: 16 | - viewers 17 | - editors -------------------------------------------------------------------------------- /helm/aws-auth-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "1.0" 3 | description: A Helm chart for Kubernetes 4 | name: aws-auth-operator 5 | version: 0.1.1 -------------------------------------------------------------------------------- /helm/aws-auth-operator/templates/_helpers.yaml: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "aws-auth-operator.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "aws-auth-operator.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "aws-auth-operator.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | {{/* 34 | All environment variables for containers 35 | */}} -------------------------------------------------------------------------------- /helm/aws-auth-operator/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ template "aws-auth-operator.fullname" . }} 6 | labels: 7 | app: {{ template "aws-auth-operator.fullname" . }} 8 | chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" 9 | release: "{{ .Release.Name }}" 10 | heritage: "{{ .Release.Service }}" 11 | app.kubernetes.io/managed-by: {{ .Release.Service }} 12 | meta.helm.sh/release-name: {{ .Release.Name }} 13 | 14 | data: 15 | {{- if .Values.env.variables}} 16 | {{- range $key, $value := .Values.env.variables.data }} 17 | {{ $key }}: {{ $value | quote }} 18 | {{- end }} 19 | {{- end }} 20 | -------------------------------------------------------------------------------- /helm/aws-auth-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "aws-auth-operator.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "aws-auth-operator.name" . }} 7 | helm.sh/chart: {{ include "aws-auth-operator.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | replicas: {{ .Values.replicaCount }} 12 | strategy: 13 | type: Recreate 14 | selector: 15 | matchLabels: 16 | app.kubernetes.io/name: {{ include "aws-auth-operator.name" . }} 17 | app.kubernetes.io/instance: {{ .Release.Name }} 18 | template: 19 | metadata: 20 | labels: 21 | app.kubernetes.io/name: {{ include "aws-auth-operator.name" . }} 22 | app.kubernetes.io/instance: {{ .Release.Name }} 23 | annotations: 24 | timestamp: {{ date "20060102150405" .Release.Time | quote }} 25 | spec: 26 | serviceAccountName: {{ include "aws-auth-operator.name" . }} 27 | containers: 28 | - name: {{ .Chart.Name }} 29 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 30 | imagePullPolicy: {{ .Values.image.pullPolicy }} 31 | ports: 32 | - name: http 33 | containerPort: {{ .Values.healthcheck.port }} 34 | protocol: TCP 35 | livenessProbe: 36 | httpGet: 37 | path: /healthz 38 | port: http 39 | scheme: HTTP 40 | initialDelaySeconds: 5 41 | periodSeconds: 60 42 | 43 | readinessProbe: 44 | httpGet: 45 | path: /healthz 46 | port: http 47 | scheme: HTTP 48 | initialDelaySeconds: 5 49 | periodSeconds: 60 50 | 51 | envFrom: 52 | - configMapRef: 53 | name: {{ template "aws-auth-operator.fullname" . }} 54 | env: 55 | - name: PORT 56 | value: {{ .Values.healthcheck.port | quote }} 57 | resources: 58 | {{- toYaml .Values.resources | nindent 12 }} 59 | {{- with .Values.nodeSelector }} 60 | nodeSelector: 61 | {{- toYaml . | nindent 8 }} 62 | {{- end }} 63 | {{- with .Values.affinity }} 64 | affinity: 65 | {{- toYaml . | nindent 8 }} 66 | {{- end }} 67 | {{- with .Values.tolerations }} 68 | tolerations: 69 | {{- toYaml . | nindent 8 }} 70 | {{- end }} -------------------------------------------------------------------------------- /helm/aws-auth-operator/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1beta1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ template "aws-auth-operator.fullname" . }} 5 | labels: 6 | app: {{ template "aws-auth-operator.name" . }} 7 | chart: {{ template "aws-auth-operator.chart" . }} 8 | release: {{ .Release.Name }} 9 | heritage: {{ .Release.Service }} 10 | app.kubernetes.io/managed-by: {{ .Release.Service }} 11 | meta.helm.sh/release-name: {{ .Release.Name }} 12 | rules: 13 | - apiGroups: ["tier.app"] 14 | resources: ["awsauthmappings"] 15 | verbs: ["*"] 16 | - apiGroups: [apiextensions.k8s.io] 17 | resources: [customresourcedefinitions] 18 | verbs: ["list", "get", "watch"] 19 | - apiGroups: [""] 20 | resources: ["configmaps"] 21 | verbs: ["*"] 22 | - apiGroups: [""] 23 | resources: ["events"] 24 | verbs: ["list", "get", "watch", "create"] 25 | - apiGroups: [""] 26 | resources: ["services", "pods", "namespaces","deployments"] 27 | verbs: ["list", "get", "watch"] 28 | --- 29 | apiVersion: rbac.authorization.k8s.io/v1beta1 30 | kind: ClusterRoleBinding 31 | metadata: 32 | name: {{ template "aws-auth-operator.fullname" . }} 33 | labels: 34 | app: {{ template "aws-auth-operator.name" . }} 35 | chart: {{ template "aws-auth-operator.chart" . }} 36 | release: {{ .Release.Name }} 37 | heritage: {{ .Release.Service }} 38 | app.kubernetes.io/managed-by: {{ .Release.Service }} 39 | meta.helm.sh/release-name: {{ .Release.Name }} 40 | roleRef: 41 | apiGroup: rbac.authorization.k8s.io 42 | kind: ClusterRole 43 | name: {{ template "aws-auth-operator.fullname" . }} 44 | subjects: 45 | - name: {{ template "aws-auth-operator.name" . }} 46 | namespace: {{ .Release.Namespace | quote }} 47 | kind: ServiceAccount -------------------------------------------------------------------------------- /helm/aws-auth-operator/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ include "aws-auth-operator.name" . }} 5 | namespace: {{ .Release.Name }} 6 | labels: 7 | app.kubernetes.io/name: {{ include "aws-auth-operator.name" . }} 8 | helm.sh/chart: {{ include "aws-auth-operator.chart" . }} 9 | app.kubernetes.io/instance: {{ .Release.Name }} 10 | app.kubernetes.io/managed-by: {{ .Release.Service }} 11 | meta.helm.sh/release-name: {{ .Release.Name }} 12 | -------------------------------------------------------------------------------- /helm/aws-auth-operator/values.yaml: -------------------------------------------------------------------------------- 1 | 2 | replicaCount: 1 3 | 4 | image: 5 | pullPolicy: Always 6 | 7 | alerts: 8 | enabled: false 9 | 10 | healthcheck: 11 | port: 8080 12 | env: 13 | variables: 14 | data: 15 | USE_PROTECTED_MAPPING: true -------------------------------------------------------------------------------- /helm/helmfile.yaml: -------------------------------------------------------------------------------- 1 | 2 | releases: 3 | - name: aws-auth-operator 4 | chart: aws-auth-operator 5 | namespace: aws-auth-operator 6 | wait: true 7 | timeout: 120 8 | values: 9 | - image: 10 | tag: '{{ env "IMAGE_TAG" | default "latest-master" }}' 11 | repository: '{{ env "ECR_REPOSITORY_URL" }}' -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- 1 | from lib.mappings import * 2 | from lib.services import * 3 | from lib.worker import * 4 | -------------------------------------------------------------------------------- /lib/constants.py: -------------------------------------------------------------------------------- 1 | USE_PROTECTED_MAPPING = "USE_PROTECTED_MAPPING" 2 | NAMESPACE = "kube-system" 3 | CM_NAME = "aws-auth" 4 | PROTECTED_MAPPING = "aws-auth-protected-mappings" 5 | LAST_HANDLED_MAPPING = "aws-auth-last-handled" 6 | CRD_GROUP = "tier.app" 7 | CRD_NAME = "awsauthmappings" 8 | CRD_VERSION = "v1" 9 | CRD_KIND = "AwsAuthMapping" 10 | SYSTEM_MAPPINGS = [PROTECTED_MAPPING, LAST_HANDLED_MAPPING] 11 | -------------------------------------------------------------------------------- /lib/crd.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | from lib.constants import CRD_GROUP, CRD_VERSION, CRD_KIND 3 | 4 | 5 | def build_aws_auth_mapping(mappings: List, name: str) -> Dict: 6 | return { 7 | "apiVersion": CRD_GROUP + "/" + CRD_VERSION, 8 | "kind": CRD_KIND, 9 | "metadata": { 10 | "annotations": {}, 11 | "labels": {}, 12 | "name": name, 13 | }, 14 | "spec": {"mappings": mappings}, 15 | } 16 | -------------------------------------------------------------------------------- /lib/mappings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import yaml 3 | from enum import Enum 4 | from typing import List, Dict 5 | import dictdiffer 6 | 7 | 8 | class UserType(Enum): 9 | Role = "Role" 10 | User = "User" 11 | 12 | @classmethod 13 | def value_of(cls, object): 14 | if isinstance(object, UserType): 15 | return object 16 | elif object == "Role": 17 | return UserType.Role 18 | elif object == "User": 19 | return UserType.User 20 | 21 | 22 | class AuthMapping: 23 | arn: str 24 | username: str 25 | groups: List 26 | usertype: UserType 27 | 28 | def __init__(self, mapping: Dict): 29 | if "arn" in mapping.keys(): 30 | self.arn = mapping["arn"] 31 | self.usertype = UserType.value_of(mapping["usertype"]) 32 | elif "rolearn" in mapping.keys(): 33 | self.arn = mapping["rolearn"] 34 | self.usertype = UserType.Role 35 | elif "userarn" in mapping.keys(): 36 | self.arn = mapping["userarn"] 37 | self.usertype = UserType.User 38 | 39 | self.username = mapping["username"] 40 | self.groups = mapping["groups"] 41 | 42 | def get_mapping(self) -> dict: 43 | mapping = {"username": self.username, "groups": self.groups} 44 | if self.usertype == UserType.Role: 45 | mapping["rolearn"] = self.arn 46 | else: 47 | mapping["userarn"] = self.arn 48 | return mapping 49 | 50 | def __repr__(self): 51 | return "arn: {0}, usertype: {1}, groups: {2} ".format( 52 | self.arn, self.usertype, self.groups 53 | ) 54 | 55 | def __hash__(self): 56 | return hash((self.arn, self.usertype)) 57 | 58 | def as_dict(self) -> Dict: 59 | return { 60 | "arn": self.arn, 61 | "username": self.username, 62 | "groups": self.groups, 63 | "usertype": self.usertype.name, 64 | } 65 | 66 | 67 | class AuthMappingList: 68 | auth_mappings: Dict[str, AuthMapping] 69 | 70 | def __init__(self, mappings=None, data={}): 71 | if mappings is None: 72 | mappings = [] 73 | if "mapRoles" in data.keys(): 74 | mappings.extend(yaml.load(data["mapRoles"], Loader=yaml.FullLoader)) 75 | if "mapUsers" in data.keys(): 76 | mappings.extend(yaml.load(data["mapUsers"], Loader=yaml.FullLoader)) 77 | self.auth_mappings = {} 78 | for mapping in mappings: 79 | auth_mapping = AuthMapping(mapping) 80 | self.auth_mappings[hash(auth_mapping)] = auth_mapping 81 | 82 | def merge_mappings(self, mappings_to_merge: AuthMappingList): 83 | for new_mapping_arn in mappings_to_merge.auth_mappings.keys(): 84 | self.auth_mappings[new_mapping_arn] = mappings_to_merge.auth_mappings[ 85 | new_mapping_arn 86 | ] 87 | 88 | def remove_mappings(self, mappings_to_remove: AuthMappingList): 89 | for new_mapping_arn in mappings_to_remove.auth_mappings.keys(): 90 | if new_mapping_arn in self.auth_mappings.keys(): 91 | del self.auth_mappings[new_mapping_arn] 92 | 93 | def get_roles_dict(self) -> List[Dict]: 94 | result = [] 95 | for auth_mapping in self.auth_mappings.values(): 96 | if auth_mapping.usertype == UserType.Role: 97 | result.append(auth_mapping.get_mapping()) 98 | return result 99 | 100 | def get_user_dict(self) -> List[Dict]: 101 | result = [] 102 | for auth_mapping in self.auth_mappings.values(): 103 | if auth_mapping.usertype == UserType.User: 104 | result.append(auth_mapping.get_mapping()) 105 | return result 106 | 107 | def get_values(self) -> List[Dict]: 108 | result = [] 109 | for auth_mapping in self.auth_mappings.values(): 110 | result.append(auth_mapping.as_dict()) 111 | return result 112 | 113 | def get_data(self) -> Dict: 114 | data = {} 115 | roles = self.get_roles_dict() 116 | if len(roles) > 0: 117 | data["mapRoles"] = roles 118 | users = self.get_user_dict() 119 | if len(users) > 0: 120 | data["mapUsers"] = users 121 | return data 122 | 123 | def __repr__(self): 124 | return self.auth_mappings.__repr__() 125 | 126 | def __eq__(self, other: AuthMappingList): 127 | own_keys = set(self.auth_mappings.keys()) 128 | other_keys = set(other.auth_mappings.keys()) 129 | return len(own_keys.intersection(other_keys)) == len(own_keys) 130 | 131 | def __contains__(self, other: AuthMappingList): 132 | own_keys = set(self.auth_mappings.keys()) 133 | other_keys = set(other.auth_mappings.keys()) 134 | return len(own_keys.intersection(other_keys)) > 0 135 | 136 | def __len__(self): 137 | return len(self.auth_mappings) 138 | 139 | def check_update(self, response_roles: Dict) -> str: 140 | role_arns = [] 141 | new_role_arns = [] 142 | for role in response_roles: 143 | role_arns.append(role["rolearn"]) 144 | for role in self.get_roles_dict(): 145 | new_role_arns.append(role["rolearn"]) 146 | return ( 147 | "success" if all(elem in role_arns for elem in new_role_arns) else "failed" 148 | ) 149 | 150 | def check_delete(self, response_roles: Dict) -> str: 151 | role_arns = [] 152 | new_role_arns = [] 153 | for role in response_roles: 154 | role_arns.append(role["rolearn"]) 155 | for role in self.get_roles_dict(): 156 | new_role_arns.append(role["rolearn"]) 157 | return ( 158 | "failed" if any(elem in role_arns for elem in new_role_arns) else "success" 159 | ) 160 | 161 | def diff(self, other: AuthMappingList): 162 | return dictdiffer.diff(self.get_data(), other.get_data()) 163 | -------------------------------------------------------------------------------- /lib/services.py: -------------------------------------------------------------------------------- 1 | import kubernetes 2 | import yaml 3 | import kopf 4 | from kubernetes.client.rest import ApiException 5 | from kubernetes.client import V1ConfigMap 6 | from lib.constants import * 7 | from lib.crd import build_aws_auth_mapping 8 | from typing import Dict, List 9 | import datetime 10 | 11 | 12 | def get_config_map() -> V1ConfigMap: 13 | api_instance = kubernetes.client.CoreV1Api() 14 | return api_instance.read_namespaced_config_map(CM_NAME, NAMESPACE) 15 | 16 | 17 | def write_config_map(auth_config_map: V1ConfigMap) -> V1ConfigMap: 18 | api_instance = kubernetes.client.CoreV1Api() 19 | return api_instance.patch_namespaced_config_map( 20 | CM_NAME, NAMESPACE, auth_config_map, pretty="true" 21 | ) 22 | 23 | 24 | def update_config_map(auth_config_map: V1ConfigMap, data: Dict): 25 | if "mapRoles" in data: 26 | auth_config_map.data["mapRoles"] = yaml.dump( 27 | data["mapRoles"], default_flow_style=False 28 | ) 29 | if "mapUsers" in data: 30 | auth_config_map.data["mapUsers"] = yaml.dump( 31 | data["mapUsers"], default_flow_style=False 32 | ) 33 | auth_config_map.metadata.namespace = None 34 | auth_config_map.metadata.uid = None 35 | auth_config_map.metadata.annotations = None 36 | auth_config_map.metadata.resource_version = None 37 | return auth_config_map 38 | 39 | 40 | def get_protected_mapping() -> Dict: 41 | return get_mapping(PROTECTED_MAPPING) 42 | 43 | 44 | def write_protected_mapping(logger, mappings: Dict): 45 | create_mapping(logger, PROTECTED_MAPPING, mappings) 46 | 47 | 48 | def get_last_handled_mapping() -> Dict: 49 | return get_mapping(LAST_HANDLED_MAPPING) 50 | 51 | 52 | def write_last_handled_mapping(logger, mappings: List): 53 | lm = get_mapping(LAST_HANDLED_MAPPING) 54 | if lm is None: 55 | create_mapping(logger, LAST_HANDLED_MAPPING, mappings) 56 | else: 57 | update_mapping(logger, LAST_HANDLED_MAPPING, mappings) 58 | 59 | 60 | def get_mapping(name: str) -> Dict: 61 | api_instance = get_custom_object_api() 62 | try: 63 | protected_mapping = api_instance.get_cluster_custom_object( 64 | CRD_GROUP, CRD_VERSION, CRD_NAME, name 65 | ) 66 | return protected_mapping 67 | except ApiException as e: 68 | if e.status == 404: 69 | return None 70 | else: 71 | raise Exception("Getting resource failed!", e) 72 | 73 | 74 | def create_mapping(logger, name: str, mappings: Dict): 75 | body = build_aws_auth_mapping(mappings, name) 76 | api_instance = get_custom_object_api() 77 | try: 78 | pm = api_instance.create_cluster_custom_object( 79 | CRD_GROUP, CRD_VERSION, CRD_NAME, body, pretty=True 80 | ) 81 | print(pm) 82 | except ApiException as e: 83 | logger.error(e) 84 | 85 | 86 | def update_mapping(logger, name: str, mappings: Dict): 87 | body = build_aws_auth_mapping(mappings, name) 88 | api_instance = get_custom_object_api() 89 | try: 90 | pm = api_instance.patch_cluster_custom_object( 91 | CRD_GROUP, CRD_VERSION, CRD_NAME, name, body 92 | ) 93 | print(pm) 94 | except ApiException as e: 95 | logger.error(e) 96 | 97 | 98 | def update_mapping_status(logger, name: str, status_update: Dict): 99 | api_instance = get_custom_object_api() 100 | try: 101 | pm = api_instance.patch_cluster_custom_object( 102 | CRD_GROUP, CRD_VERSION, CRD_NAME, name, {"status": status_update} 103 | ) 104 | logger.debug(pm) 105 | except ApiException as e: 106 | logger.error(e) 107 | 108 | 109 | def get_custom_object_api() -> kubernetes.client.CustomObjectsApi: 110 | return kubernetes.client.CustomObjectsApi() 111 | 112 | 113 | def get_result_message(message: str): 114 | return { 115 | "message": message, 116 | "timestamp": str(datetime.datetime.now()), 117 | } 118 | -------------------------------------------------------------------------------- /lib/worker.py: -------------------------------------------------------------------------------- 1 | from lib.mappings import AuthMappingList 2 | from lib import ( 3 | get_config_map, 4 | update_config_map, 5 | write_config_map, 6 | write_last_handled_mapping, 7 | update_mapping_status, 8 | get_result_message, 9 | ) 10 | from kubernetes.client.rest import ApiException 11 | from enum import Enum 12 | from dataclasses import dataclass 13 | import queue 14 | import threading 15 | import time 16 | 17 | 18 | class EventType(Enum): 19 | CREATE = 0 20 | UPDATE = 1 21 | DELETE = 2 22 | 23 | 24 | @dataclass 25 | class Event: 26 | object_name: str 27 | event_type: EventType 28 | mappings: AuthMappingList 29 | old_mappings: AuthMappingList = None 30 | 31 | 32 | class Worker(threading.Thread): 33 | def __init__(self, event_queue: queue.Queue, logger): 34 | threading.Thread.__init__(self) 35 | 36 | # The shutdown_flag is a threading.Event object that 37 | # indicates whether the thread should be terminated. 38 | self.shutdown_flag = threading.Event() 39 | self.event_queue = event_queue 40 | self.logger = logger 41 | 42 | # ... Other thread setup code here ... 43 | 44 | def run(self): 45 | self.logger.info("Worker Thread #%s started" % self.ident) 46 | 47 | while not self.shutdown_flag.is_set(): 48 | while not self.event_queue.empty(): 49 | event = self.event_queue.get() 50 | if isinstance(event, Event): 51 | self.logger.info(f"Got event: {event.event_type}") 52 | match event.event_type: 53 | case EventType.CREATE: 54 | create_mapping(event, self.logger) 55 | case EventType.UPDATE: 56 | update_mapping(event, self.logger) 57 | case EventType.DELETE: 58 | delete_mapping(event, self.logger) 59 | case _: 60 | self.logger.error( 61 | f"Got unknown event type: {event.event_type}" 62 | ) 63 | else: 64 | self.logger.info(event) 65 | time.sleep(1) 66 | 67 | # ... Clean shutdown code here ... 68 | self.logger.info("Worker Thread #%s stopped" % self.ident) 69 | 70 | 71 | def create_mapping(event: Event, logger): 72 | logger.info(f"CREATING: {event.object_name}") 73 | try: 74 | auth_config_map = get_config_map() 75 | current_config_mapping = AuthMappingList(data=auth_config_map.data) 76 | # save current config before change 77 | write_last_handled_mapping(logger, current_config_mapping.get_values()) 78 | # add new roles 79 | current_config_mapping.merge_mappings(event.mappings) 80 | auth_config_map = update_config_map( 81 | auth_config_map, current_config_mapping.get_data() 82 | ) 83 | response = write_config_map(auth_config_map) 84 | response_data = AuthMappingList(data=response.data) 85 | if event.mappings not in response_data: 86 | logger.error("Add Roles failed") 87 | update_mapping_status( 88 | logger, 89 | event.object_name, 90 | {"create_fn": get_result_message("Error")}, 91 | ) 92 | else: 93 | update_mapping_status( 94 | logger, 95 | event.object_name, 96 | {"create_fn": get_result_message("All good")}, 97 | ) 98 | except ApiException as e: 99 | logger.error(f"Exception: {e}") 100 | update_mapping_status( 101 | logger, 102 | event.object_name, 103 | {"create_fn": get_result_message("Error")}, 104 | ) 105 | 106 | 107 | def update_mapping(event: Event, logger): 108 | logger.info(f"UPDATING: {event.object_name}") 109 | try: 110 | auth_config_map = get_config_map() 111 | current_config_mapping = AuthMappingList(data=auth_config_map.data) 112 | # save current config before change 113 | write_last_handled_mapping(logger, current_config_mapping.get_values()) 114 | 115 | # remove old stuff first 116 | current_config_mapping.remove_mappings(event.old_mappings) 117 | # add new values 118 | current_config_mapping.merge_mappings(event.mappings) 119 | auth_config_map = update_config_map( 120 | auth_config_map, current_config_mapping.get_data() 121 | ) 122 | response = write_config_map(auth_config_map) 123 | response_data = AuthMappingList(data=response.data) 124 | if len(event.mappings) > 0 and event.mappings not in response_data: 125 | logger.error("Update Roles failed") 126 | update_mapping_status( 127 | logger, 128 | event.object_name, 129 | {"update_fn": get_result_message("Error")}, 130 | ) 131 | else: 132 | update_mapping_status( 133 | logger, 134 | event.object_name, 135 | {"update_fn": get_result_message("All good")}, 136 | ) 137 | except ApiException as e: 138 | logger.error(f"Exception: {e}") 139 | update_mapping_status( 140 | logger, 141 | event.object_name, 142 | {"update_fn": get_result_message("Error")}, 143 | ) 144 | 145 | 146 | def delete_mapping(event: Event, logger): 147 | logger.info(f"DELETING: {event.object_name}") 148 | try: 149 | auth_config_map = get_config_map() 150 | current_config_mapping = AuthMappingList(data=auth_config_map.data) 151 | 152 | # save current config before change 153 | write_last_handled_mapping(logger, current_config_mapping.get_values()) 154 | # remove old roles 155 | current_config_mapping.remove_mappings(event.mappings) 156 | auth_config_map = update_config_map( 157 | auth_config_map, current_config_mapping.get_data() 158 | ) 159 | response = write_config_map(auth_config_map) 160 | response_data = AuthMappingList(data=response.data) 161 | if event.mappings in response_data: 162 | logger.error("Delete Roles failed") 163 | update_mapping_status( 164 | logger, 165 | event.object_name, 166 | {"delete_fn": get_result_message("Error")}, 167 | ) 168 | except ApiException as e: 169 | logger.error(f"Exception: {e}") 170 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aws-auth-operator" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Daniel Hahn "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | kopf = "^1.36.1" 11 | kubernetes = "^26.1.0" 12 | pyyaml = "^6.0" 13 | dictdiffer = "^0.9.0" 14 | 15 | 16 | [tool.poetry.group.dev.dependencies] 17 | black = "^23.3.0" 18 | pytest = "*" 19 | pytest-mock = "*" 20 | 21 | [build-system] 22 | requires = ["poetry-core"] 23 | build-backend = "poetry.core.masonry.api" 24 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.4 ; python_version >= "3.7" and python_version < "4.0" 2 | aiosignal==1.3.1 ; python_version >= "3.7" and python_version < "4.0" 3 | async-timeout==4.0.2 ; python_version >= "3.7" and python_version < "4.0" 4 | asynctest==0.13.0 ; python_version >= "3.7" and python_version < "3.8" 5 | attrs==23.1.0 ; python_version >= "3.7" and python_version < "4.0" 6 | cachetools==5.3.1 ; python_version >= "3.7" and python_version < "4.0" 7 | certifi==2023.5.7 ; python_version >= "3.7" and python_version < "4.0" 8 | charset-normalizer==3.1.0 ; python_version >= "3.7" and python_version < "4.0" 9 | click==8.1.3 ; python_version >= "3.7" and python_version < "4.0" 10 | colorama==0.4.6 ; python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows" 11 | dictdiffer==0.9.0 ; python_version >= "3.7" and python_version < "4.0" 12 | frozenlist==1.3.3 ; python_version >= "3.7" and python_version < "4.0" 13 | google-auth==2.17.3 ; python_version >= "3.7" and python_version < "4.0" 14 | idna==3.4 ; python_version >= "3.7" and python_version < "4.0" 15 | importlib-metadata==6.6.0 ; python_version >= "3.7" and python_version < "3.8" 16 | iso8601==1.1.0 ; python_version >= "3.7" and python_version < "4.0" 17 | kopf==1.36.1 ; python_version >= "3.7" and python_version < "4.0" 18 | kubernetes==26.1.0 ; python_version >= "3.7" and python_version < "4.0" 19 | multidict==6.0.4 ; python_version >= "3.7" and python_version < "4.0" 20 | oauthlib==3.2.2 ; python_version >= "3.7" and python_version < "4.0" 21 | pyasn1-modules==0.3.0 ; python_version >= "3.7" and python_version < "4.0" 22 | pyasn1==0.5.0 ; python_version >= "3.7" and python_version < "4.0" 23 | python-dateutil==2.8.2 ; python_version >= "3.7" and python_version < "4.0" 24 | python-json-logger==2.0.7 ; python_version >= "3.7" and python_version < "4.0" 25 | pyyaml==6.0 ; python_version >= "3.7" and python_version < "4.0" 26 | requests-oauthlib==1.3.1 ; python_version >= "3.7" and python_version < "4.0" 27 | requests==2.31.0 ; python_version >= "3.7" and python_version < "4.0" 28 | rsa==4.9 ; python_version >= "3.7" and python_version < "4" 29 | setuptools==67.8.0 ; python_version >= "3.7" and python_version < "4.0" 30 | six==1.16.0 ; python_version >= "3.7" and python_version < "4.0" 31 | typing-extensions==4.6.2 ; python_version >= "3.7" and python_version < "4.0" 32 | urllib3==2.0.2 ; python_version >= "3.7" and python_version < "4.0" 33 | websocket-client==1.5.2 ; python_version >= "3.7" and python_version < "4.0" 34 | yarl==1.9.2 ; python_version >= "3.7" and python_version < "4.0" 35 | zipp==3.15.0 ; python_version >= "3.7" and python_version < "3.8" 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TierMobility/aws-auth-operator/769560505216e91df43592f5e1dc2fcaab26de61/tests/__init__.py -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | 2 | [pytest] 3 | rootdir = ../ 4 | cache_dir = ../.pytest_cache 5 | capture = yes 6 | env = 7 | USE_PROTECTED_MAPPING=true -------------------------------------------------------------------------------- /tests/test_mapping.py: -------------------------------------------------------------------------------- 1 | from tests.test_operator import ( 2 | DATA_CREATE, 3 | DATA_DEFAULT, 4 | DATA_UPDATE, 5 | DATA_NOT_CONTAINED, 6 | ) 7 | from lib.mappings import AuthMappingList 8 | from typing import Dict 9 | 10 | 11 | def test_mapping_equality(): 12 | mapping1 = AuthMappingList([DATA_DEFAULT]) 13 | mapping2 = AuthMappingList([DATA_DEFAULT]) 14 | 15 | assert mapping1 == mapping2 16 | 17 | 18 | def test_mapping_inequality(): 19 | mapping1 = AuthMappingList([DATA_DEFAULT]) 20 | mapping2 = AuthMappingList([DATA_CREATE]) 21 | 22 | assert mapping1 != mapping2 23 | 24 | 25 | def test_mapping_merge(): 26 | mapping1 = AuthMappingList([DATA_DEFAULT]) 27 | mapping2 = AuthMappingList([DATA_CREATE]) 28 | mapping1.merge_mappings(mapping2) 29 | print(mapping1.auth_mappings.values()) 30 | print([DATA_DEFAULT, DATA_CREATE]) 31 | assert mapping1 == AuthMappingList([DATA_DEFAULT, DATA_CREATE]) 32 | 33 | 34 | def test_mapping_remove(): 35 | mapping1 = AuthMappingList([DATA_DEFAULT, DATA_CREATE]) 36 | mapping2 = AuthMappingList([DATA_CREATE]) 37 | mapping1.remove_mappings(mapping2) 38 | print(mapping1.auth_mappings.values()) 39 | print([DATA_DEFAULT, DATA_CREATE]) 40 | assert mapping1 == AuthMappingList([DATA_DEFAULT]) 41 | 42 | 43 | def test_contains(): 44 | mapping1 = AuthMappingList([DATA_DEFAULT, DATA_CREATE]) 45 | mapping2 = AuthMappingList([DATA_CREATE]) 46 | 47 | assert mapping2 in mapping1 48 | 49 | 50 | def test_not_contains(): 51 | mapping1 = AuthMappingList([DATA_DEFAULT, DATA_CREATE]) 52 | mapping2 = AuthMappingList([DATA_NOT_CONTAINED]) 53 | 54 | assert mapping2 not in mapping1 55 | 56 | 57 | def test_mapping_with_string_usertype(): 58 | data_default = DATA_DEFAULT.copy() 59 | data_default["usertype"] = "Role" 60 | mapping1 = AuthMappingList([data_default]) 61 | mapping2 = AuthMappingList([DATA_DEFAULT]) 62 | print(mapping1.auth_mappings) 63 | print(mapping2.auth_mappings) 64 | assert mapping1 == mapping2 65 | 66 | 67 | def test_get_values(): 68 | mapping1 = AuthMappingList([DATA_DEFAULT, DATA_CREATE]) 69 | value_dict = mapping1.get_values() 70 | assert len(value_dict) == 2 71 | assert isinstance(value_dict[0], Dict) 72 | assert isinstance(value_dict[1], Dict) 73 | assert "arn" in value_dict[0] and value_dict[0]["arn"] == DATA_DEFAULT["arn"] 74 | assert ( 75 | "usertype" in value_dict[0] 76 | and value_dict[0]["usertype"] == DATA_DEFAULT["usertype"].name 77 | ) 78 | 79 | 80 | def test_diff(): 81 | mapping1 = AuthMappingList([DATA_DEFAULT, DATA_CREATE]) 82 | mapping2 = AuthMappingList([DATA_CREATE]) 83 | test_diff = list(mapping1.diff(mapping2)) 84 | assert test_diff == [ 85 | ("change", ["mapRoles", 0, "username"], ("test-role-0", "test-role-1")), 86 | ( 87 | "change", 88 | ["mapRoles", 0, "rolearn"], 89 | ( 90 | "arn:aws:iam::6666:role/test-role-0", 91 | "arn:aws:iam::6666:role/test-role-1", 92 | ), 93 | ), 94 | ( 95 | "remove", 96 | "mapRoles", 97 | [ 98 | ( 99 | 1, 100 | { 101 | "groups": ["viewers"], 102 | "rolearn": "arn:aws:iam::6666:role/test-role-1", 103 | "username": "test-role-1", 104 | }, 105 | ) 106 | ], 107 | ), 108 | ] 109 | -------------------------------------------------------------------------------- /tests/test_operator.py: -------------------------------------------------------------------------------- 1 | import aws_auth 2 | import kubernetes 3 | import yaml 4 | import copy 5 | import logging 6 | import pytest 7 | import kopf 8 | from unittest.mock import MagicMock 9 | import queue 10 | from lib.mappings import UserType 11 | from lib.worker import EventType 12 | 13 | DATA_DEFAULT = { 14 | "arn": "arn:aws:iam::6666:role/test-role-0", 15 | "username": "test-role-0", 16 | "usertype": UserType.Role, 17 | "groups": ["viewers"], 18 | } 19 | 20 | DATA_CREATE = { 21 | "arn": "arn:aws:iam::6666:role/test-role-1", 22 | "username": "test-role-1", 23 | "usertype": UserType.Role, 24 | "groups": ["viewers"], 25 | } 26 | 27 | DATA_UPDATE = { 28 | "arn": "arn:aws:iam::6666:role/test-role-1", 29 | "username": "test-role-1", 30 | "usertype": UserType.Role, 31 | "groups": ["viewers", "editors"], 32 | } 33 | 34 | DATA_NOT_CONTAINED = { 35 | "arn": "arn:aws:iam::6666:role/test-role-2", 36 | "username": "test-role-2", 37 | "usertype": UserType.Role, 38 | "groups": ["viewers", "editors"], 39 | } 40 | 41 | CM_DATA_1 = { 42 | "rolearn": "arn:aws:iam::6666:role/test-role-1", 43 | "username": "test-role-1", 44 | "groups": ["viewers"], 45 | } 46 | 47 | CM_DATA_2 = { 48 | "rolearn": "arn:aws:iam::6666:role/test-role-2", 49 | "username": "test-role-2", 50 | "groups": ["viewers", "editors"], 51 | } 52 | 53 | TEST_MEMO = kopf.Memo() 54 | TEST_MEMO.event_queue = queue.Queue() 55 | TEST_MEMO.event_thread = MagicMock() 56 | 57 | logger = logging.getLogger() 58 | 59 | 60 | def test_run(): 61 | assert 1 == 1 62 | 63 | 64 | def test_create(mocker): 65 | mocker.patch("aws_auth.get_protected_mapping") 66 | aws_auth.get_protected_mapping.return_value = { 67 | "spec": {"mappings": [DATA_NOT_CONTAINED]} 68 | } 69 | message = aws_auth.create_fn( 70 | logger, 71 | spec={"mappings": [DATA_CREATE]}, 72 | meta={}, 73 | name="test", 74 | memo=TEST_MEMO, 75 | kwargs={}, 76 | ) 77 | # asserts 78 | assert "Processing" == message["message"] 79 | assert not TEST_MEMO.event_queue.empty() 80 | assert TEST_MEMO.event_queue.get().event_type == EventType.CREATE 81 | aws_auth.get_protected_mapping.assert_called_once() 82 | 83 | 84 | def test_delete(mocker): 85 | mocker.patch("aws_auth.get_protected_mapping") 86 | mocker.patch("aws_auth.get_config_map") 87 | message = aws_auth.delete_fn( 88 | logger, 89 | spec={"mappings": [DATA_CREATE]}, 90 | meta={}, 91 | name="test", 92 | memo=TEST_MEMO, 93 | kwargs={}, 94 | ) 95 | assert "Processing" == message["message"] 96 | assert not TEST_MEMO.event_queue.empty() 97 | assert TEST_MEMO.event_queue.get().event_type == EventType.DELETE 98 | aws_auth.get_protected_mapping.assert_called_once() 99 | 100 | 101 | def test_update(mocker): 102 | mocker.patch("aws_auth.get_protected_mapping") 103 | mocker.patch("aws_auth.get_config_map") 104 | old = {"spec": {"mappings": [DATA_DEFAULT]}} 105 | new = {"spec": {"mappings": [DATA_UPDATE]}} 106 | message = aws_auth.update_fn( 107 | logger, 108 | old=old, 109 | new=new, 110 | spec={}, 111 | diff={}, 112 | name="test", 113 | memo=TEST_MEMO, 114 | kwargs={}, 115 | ) 116 | assert "Processing" == message["message"] 117 | assert not TEST_MEMO.event_queue.empty() 118 | assert TEST_MEMO.event_queue.get().event_type == EventType.UPDATE 119 | 120 | 121 | @pytest.mark.skip(reason="no way of currently testing this") 122 | def test_create_failed(mocker): 123 | with pytest.raises(kopf.PermanentError) as err: 124 | # mocker.patch("aws_auth.get_protected_mapping") 125 | # mocker.patch("aws_auth.get_config_map") 126 | # mocker.patch("aws_auth.write_config_map") 127 | # mocker.patch("aws_auth.write_last_handled_mapping") 128 | # aws_auth.get_config_map.return_value = build_cm() 129 | # aws_auth.write_config_map.return_value = build_cm(default={}) 130 | aws_auth.create_fn( 131 | logger, 132 | spec={"mappings": [DATA_CREATE]}, 133 | meta={}, 134 | name="test", 135 | memo=TEST_MEMO, 136 | kwargs={}, 137 | ) 138 | 139 | assert "Add Roles failed" in str(err) 140 | 141 | 142 | @pytest.mark.skip(reason="no way of currently testing this") 143 | def test_update_failed(mocker): 144 | with pytest.raises(kopf.PermanentError) as err: 145 | mocker.patch("aws_auth.get_protected_mapping") 146 | mocker.patch("aws_auth.get_config_map") 147 | mocker.patch("aws_auth.write_config_map") 148 | mocker.patch("aws_auth.write_last_handled_mapping") 149 | aws_auth.get_config_map.return_value = build_cm() 150 | aws_auth.write_config_map.return_value = build_cm() 151 | old = {"spec": {"mappings": [DATA_DEFAULT]}} 152 | new = {"spec": {"mappings": [DATA_UPDATE]}} 153 | aws_auth.update_fn( 154 | logger, 155 | old=old, 156 | new=new, 157 | spec={}, 158 | diff={}, 159 | name="test", 160 | memo=TEST_MEMO, 161 | kwargs={}, 162 | ) 163 | 164 | assert "Update Roles failed" in str(err) 165 | 166 | 167 | @pytest.mark.skip(reason="no way of currently testing this") 168 | def test_delete_failed(mocker): 169 | with pytest.raises(kopf.PermanentError) as err: 170 | mocker.patch("aws_auth.get_protected_mapping") 171 | mocker.patch("aws_auth.get_config_map") 172 | mocker.patch("aws_auth.write_config_map") 173 | mocker.patch("aws_auth.write_last_handled_mapping") 174 | aws_auth.get_config_map.return_value = build_cm(extra_data=DATA_CREATE) 175 | aws_auth.write_config_map.return_value = build_cm(extra_data=DATA_CREATE) 176 | aws_auth.delete_fn( 177 | logger, 178 | spec={"mappings": [DATA_CREATE]}, 179 | meta={}, 180 | name="test", 181 | memo=TEST_MEMO, 182 | kwargs={}, 183 | ) 184 | 185 | assert "Delete Roles failed" in str(err) 186 | 187 | 188 | def test_create_invalid_spec(): 189 | message = aws_auth.create_fn( 190 | logger, 191 | spec={}, 192 | meta={"object": {"name": "test"}}, 193 | name="test", 194 | memo=TEST_MEMO, 195 | kwargs={}, 196 | ) 197 | assert "invalid schema {}" == message["message"] 198 | 199 | 200 | def test_update_invalid_spec(): 201 | old = {"spec": {"mappings": [DATA_DEFAULT]}} 202 | new = {} 203 | message = message = aws_auth.update_fn( 204 | logger, 205 | old=old, 206 | new=new, 207 | spec={}, 208 | diff={}, 209 | name="test", 210 | memo=TEST_MEMO, 211 | kwargs={}, 212 | ) 213 | assert "invalid schema {}" == message["message"] 214 | 215 | 216 | def test_delete_invalid_spec(): 217 | message = aws_auth.delete_fn( 218 | logger, spec={}, meta={}, name="test", memo=TEST_MEMO, kwargs={} 219 | ) 220 | assert "invalid schema {}" == message["message"] 221 | 222 | 223 | def test_startup(mocker): 224 | settings = kopf.OperatorSettings() 225 | mocker.patch("aws_auth.kopf.login_via_client") 226 | mocker.patch("aws_auth.get_protected_mapping") 227 | mocker.patch("aws_auth.get_config_map") 228 | mocker.patch("aws_auth.write_protected_mapping") 229 | mocker.patch("aws_auth.Worker") 230 | aws_auth.get_protected_mapping.return_value = None 231 | aws_auth.startup(logger, settings=settings, memo=TEST_MEMO) 232 | aws_auth.get_protected_mapping.assert_called_once() 233 | aws_auth.get_config_map.assert_called_once() 234 | aws_auth.write_protected_mapping.assert_called_once() 235 | assert not TEST_MEMO.event_queue.empty() 236 | assert TEST_MEMO.event_queue.get() == "Starting Operator ..." 237 | 238 | 239 | def test_create_overwrite_protected_mapping(mocker): 240 | mocker.patch("aws_auth.get_protected_mapping") 241 | mocker.patch("aws_auth.get_config_map") 242 | mocker.patch("aws_auth.write_config_map") 243 | aws_auth.get_protected_mapping.return_value = {"spec": {"mappings": [DATA_CREATE]}} 244 | aws_auth.get_config_map.return_value = build_cm() 245 | aws_auth.write_config_map.return_value = build_cm(extra_data=DATA_CREATE) 246 | message = aws_auth.create_fn( 247 | logger, 248 | spec={"mappings": [DATA_CREATE]}, 249 | meta={}, 250 | name="test", 251 | memo=TEST_MEMO, 252 | kwargs={}, 253 | ) 254 | assert "overwriting protected mapping not possible" == message["message"] 255 | # asserts 256 | aws_auth.get_config_map.assert_not_called() 257 | aws_auth.write_config_map.assert_not_called() 258 | aws_auth.get_protected_mapping.assert_called_once() 259 | 260 | 261 | def test_log_config_map_change(mocker): 262 | mocker.patch("aws_auth.get_last_handled_mapping") 263 | aws_auth.get_last_handled_mapping.return_value = { 264 | "spec": {"mappings": [DATA_CREATE]} 265 | } 266 | aws_auth.log_config_map_change(logger, {"data": CM_DATA_2}) 267 | 268 | 269 | def build_cm(default=DATA_DEFAULT, extra_data=None): 270 | data = [default] 271 | if extra_data is not None: 272 | data.append(extra_data) 273 | cm = kubernetes.client.V1ConfigMap( 274 | data={"mapRoles": yaml.dump(rename_arn_keys(data), default_flow_style=False)} 275 | ) 276 | cm.metadata = kubernetes.client.V1ObjectMeta() 277 | return cm 278 | 279 | 280 | def rename_arn_keys(mappings): 281 | result = [] 282 | if not mappings[0]: 283 | return result 284 | for mapping_orig in mappings: 285 | mapping = copy.copy(mapping_orig) 286 | if mapping["usertype"] == UserType.Role: 287 | mapping["rolearn"] = mapping.pop("arn") 288 | else: 289 | mapping["userarn"] = mapping.pop("arn") 290 | mapping.pop("usertype") 291 | result.append(mapping) 292 | return result 293 | -------------------------------------------------------------------------------- /tests/test_services.py: -------------------------------------------------------------------------------- 1 | from lib.services import * 2 | import pytest 3 | import logging 4 | import kubernetes 5 | 6 | 7 | logger = logging.getLogger() 8 | 9 | 10 | def test_write_last_handled_mapping(mocker): 11 | mocker.patch("lib.services.get_custom_object_api") 12 | write_last_handled_mapping(logger, {}) 13 | -------------------------------------------------------------------------------- /tests/test_worker.py: -------------------------------------------------------------------------------- 1 | from tests.test_operator import DATA_CREATE, DATA_DEFAULT, DATA_UPDATE, build_cm, rename_arn_keys 2 | from lib import create_mapping, delete_mapping, update_mapping, AuthMappingList, Event, EventType 3 | from lib.worker import get_config_map, write_config_map, update_mapping_status 4 | from unittest.mock import MagicMock 5 | import logging 6 | import lib 7 | import kubernetes 8 | import yaml 9 | 10 | def test_create_mapping(mocker): 11 | mocker.patch("lib.worker.get_config_map") 12 | mocker.patch("lib.worker.write_config_map") 13 | mocker.patch("lib.worker.write_last_handled_mapping") 14 | mocker.patch("lib.worker.update_mapping_status") 15 | lib.worker.get_config_map.return_value = build_cm() 16 | lib.worker.write_config_map.return_value = build_cm(extra_data=DATA_CREATE) 17 | logger = logging.Logger( __name__) 18 | spec = {"mappings": [DATA_CREATE]} 19 | mappings = AuthMappingList(spec["mappings"]) 20 | event = Event(event_type=EventType.CREATE, object_name="test", mappings=mappings) 21 | create_mapping(event, logger) 22 | lib.worker.get_config_map.assert_called_once() 23 | lib.worker.write_config_map.assert_called_once() 24 | lib.worker.update_mapping_status.assert_called_once() 25 | config_map, _ = lib.worker.write_config_map.call_args 26 | # import pdb; pdb.set_trace() 27 | assert isinstance(config_map[0], kubernetes.client.V1ConfigMap) 28 | data = { 29 | "mapRoles": yaml.dump( 30 | rename_arn_keys([DATA_DEFAULT, DATA_CREATE]), default_flow_style=False 31 | ) 32 | } 33 | assert config_map[0].data == data 34 | 35 | def test_update_mapping(mocker): 36 | mocker.patch("lib.worker.get_config_map") 37 | mocker.patch("lib.worker.write_config_map") 38 | mocker.patch("lib.worker.write_last_handled_mapping") 39 | mocker.patch("lib.worker.update_mapping_status") 40 | lib.worker.get_config_map.return_value = build_cm() 41 | lib.worker.write_config_map.return_value = build_cm(default=DATA_UPDATE) 42 | old_spec = {"mappings": [DATA_DEFAULT]} 43 | new_spec = {"mappings": [DATA_UPDATE]} 44 | logger = logging.Logger( __name__) 45 | mappings = AuthMappingList(new_spec["mappings"]) 46 | mappings_old = AuthMappingList(old_spec["mappings"]) 47 | event = Event(event_type=EventType.UPDATE, object_name="test", mappings=mappings, old_mappings=mappings_old) 48 | update_mapping(event, logger) 49 | lib.worker.update_mapping_status.assert_called_once() 50 | lib.worker.get_config_map.assert_called_once() 51 | lib.worker.write_config_map.assert_called_once() 52 | config_map, _ = lib.worker.write_config_map.call_args 53 | assert isinstance(config_map[0], kubernetes.client.V1ConfigMap) 54 | data = { 55 | "mapRoles": yaml.dump(rename_arn_keys([DATA_UPDATE]), default_flow_style=False) 56 | } 57 | assert config_map[0].data == data 58 | 59 | def test_delete_mapping(mocker): 60 | mocker.patch("lib.worker.get_config_map") 61 | mocker.patch("lib.worker.write_config_map") 62 | mocker.patch("lib.worker.write_last_handled_mapping") 63 | lib.worker.get_config_map.return_value = build_cm(extra_data=DATA_CREATE) 64 | lib.worker.write_config_map.return_value = build_cm() 65 | logger = logging.Logger( __name__) 66 | spec = {"mappings": [DATA_CREATE]} 67 | mappings = AuthMappingList(spec["mappings"]) 68 | event = Event(event_type=EventType.DELETE, object_name="test", mappings=mappings) 69 | delete_mapping(event, logger) 70 | lib.worker.get_config_map.assert_called_once() 71 | lib.worker.write_config_map.assert_called_once() 72 | config_map, _ = lib.worker.write_config_map.call_args 73 | assert isinstance(config_map[0], kubernetes.client.V1ConfigMap) 74 | data = { 75 | "mapRoles": yaml.dump(rename_arn_keys([DATA_DEFAULT]), default_flow_style=False) 76 | } 77 | assert config_map[0].data == data 78 | 79 | def test_create_mapping_failed(mocker): 80 | mocker.patch("lib.worker.get_config_map") 81 | mocker.patch("lib.worker.write_config_map") 82 | mocker.patch("lib.worker.write_last_handled_mapping") 83 | mocker.patch("lib.worker.update_mapping_status") 84 | lib.worker.get_config_map.return_value = build_cm() 85 | lib.worker.write_config_map.return_value = build_cm(default={}) 86 | logger = MagicMock() 87 | spec = {"mappings": [DATA_CREATE]} 88 | mappings = AuthMappingList(spec["mappings"]) 89 | event = Event(event_type=EventType.CREATE, object_name="test", mappings=mappings) 90 | create_mapping(event, logger) 91 | logger.error.assert_called_once_with("Add Roles failed") 92 | lib.worker.update_mapping_status.assert_called_once() 93 | 94 | def test_update_mapping_failed(mocker): 95 | mocker.patch("lib.worker.get_config_map") 96 | mocker.patch("lib.worker.write_config_map") 97 | mocker.patch("lib.worker.write_last_handled_mapping") 98 | mocker.patch("lib.worker.update_mapping_status") 99 | lib.worker.get_config_map.return_value = build_cm() 100 | lib.worker.write_config_map.return_value = build_cm() 101 | old_spec = {"mappings": [DATA_DEFAULT]} 102 | new_spec = {"mappings": [DATA_UPDATE]} 103 | logger = MagicMock() 104 | mappings = AuthMappingList(new_spec["mappings"]) 105 | mappings_old = AuthMappingList(old_spec["mappings"]) 106 | event = Event(event_type=EventType.UPDATE, object_name="test", mappings=mappings, old_mappings=mappings_old) 107 | update_mapping(event, logger) 108 | logger.error.assert_called_once_with("Update Roles failed") 109 | lib.worker.update_mapping_status.assert_called_once() 110 | 111 | def test_delete_mapping_failed(mocker): 112 | mocker.patch("lib.worker.get_config_map") 113 | mocker.patch("lib.worker.write_config_map") 114 | mocker.patch("lib.worker.write_last_handled_mapping") 115 | mocker.patch("lib.worker.update_mapping_status") 116 | lib.worker.get_config_map.return_value = build_cm(extra_data=DATA_CREATE) 117 | lib.worker.write_config_map.return_value = build_cm(extra_data=DATA_CREATE) 118 | logger = MagicMock() 119 | spec = {"mappings": [DATA_CREATE]} 120 | mappings = AuthMappingList(spec["mappings"]) 121 | event = Event(event_type=EventType.DELETE, object_name="test", mappings=mappings) 122 | delete_mapping(event, logger) 123 | logger.error.assert_called_once_with("Delete Roles failed") 124 | lib.worker.update_mapping_status.assert_called_once() 125 | --------------------------------------------------------------------------------