Skip to content

Commit

Permalink
Implemented CI/CD pipeline which builds, packages and deploys a Pytho…
Browse files Browse the repository at this point in the history
…n Flask encryption service to Azure Function Apps.
  • Loading branch information
hvalfangst committed Dec 25, 2023
1 parent 607b5a2 commit 7cb8e3d
Show file tree
Hide file tree
Showing 12 changed files with 367 additions and 1 deletion.
7 changes: 7 additions & 0 deletions .funcignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git*
.idea*
.terraform*
terraform
local.settings.json
test
.venv
75 changes: 75 additions & 0 deletions .github/workflows/main_hvalfangstlinuxfunctionapp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Docs for the Azure Web Apps Deploy action: https://github.com/azure/functions-action
# More GitHub Actions for Azure: https://github.com/Azure/actions
# More info on Python, GitHub Actions, and Azure Functions: https://aka.ms/python-webapps-actions

name: Build and deploy Python project to Azure Function App - hvalfangstlinuxfunctionapp

on:
push:
branches:
- main
workflow_dispatch:

env:
AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' # set this to the path to your web app project, defaults to the repository root
PYTHON_VERSION: '3.10' # set this to the python version to use (supports 3.6, 3.7, 3.8)

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Python version
uses: actions/setup-python@v1
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Create and start virtual environment
run: |
python -m venv venv
source venv/bin/activate
- name: Install dependencies
run: pip install -r requirements.txt

# Optional: Add step to run tests here

