diff --git a/README.md b/README.md index 0ac26cb..c93ad13 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ The first time you run a Jupyter notebook, you'll be asked to install the Jupyte |:-----------------|:----------------------------------------------------------------------------|:-------------------------------------------| | [General](./samples/general/create.ipynb) | Basic demo of APIM sample setup and policy usage. | All infrastructures | | [Load Balancing](./samples/load-balancing/create.ipynb) | Priority and weighted load balancing across backends. | apim-aca, afd-apim (with ACA) | +| [AuthX](./samples/authx/create.ipynb) | Authentication and role-based authorization in a mock HR API. | All infrastructures | ### Running a Sample diff --git a/requirements.txt b/requirements.txt index e13b8d7..4bb6205 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ requests setuptools pandas matplotlib +pyjwt pytest pytest-cov \ No newline at end of file diff --git a/samples/_TEMPLATE/create.ipynb b/samples/_TEMPLATE/create.ipynb index 1d98bf6..83057be 100644 --- a/samples/_TEMPLATE/create.ipynb +++ b/samples/_TEMPLATE/create.ipynb @@ -18,6 +18,10 @@ "1. [LEARNING / EXPERIMENTATION OBJECTIVE 2]\n", "1. ...\n", "\n", + "## Scenario\n", + "\n", + "[IF THE SAMPLE IS DEMONSTRATED THROUGH A USE CASE OR SCENARIO, PLEASE DETAIL IT HERE. OTHERWISE, DELETE THIS SECTION]\n", + "\n", "## Lab Components\n", "\n", "[DESCRIBE IN MORE DETAIL WHAT THIS LAB SETS UP AND HOW THIS BENEFITS THE LEARNER/USER.]\n", diff --git a/samples/authX/create.ipynb b/samples/authX/create.ipynb new file mode 100644 index 0000000..a7f5dad --- /dev/null +++ b/samples/authX/create.ipynb @@ -0,0 +1,246 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Samples: AuthX - Authentication & Authorization\n", + "\n", + "[BRIEF SAMPLE DESCRIPTION]\n", + "\n", + "⚙️ **Supported infrastructures**: All infrastructures\n", + "\n", + "⌚ **Expected *Run All* runtime (excl. infrastructure prerequisite): ~[NOTEBOOK RUNTIME] minute**\n", + "\n", + "## Objectives\n", + "\n", + "1. Understand how API Management supports OAuth 2.0 authentication (authN) with JSON Web Tokens (JWT).\n", + "1. Learn how authorization (authZ) can be accomplished based on JWT claims.\n", + "1. Configure authN and authZ at various levels in the API Management hierarchy.\n", + "1. Use external secrets in policies.\n", + "\n", + "## Scenario\n", + "\n", + "This sample combines _authentication (authN)_ and _authorization (authZ)_ into _authX_. This scenario focuses on a Human Resources API that requires privileged role-based access to GET and to POST data. This is simplistic but shows the combination of authN and authZ.\n", + "\n", + "There are two personas at play:\n", + "\n", + "- `HR Administrator` - holds broad rights to the API\n", + "- `HR Associate` - has read-only permissions\n", + "\n", + "Both personas are part of an HR_Members group and may access the HR API Management Product. Subsequent access to the APIs and their operations must be granular.\n", + "\n", + "### Notes\n", + "\n", + "Many organizations require 100% authentication for their APIs. While that is prudent and typically done at the global _All APIs_ level, we refrain from doing so here as to not impact other samples. Instead, we focus on authentication at the API Management API and API operation levels.\n", + "\n", + "## Lab Components\n", + "\n", + "While OAuth 2.0 includes an identity provider (IDP), for sake of the sample, we can remove the complexity of including real identities. It is sufficient to use mock JWTs that we can \"authenticate\" by way of a signing key. This is a valid, albeit not the default method for authentication. \n", + "\n", + "We do not need real APIs and can rely on mock returns.\n", + "\n", + "Furthermore, secrets would ideally be kept in a secret store such as Azure Key Vault and be accessed via API Management's managed identity. Adding a Key Vault to our architecture is a stretch goal that provides value but is not immediately necessary to showcase the authX sample.\n", + "\n", + "JSON Web Tokens are defined in [RFC 7519](https://www.rfc-editor.org/rfc/rfc7519). Two websites to use with JWTs are [Okta's](https://jwt.io/) and [Microsoft's](https://jwt.ms/). Okta's may be preferential due to its features.\n", + "\n", + "## Configuration\n", + "\n", + "1. Decide which of the [Infrastructure Architectures](../../README.md#infrastructure-architectures) you wish to use.\n", + " 1. If the infrastructure _does not_ yet exist, navigate to the desired [infrastructure](../../infrastructure/) folder and follow its README.md.\n", + " 1. If the infrastructure _does_ exist, adjust the `user-defined parameters` in the _Initialize notebook variables_ below. Please ensure that all parameters match your infrastructure." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Initialize notebook variables\n", + "\n", + "Configures everything that's needed for deployment. \n", + "\n", + "[ADD ANY SPECIAL INSTRUCTIONS]\n", + "\n", + "**Modify entries under _1) User-defined parameters_ and _3) Define the APIs and their operations and policies_**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import utils\n", + "import time\n", + "from apimtypes import *\n", + "\n", + "# 1) User-defined parameters (change these as needed)\n", + "rg_location = 'eastus2'\n", + "index = 1\n", + "deployment = INFRASTRUCTURE.SIMPLE_APIM\n", + "tags = ['authX', 'jwt', 'hr'] # ENTER DESCRIPTIVE TAG(S)\n", + "api_prefix = 'authX-' # OPTIONAL: ENTER A PREFIX FOR THE APIS TO REDUCE COLLISION POTENTIAL WITH OTHER SAMPLES\n", + "\n", + "# 2) Service-defined parameters (please do not change these)\n", + "rg_name = utils.get_infra_rg_name(deployment, index)\n", + "supported_infrastructures = [INFRASTRUCTURE.SIMPLE_APIM, INFRASTRUCTURE.AFD_APIM_PE, INFRASTRUCTURE.APIM_ACA] # ENTER SUPPORTED INFRASTRUCTURES HERE, e.g., [INFRASTRUCTURE.AFD_APIM_PE, INFRASTRUCTURE.AFD_APIM_FE]\n", + "utils.validate_infrastructure(deployment, supported_infrastructures)\n", + "\n", + "# Set up the signing key for the JWT policy\n", + "jwt_key_name = f'JwtSigningKey{int(time.time())}'\n", + "jwt_key_value, jwt_key_value_bytes_b64 = utils.generate_signing_key()\n", + "utils.print_val('JWT key value', jwt_key_value) # this value is used to create the signed JWT token for requests to APIM\n", + "utils.print_val('JWT key value (base64)', jwt_key_value_bytes_b64) # this value is used in the APIM validate-jwt policy's issuer-signing-key attribute \n", + "\n", + "# 3) Define the APIs and their operations and policies\n", + "\n", + "# Policies\n", + "# Named values must be set up a bit differently as they need to have two surrounding curly braces\n", + "hr_all_operations_xml = utils.read_policy_xml('./hr_all_operations.xml').format(\n", + " jwt_signing_key = '{{' + jwt_key_name + '}}', \n", + " hr_member_role_id = '{{HRMemberRoleId}}'\n", + ")\n", + "hr_get_xml = utils.read_policy_xml('./hr_get.xml').format(\n", + " hr_administrator_role_id = '{{HRAdministratorRoleId}}',\n", + " hr_associate_role_id = '{{HRAssociateRoleId}}'\n", + ")\n", + "hr_post_xml = utils.read_policy_xml('./hr_post.xml').format(\n", + " hr_administrator_role_id = '{{HRAdministratorRoleId}}'\n", + ")\n", + "\n", + "# Employees (HR)\n", + "hremployees_get = GET_APIOperation('Gets the employees', hr_get_xml)\n", + "hremployees_post = POST_APIOperation('Creates a new employee', hr_post_xml)\n", + "hremployees = API('Employees', 'Employees', '/employees', 'This is a Human Resources API to obtain employee information', hr_all_operations_xml, operations = [hremployees_get, hremployees_post], tags = tags)\n", + "\n", + "# APIs Array\n", + "apis: List[API] = [hremployees]\n", + "\n", + "# 4) Set up the named values\n", + "nvs: List[NamedValue] = [\n", + " NamedValue(jwt_key_name, jwt_key_value_bytes_b64, True),\n", + " NamedValue('HRMemberRoleId', HR_MEMBER_ROLE_ID),\n", + " NamedValue('HRAssociateRoleId', HR_ASSOCIATE_ROLE_ID),\n", + " NamedValue('HRAdministratorRoleId', HR_ADMINISTRATOR_ROLE_ID)\n", + "]\n", + "\n", + "utils.print_ok('Notebook initialized')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create deployment using Bicep\n", + "\n", + "Creates the bicep deployment into the previously-specified resource group. A bicep parameters file will be created prior to execution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import utils\n", + "\n", + "# 1) Define the Bicep parameters with serialized APIs\n", + "bicep_parameters = {\n", + " 'apis': {'value': [api.to_dict() for api in apis]},\n", + " 'namedValues': {'value': [nv.to_dict() for nv in nvs]}\n", + "}\n", + "\n", + "# 2) Infrastructure must be in place before samples can be layered on top\n", + "if not utils.does_resource_group_exist(rg_name):\n", + " utils.print_error(f'The specified infrastructure resource group and its resources must exist first. Please check that the user-defined parameters above are correctly referencing an existing infrastructure. If it does not yet exist, run the desired infrastructure in the /infra/ folder first.')\n", + " raise SystemExit(1)\n", + "\n", + "# 3) Run the deployment\n", + "output = utils.create_bicep_deployment_group(rg_name, rg_location, deployment, bicep_parameters)\n", + "\n", + "# 4) Print a deployment summary, if successful; otherwise, exit with an error\n", + "if not output.success:\n", + " raise SystemExit('Deployment failed')\n", + "\n", + "if output.success and output.json_data:\n", + " apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n", + "\n", + "utils.print_ok('Deployment completed')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Verify API Request Success\n", + "\n", + "Assert that the deployment was successful by making simple calls to APIM. \n", + "\n", + "❗️ If the infrastructure shields APIM and requires a different ingress (e.g. Azure Front Door), the request to the APIM gateway URl will fail by design. Obtain the Front Door endpoint hostname and try that instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import utils\n", + "from apimrequests import ApimRequests\n", + "from apimjwt import JwtPayload, SymmetricJwtToken\n", + "from apimtypes import HR_MEMBER_ROLE_ID, HR_ADMINISTRATOR_ROLE_ID, HR_ASSOCIATE_ROLE_ID\n", + "\n", + "# 1) HR Administrator\n", + "# Create a JSON Web Token with a payload and sign it with the symmetric key from above.\n", + "jwt_payload_hr_admin = JwtPayload(subject = 'user123', name = 'Angie Administrator', roles = [HR_MEMBER_ROLE_ID, HR_ADMINISTRATOR_ROLE_ID])\n", + "encoded_jwt_token_hr_admin = SymmetricJwtToken(jwt_key_value, jwt_payload_hr_admin).encode()\n", + "print(f'JWT token HR Admin: {encoded_jwt_token_hr_admin}') # this value is used to call the APIs via APIM\n", + "\n", + "# Set up an APIM requests object with the JWT token\n", + "reqsApimAdmin = ApimRequests(apim_gateway_url)\n", + "reqsApimAdmin.headers['Authorization'] = f'Bearer {encoded_jwt_token_hr_admin}'\n", + "\n", + "# Call APIM\n", + "reqsApimAdmin.singleGet('/employees', msg = 'Calling GET Employees API via API Management Gateway URL. Expect 200.')\n", + "reqsApimAdmin.singlePost('/employees', msg = 'Calling POST Employees API via API Management Gateway URL. Expect 200.')\n", + "\n", + "# 2) HR Associate\n", + "# Create a JSON Web Token with a payload and sign it with the symmetric key from above.\n", + "jwt_payload_hr_associate = JwtPayload(subject = 'user789', name = 'Aaron Associate', roles = [HR_MEMBER_ROLE_ID, HR_ASSOCIATE_ROLE_ID])\n", + "encoded_jwt_token_hr_associate = SymmetricJwtToken(jwt_key_value, jwt_payload_hr_associate).encode()\n", + "print(f'\\n\\nJWT token HR Associate: {encoded_jwt_token_hr_associate}') # this value is used to call the APIs via APIM\n", + "\n", + "# Set up an APIM requests object with the JWT token\n", + "reqsApimAssociate = ApimRequests(apim_gateway_url)\n", + "reqsApimAssociate.headers['Authorization'] = f'Bearer {encoded_jwt_token_hr_associate}'\n", + "\n", + "# Call APIM\n", + "reqsApimAssociate.singleGet('/employees', msg = 'Calling GET Employees API via API Management Gateway URL. Expect 200.')\n", + "reqsApimAssociate.singlePost('/employees', msg = 'Calling POST Employees API via API Management Gateway URL. Expect 403.')\n", + "\n", + "utils.print_ok('All done!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/samples/authX/hr_all_operations.xml b/samples/authX/hr_all_operations.xml new file mode 100644 index 0000000..7de588b --- /dev/null +++ b/samples/authX/hr_all_operations.xml @@ -0,0 +1,27 @@ + + + + + + + {jwt_signing_key} + + + + {hr_member_role_id} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/authX/hr_get.xml b/samples/authX/hr_get.xml new file mode 100644 index 0000000..98bfeb8 --- /dev/null +++ b/samples/authX/hr_get.xml @@ -0,0 +1,31 @@ + + + + + + + + + + Returning a mock employee + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/authX/hr_post.xml b/samples/authX/hr_post.xml new file mode 100644 index 0000000..770d6e2 --- /dev/null +++ b/samples/authX/hr_post.xml @@ -0,0 +1,31 @@ + + + + + + + + + + A mock employee has been created. + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/authX/main.bicep b/samples/authX/main.bicep new file mode 100644 index 0000000..60681e2 --- /dev/null +++ b/samples/authX/main.bicep @@ -0,0 +1,69 @@ +// ------------------ +// PARAMETERS +// ------------------ + +@description('Location to be used for resources. Defaults to the resource group location') +param location string = resourceGroup().location + +@description('The unique suffix to append. Defaults to a unique string based on subscription and resource group IDs.') +param resourceSuffix string = uniqueString(subscription().id, resourceGroup().id) + +param namedValues array = [] +param apimName string = 'apim-${resourceSuffix}' +param appInsightsName string = 'appi-${resourceSuffix}' +param apis array = [] + +// [ADD RELEVANT PARAMETERS HERE] + +// ------------------ +// RESOURCES +// ------------------ + +// https://learn.microsoft.com/azure/templates/microsoft.insights/components +resource appInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: appInsightsName +} + +var appInsightsId = appInsights.id +var appInsightsInstrumentationKey = appInsights.properties.InstrumentationKey + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service +resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = { + name: apimName +} + +// APIM Named Values +module namedValue '../../shared/bicep/modules/apim/v1/named-value.bicep' = [for nv in namedValues: if (length(namedValues) > 0) { + name: nv.name + params: { + apimName: apimName + namedValueName: nv.name + namedValueValue: nv.value + namedValueIsSecret: nv.isSecret + } +}] + +// APIM APIs +module apisModule '../../shared/bicep/modules/apim/v1/api.bicep' = [for api in apis: if(length(apis) > 0) { + name: '${api.name}-${resourceSuffix}' + params: { + apimName: apimName + appInsightsInstrumentationKey: appInsightsInstrumentationKey + appInsightsId: appInsightsId + api: api + } + dependsOn: [ + namedValue // ensure all named values are created before APIs + ] +}] + +// [ADD RELEVANT BICEP MODULES HERE] + +// ------------------ +// MARK: OUTPUTS +// ------------------ + +output apimServiceId string = apimService.id +output apimServiceName string = apimService.name +output apimResourceGatewayURL string = apimService.properties.gatewayUrl +// [ADD RELEVANT OUTPUTS HERE] diff --git a/shared/bicep/modules/apim/v1/api.bicep b/shared/bicep/modules/apim/v1/api.bicep index c42ed4a..54d1ea5 100644 --- a/shared/bicep/modules/apim/v1/api.bicep +++ b/shared/bicep/modules/apim/v1/api.bicep @@ -1,5 +1,5 @@ /** - * @module apim-v1 + * @module api-v1 * @description This module defines the API resources using Bicep. * It includes configurations for creating and managing APIs, products, and policies. */ diff --git a/shared/bicep/modules/apim/v1/named-value.bicep b/shared/bicep/modules/apim/v1/named-value.bicep new file mode 100644 index 0000000..42cc11a --- /dev/null +++ b/shared/bicep/modules/apim/v1/named-value.bicep @@ -0,0 +1,56 @@ +/** + * @module named-value-v1 + * @description This module defines the named value resource using Bicep. + * It includes configurations plain text and secret named values but not for Key Vault (may create a separate module for that). + */ + + +// ------------------------------ +// PARAMETERS +// ------------------------------ + +@description('Location to be used for resources. Defaults to the resource group location') +param location string = resourceGroup().location + +@description('The unique suffix to append. Defaults to a unique string based on subscription and resource group IDs.') +param resourceSuffix string = uniqueString(subscription().id, resourceGroup().id) + +@description('The name of the API Management service.') +param apimName string + +@description('The name of the named value to create.') +param namedValueName string + +@description('The value to assign to the named value.') +param namedValueValue string + +@description('Whether the value is a secret.') +param namedValueIsSecret bool = false + +// ------------------------------ +// RESOURCES +// ------------------------------ + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service +resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = { + name: apimName +} + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/namedvalues +resource namedValue 'Microsoft.ApiManagement/service/namedValues@2024-06-01-preview' = { + name: namedValueName + parent: apimService + properties: { + displayName: namedValueName + value: namedValueValue + secret: namedValueIsSecret + tags: [] + } +} + +// ------------------------------ +// OUTPUTS +// ------------------------------ + +@description('The resource ID of the created named value.') +output namedValueResourceId string = namedValue.id diff --git a/shared/python/apimjwt.py b/shared/python/apimjwt.py new file mode 100644 index 0000000..f89f101 --- /dev/null +++ b/shared/python/apimjwt.py @@ -0,0 +1,75 @@ +""" +Module for creating JSON Web Tokens (JWT) used with API Management requests. +""" + +import jwt +import time +from typing import Any, Dict + + +# ------------------------------ +# CLASSES +# ------------------------------ + +class JwtPayload: + """ + Represents the payload (claims) of a JSON Web Token for APIM testing. + https://datatracker.ietf.org/doc/html/rfc7519 + """ + + DEFAULT_LIFETIME_SECONDS = 3600 * 24 # Default lifetime of 24 hours + + def __init__(self, subject: str, name: str, issued_at: int | None = None, expires: int | None = None, roles: dict[str] | None = None) -> None: + self.sub = subject + self.name = name + self.iat = issued_at if issued_at is not None else int(time.time()) + self.exp = expires if expires is not None else self.iat + self.DEFAULT_LIFETIME_SECONDS + self.roles = roles if roles is not None else [] + + def to_dict(self) -> dict[str, Any]: + """ + Convert the payload to a dictionary for encoding. + """ + + pl: dict[str, Any] = { + "sub": self.sub, + "name": self.name, + "iat": self.iat, + "exp": self.exp + } + + if bool(self.roles): + pl["roles"] = self.roles + + return pl + + +class SymmetricJwtToken: + """ + Represents a JSON Web Token using a symmetric signing algorithm (HS256) for APIM testing. + This is a simple implementation for demonstration purposes as it uses a shared secret key + for the token creation and verification. This is not production-ready code. + """ + + def __init__(self, key: str, payload: JwtPayload) -> None: + """ + Initialize the SymmetricJwtToken with a signing key and payload. + + Args: + key (str): The symmetric key as a regular ASCII string. This should NOT be a base64-encoded string. Use the raw ASCII string that will be used for signing the JWT. If you have a base64-encoded key, decode it to its ASCII form before passing it here. + payload (JwtPayload): The payload (claims) for the JWT. + """ + self.key = key + self.payload = payload + + def encode(self) -> str: + """ + Encode the JWT token using the provided key and payload. + + Returns: + str: The encoded JWT as a string. + + Note: + The key parameter used for signing must be a regular ASCII string, NOT a base64-encoded string. If you have a base64-encoded key, decode it to its ASCII form before using it here. Passing a base64-encoded string directly will result in signature validation errors in APIM or other JWT consumers. + """ + return jwt.encode(self.payload.to_dict(), self.key, algorithm = "HS256") \ No newline at end of file diff --git a/shared/python/apimrequests.py b/shared/python/apimrequests.py index 4250efe..b0bcf91 100644 --- a/shared/python/apimrequests.py +++ b/shared/python/apimrequests.py @@ -36,13 +36,32 @@ def __init__(self, url: str, apimSubscriptionKey: str | None = None): self.url = url self.apimSubscriptionKey = apimSubscriptionKey - self.headers = {} + self._headers: dict[str, str] = {} if self.apimSubscriptionKey: - self.headers[SUBSCRIPTION_KEY_PARAMETER_NAME] = self.apimSubscriptionKey + self._headers[SUBSCRIPTION_KEY_PARAMETER_NAME] = self.apimSubscriptionKey - self.headers['Accept'] = 'application/json' + self._headers['Accept'] = 'application/json' + @property + def headers(self) -> dict[str, str]: + """ + Get the HTTP headers used for requests. + + Returns: + dict[str, str]: The headers dictionary. + """ + return self._headers + + @headers.setter + def headers(self, value: dict[str, str]) -> None: + """ + Set the HTTP headers used for requests. + + Args: + value: The new headers dictionary. + """ + self._headers = value # ------------------------------ # PRIVATE METHODS @@ -209,7 +228,7 @@ def singleGet(self, path: str, headers = None, msg: str | None = None, printResp return self._request(method = HTTP_VERB.GET, path = path, headers = headers, msg = msg, printResponse = printResponse) - def singlePost(self, path: str, *, headers = None, data = None, msg: str | None = None, printResponse) -> Any: + def singlePost(self, path: str, *, headers = None, data = None, msg: str | None = None, printResponse = True) -> Any: """ Make a POST request to the Azure API Management service. diff --git a/shared/python/apimtypes.py b/shared/python/apimtypes.py index 3ac92d0..3092e87 100644 --- a/shared/python/apimtypes.py +++ b/shared/python/apimtypes.py @@ -23,6 +23,11 @@ SUBSCRIPTION_KEY_PARAMETER_NAME = 'api_key' SLEEP_TIME_BETWEEN_REQUESTS_MS = 50 +# Mock role IDs for testing purposes +HR_MEMBER_ROLE_ID = "316790bc-fbd3-4a14-8867-d1388ffbc195" +HR_ASSOCIATE_ROLE_ID = "d3c1b0f2-4a5e-4c8b-9f6d-7c8e1f2a3b4c" +HR_ADMINISTRATOR_ROLE_ID = "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6" + # ------------------------------ # PRIVATE METHODS @@ -231,4 +236,38 @@ class POST_APIOperation(APIOperation): # ------------------------------ def __init__(self, description: str, policyXml: Optional[str] = None): - super().__init__('POST', 'POST', '/', HTTP_VERB.POST, description, policyXml) \ No newline at end of file + super().__init__('POST', 'POST', '/', HTTP_VERB.POST, description, policyXml) + + +@dataclass +class NamedValue: + """ + Represents a named value within API Management. + """ + + name: str + value: str + isSecret: bool = False + + # ------------------------------ + # CONSTRUCTOR + # ------------------------------ + + def __init__(self, name: str, value: str, isSecret: bool = False): + self.name = name + self.value = value + self.isSecret = isSecret + + + # ------------------------------ + # PUBLIC METHODS + # ------------------------------ + + def to_dict(self) -> dict: + nv_dict = { + "name": self.name, + "value": self.value, + "isSecret": self.isSecret + } + + return nv_dict \ No newline at end of file diff --git a/shared/python/utils.py b/shared/python/utils.py index a8ddf5f..46266df 100644 --- a/shared/python/utils.py +++ b/shared/python/utils.py @@ -9,6 +9,9 @@ import textwrap import time import traceback +import string +import secrets +import base64 from typing import Any, Tuple from apimtypes import APIM_SKU, HTTP_VERB, INFRASTRUCTURE @@ -596,13 +599,13 @@ def run(command: str, ok_message: str = '', error_message: str = '', print_outpu # Handles both CalledProcessError and any custom/other exceptions (for test mocks) output_text = getattr(e, 'output', b'').decode("utf-8") if hasattr(e, 'output') and isinstance(e.output, (bytes, bytearray)) else str(e) success = False + traceback.print_exc() if print_output: print(f"Command output:\n{output_text}") minutes, seconds = divmod(time.time() - start_time, 60) - # Only print failures, warnings, or errors if print_output is True if print_output: for line in output_text.splitlines(): @@ -643,4 +646,30 @@ def validate_infrastructure(infra: INFRASTRUCTURE, supported_infras: list[INFRAS """ if infra not in supported_infras: - raise ValueError(f"Unsupported infrastructure: {infra}. Supported infrastructures are: {', '.join([i.value for i in supported_infras])}") \ No newline at end of file + raise ValueError(f"Unsupported infrastructure: {infra}. Supported infrastructures are: {', '.join([i.value for i in supported_infras])}") + +def generate_signing_key() -> tuple[str, str]: + """ + Generate a random signing key string of length 32–100 using [A-Za-z0-9], and return: + + 1. The generated ASCII string. + 2. The base64-encoded string of the ASCII bytes. + + Returns: + tuple[str, str]: + - random_string (str): The generated random ASCII string. + - b64 (str): The base64-encoded string of the ASCII bytes. + """ + + # 1) Generate a random length string based on [A-Za-z0-9] + length = secrets.choice(range(32, 101)) + alphabet = string.ascii_letters + string.digits + random_string = ''.join(secrets.choice(alphabet) for _ in range(length)) + + # 2) Convert the string to an ASCII byte array + string_in_bytes = random_string.encode('ascii') + + # 3) Base64-encode the ASCII byte array + b64 = base64.b64encode(string_in_bytes).decode('utf-8') + + return random_string, b64 diff --git a/tests/python/test_apimrequests.py b/tests/python/test_apimrequests.py index 4ce57fd..60f5bda 100644 --- a/tests/python/test_apimrequests.py +++ b/tests/python/test_apimrequests.py @@ -192,3 +192,18 @@ class DummyResponse: with patch("shared.python.apimrequests.utils.print_val") as mock_print_val: apim._print_response_code(DummyResponse()) mock_print_val.assert_called_with("Response status", "302") + +# ------------------------------ +# HEADERS PROPERTY +# ------------------------------ + +def test_headers_property_allows_external_modification(): + apim = ApimRequests(default_url, default_key) + apim.headers["X-Test"] = "value" + assert apim.headers["X-Test"] == "value" + +def test_headers_property_is_dict_reference(): + apim = ApimRequests(default_url, default_key) + h = apim.headers + h["X-Ref"] = "ref" + assert apim.headers["X-Ref"] == "ref" \ No newline at end of file diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py index d4b9fce..6e0b26a 100644 --- a/tests/python/test_utils.py +++ b/tests/python/test_utils.py @@ -271,4 +271,13 @@ def test_validate_infrastructure_unsupported(): def test_validate_infrastructure_multiple_supported(): # Should return True if infra is in the supported list supported = [INFRASTRUCTURE.SIMPLE_APIM, INFRASTRUCTURE.APIM_ACA] - assert utils.validate_infrastructure(INFRASTRUCTURE.APIM_ACA, supported) is None \ No newline at end of file + assert utils.validate_infrastructure(INFRASTRUCTURE.APIM_ACA, supported) is None + +# ------------------------------ +# generate_signing_key +# ------------------------------ + +def test_generate_signing_key(): + s, b64 = utils.generate_signing_key() + assert isinstance(s, str) + assert isinstance(b64, str)