- name: Zip artifact for deployment
run: zip release.zip ./* -r

- name: Upload artifact for deployment job
uses: actions/upload-artifact@v3
with:
name: python-app
path: |
release.zip
!venv/
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'Production'
url: ${{ steps.deploy-to-function.outputs.webapp-url }}

steps:
- name: Download artifact from build job
uses: actions/download-artifact@v3
with:
name: python-app

- name: Unzip artifact for deployment
run: unzip release.zip

- name: 'Deploy to Azure Functions'
uses: Azure/functions-action@v1
id: deploy-to-function
with:
app-name: 'hvalfangstlinuxfunctionapp'
slot-name: 'Production'
package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
scm-do-build-during-deployment: true
enable-oryx-build: true
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_D07B4CD95A574A438600A5F0A9A78160 }}
48 changes: 48 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
bin
obj
csx
.vs
edge
Publish

*.user
*.suo
*.cscfg
*.Cache
project.lock.json

/packages
/TestResults

/tools/NuGet.exe
/App_Data
/secrets
/data
.secrets
appsettings.json
local.settings.json

node_modules
dist

# Local python packages
.python_packages/

# Python Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# Azurite artifacts
__blobstorage__
__queuestorage__
__azurite_db*__.json
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Azure Function in Python with CI/CD

Azure Function programmed in Python with the Flask HTTP framework,
providing endpoints for encryption utilizing AES 256 CBC algorithm.
A CI/CD pipeline has been implemented with GitHub Actions.
It will build, package and deploy this application to Azure Function Apps
on any repository pushes. Azure resources are provisioned with Terraform.

## Requirements

* x86-64
* Linux/Unix
* [Python](https://www.python.org/downloads/)

## Creating resources

The shell script 'up' allocates Azure resources with Terraform.

## Deleting resources

The shell script 'down' deallocates Azure resources.


## Guide

### 1. Provision Azure Resources

- Run the 'up' script to provision Azure resources.

### 2. Access Azure Portal

- Open your browser and navigate to the Azure Portal.

### 3. Configure Deployment Credentials

- Navigate to your newly created Function App 'hvalfangstfunctionapp'.
- Click on 'Deployment Center' under 'Settings'.
- Choose GitHub as the source and proceed to authenticate & authorize your GH account.
- After your account has been validated you may now choose a target repository and branch.
- Click 'Save'.
- A new folder named '.github' will now be pushed to your repository on behalf of Azure.
- This folder contains the GitHub Action Workflow definition, which enables CI/CD.
73 changes: 73 additions & 0 deletions flask_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from flask import Flask, jsonify, request

from flask_api.crypto_utils.module import aes_encrypt, aes_decrypt

app = Flask(__name__)


@app.route("/")
def index():
return (
"Welcome to Hvalfangst Crypto API!\n\n"
"Available Endpoints:\n"
"1. POST /encrypt - Encrypt text\n"
" Use this endpoint to encrypt plain text with a provided encryption key.\n\n"
"2. POST /decrypt - Decrypt text\n"
" Use this endpoint to decrypt encrypted text with a provided encryption key.\n"
)


@app.route("/encrypt", methods=["POST"])
def encrypt():
# Extract data from JSON body
request_data = request.get_json()

# Check if 'plain_text' and 'encryption_key' fields are present in the JSON
if "plain_text" not in request_data or "encryption_key" not in request_data:
return jsonify({"error": "Missing 'plain_text' or 'encryption_key' in the request body"}), 400

plain_text = request_data["plain_text"]
encryption_key = request_data["encryption_key"]

# Encrypt plain text using key provided in request body
encrypted_text = aes_encrypt(encryption_key, plain_text)

# Prepare response JSON
response = {
"mode": "encrypt",
"plain_text": plain_text,
"encrypted_text": encrypted_text
}

# Return JSON response
return jsonify(response)


@app.route("/decrypt", methods=["POST"])
def decrypt():
# Extract data from JSON body
request_data = request.get_json()

# Check if 'encrypted_text' and 'encryption_key' fields are present in the JSON
if "encrypted_text" not in request_data or "encryption_key" not in request_data:
return jsonify({"error": "Missing 'encrypted_text' or 'encryption_key' in the request body"}), 400

encrypted_text = request_data["encrypted_text"]
encryption_key = request_data["encryption_key"]

# Decrypt encrypted text using key provided in request body
plain_text = aes_decrypt(encryption_key, encrypted_text)

# Prepare response JSON
response = {
"mode": "decrypt",
"encrypted_text": encrypted_text,
"plain_text": plain_text
}

# Return JSON response
return jsonify(response)


if __name__ == "__main__":
app.run()
Empty file.
72 changes: 72 additions & 0 deletions flask_api/crypto_utils/module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Hash import SHA256
from Crypto.Util.Padding import pad, unpad
import base64


def base64_encode(input_bytes: bytes) -> str:
return base64.b64encode(input_bytes).decode("utf-8")


def base64_decode(input_str: str) -> bytes:
return base64.b64decode(input_str.encode("utf-8"))


def generate_salt_32_byte() -> bytes:
return get_random_bytes(32)


def aes_encrypt(encryption_key: str, plaintext: str) -> str:
# Convert plaintext encryption key to bytes
password_bytes = encryption_key.encode("ascii")
# Generate random sequence of 32 bytes
salt = generate_salt_32_byte()
# Set key derivation interation count to 15000
pbkdf2_iterations = 15000

# Derive new key based on encryption key, salt and iterations
derived_key = PBKDF2(
password_bytes, salt, 32, count=pbkdf2_iterations, hmac_hash_module=SHA256
)

cipher = AES.new(derived_key, AES.MODE_CBC)

# Encrypt plaintext using our CBC cipher
ciphertext = cipher.encrypt(pad(plaintext.encode("ascii"), AES.block_size))

# B64-encode iv, salt and ciphertext (from bytes to text)
iv_base64 = base64_encode(cipher.iv)
salt_base64 = base64_encode(salt)
ciphertext_base64 = base64_encode(ciphertext)

# Return tuple containing base64-encoded {salt, iv and ciphertext}
return f"{salt_base64}:{iv_base64}:{ciphertext_base64}"


def aes_decrypt(encryption_key: str, ciphertext_base64: str) -> str:
# Convert plaintext encryption key to bytes
password_bytes = encryption_key.encode("ascii")

# B64-decode tuple containing ciphertext, salt and iv (from text to bytes)
data = ciphertext_base64.split(":")
salt = base64_decode(data[0])
iv = base64_decode(data[1])
ciphertext = base64_decode(data[2])

# Set key derivation interation count to 15000
pbkdf2_iterations = 15000

# Derive new key based on encryption key, salt and iterations
derived_key = PBKDF2(
password_bytes, salt, 32, count=pbkdf2_iterations, hmac_hash_module=SHA256
)

cipher = AES.new(derived_key, AES.MODE_CBC, iv)

# Decrypt ciphertext using derived_key key and iv
decrypted_text = unpad(cipher.decrypt(ciphertext), AES.block_size)

# Return original plain text as text
return decrypted_text.decode("utf-8")
8 changes: 8 additions & 0 deletions host.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"version": "2.0",
"extensions": {
"http": {
"routePrefix": ""
}
}
}
8 changes: 8 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Do not include azure-functions-worker in this file
# The Python Worker is managed by the Azure Functions platform
# Manually managing azure-functions-worker may cause unexpected issues

azure-functions
Flask
crypto
pycryptodome
6 changes: 5 additions & 1 deletion terraform.tf
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,9 @@ resource "azurerm_linux_function_app" "example" {
storage_account_name = azurerm_storage_account.hvalfangst.name
storage_account_access_key = azurerm_storage_account.hvalfangst.primary_access_key
service_plan_id = azurerm_service_plan.hvalfangst.id
site_config {}
site_config {
application_stack{
python_version = "3.10"
}
}
}
8 changes: 8 additions & 0 deletions wsgi_middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import azure.functions as func
from flask_api import app


def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse:
"""Each request is redirected to the WSGI handler, which serves our Flask API.
"""
return func.WsgiMiddleware(app.wsgi_app).handle(req, context)
21 changes: 21 additions & 0 deletions wsgi_middleware/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"scriptFile": "__init__.py",
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": [
"get",
"post"
],
"route": "/{*route}"
},
{
"type": "http",
"direction": "out",
"name": "$return"
}
]
}

0 comments on commit 7cb8e3d

Please sign in to comment.