Skip to content

Commit 2b47bb2

Browse files
Fix product check
1 parent d62e62e commit 2b47bb2

File tree

7 files changed

+151
-14
lines changed

7 files changed

+151
-14
lines changed

samples/authX-pro/create.ipynb

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
"products: List[Product] = [\n",
6363
" Product(hr_product_name, 'Human Resources', \n",
6464
" 'Product for Human Resources APIs providing access to employee data, organizational structure, benefits information, and HR management services. Includes JWT-based authentication for HR members.', \n",
65-
" 'published', False, False, pol_hr_product)\n",
65+
" 'published', True, False, pol_hr_product)\n",
6666
"]\n",
6767
"\n",
6868
"# 6) Define the APIs and their operations and policies\n",
@@ -76,7 +76,7 @@
7676
"hr_employees_api_path = f'/{api_prefix}employees'\n",
7777
"hr_employees_get = GET_APIOperation('Gets the employees', pol_hr_get,)\n",
7878
"hr_employees_post = POST_APIOperation('Creates a new employee', pol_hr_post)\n",
79-
"hr_employees = API(f'{api_prefix}Employees', 'Employees Pro', hr_employees_api_path, 'This is a Human Resources API for employee information', \n",
79+
"hr_employees = API(f'{api_prefix}Employees', 'Employees Pro', hr_employees_api_path, 'This is a Human Resources API for employee information', pol_hr_all_operations_pro,\n",
8080
" operations = [hr_employees_get, hr_employees_post], tags = tags, productNames = [hr_product_name], subscriptionRequired = False)\n",
8181
"\n",
8282
"# Benefits (HR)\n",
@@ -123,6 +123,7 @@
123123
"if output.json_data:\n",
124124
" apim_name = output.get('apimServiceName', 'APIM Service Name')\n",
125125
" apim_gateway_url = output.get('apimResourceGatewayURL', 'APIM API Gateway URL')\n",
126+
" apim_products = output.getJson('productOutputs', 'Products')\n",
126127
"\n",
127128
"utils.print_ok('Deployment completed')"
128129
]
@@ -151,6 +152,9 @@
151152
"from users import UserHelper\n",
152153
"from authfactory import AuthFactory\n",
153154
"\n",
155+
"# Test the function\n",
156+
"hr_product_apim_subscription_key = apim_products[0]['subscriptionPrimaryKey']\n",
157+
"\n",
154158
"tests = ApimTesting(\"AuthX-Pro Sample Tests\", sample_folder, deployment)\n",
155159
"\n",
156160
"# Preflight: Check if the infrastructure architecture deployment uses Azure Front Door. If so, assume that APIM is not directly accessible and use the Front Door URL instead.\n",
@@ -162,7 +166,7 @@
162166
"print(f'\\nJWT token for HR Admin:\\n{encoded_jwt_token_hr_admin}') # this value is used to call the APIs via APIM\n",
163167
"\n",
164168
"# Set up an APIM requests object with the JWT token\n",
165-
"reqsApimAdmin = ApimRequests(endpoint_url)\n",
169+
"reqsApimAdmin = ApimRequests(endpoint_url, hr_product_apim_subscription_key)\n",
166170
"reqsApimAdmin.headers['Authorization'] = f'Bearer {encoded_jwt_token_hr_admin}'\n",
167171
"\n",
168172
"# Call APIM\n",
@@ -184,7 +188,7 @@
184188
"print(f'\\nJWT token for HR Associate:\\n{encoded_jwt_token_hr_associate}') # this value is used to call the APIs via APIM\n",
185189
"\n",
186190
"# Set up an APIM requests object with the JWT token\n",
187-
"reqsApimAssociate = ApimRequests(endpoint_url)\n",
191+
"reqsApimAssociate = ApimRequests(endpoint_url, hr_product_apim_subscription_key)\n",
188192
"reqsApimAssociate.headers['Authorization'] = f'Bearer {encoded_jwt_token_hr_associate}'\n",
189193
"\n",
190194
"# Call APIM\n",
@@ -200,6 +204,24 @@
200204
"output = reqsApimAssociate.singlePost(hr_benefits_api_path, msg = 'Calling POST Benefits API via API Management Gateway URL. Expect 403.')\n",
201205
"tests.verify(output, 'Access denied - no matching roles found')\n",
202206
"\n",
207+
"# 3) HR Administrator but no HR product subscription key (api-key)\n",
208+
"# Set up an APIM requests object with the JWT token\n",
209+
"reqsApimAdminNoHrProduct = ApimRequests(endpoint_url)\n",
210+
"reqsApimAdminNoHrProduct.headers['Authorization'] = f'Bearer {encoded_jwt_token_hr_admin}'\n",
211+
"\n",
212+
"# Call APIM\n",
213+
"output = reqsApimAdminNoHrProduct.singleGet(hr_employees_api_path, msg = 'Calling GET Employees API via API Management Gateway URL but with no HR product subscription key. Expect 403.')\n",
214+
"tests.verify(output, 'Access denied - no matching product found')\n",
215+
"\n",
216+
"# 4) HR Associate but no HR product subscription key (api-key)\n",
217+
"# Set up an APIM requests object with the JWT token\n",
218+
"reqsApimAssociateNoHrProduct = ApimRequests(endpoint_url)\n",
219+
"reqsApimAssociateNoHrProduct.headers['Authorization'] = f'Bearer {encoded_jwt_token_hr_associate}'\n",
220+
"\n",
221+
"# Call APIM\n",
222+
"output = reqsApimAssociateNoHrProduct.singleGet(hr_employees_api_path, msg = 'Calling GET Employees API via API Management Gateway URL but with no HR product subscription key. Expect 403.')\n",
223+
"tests.verify(output, 'Access denied - no matching product found')\n",
224+
"\n",
203225
"tests.print_summary()\n",
204226
"\n",
205227
"utils.print_ok('All done!')"

samples/authX-pro/hr_all_operations_pro.xml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44
<policies>
55
<inbound>
66
<base />
7-
<!-- TODO: Enable this with a proper subscription key tied to the product, then add tests. -->
87
<!-- The caller must be using an HR product to call this API.-->
9-
<!-- <set-variable name="products" value="hr" />
10-
<include-fragment fragment-id="Product-Match-Any" /> -->
8+
<set-variable name="products" value="hr" />
9+
<include-fragment fragment-id="Product-Match-Any" />
1110
</inbound>
1211
<backend>
1312
<base />

samples/authX-pro/main.bicep

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,18 @@ module apisModule '../../shared/bicep/modules/apim/v1/api.bicep' = [for api in a
105105
output apimServiceId string = apimService.id
106106
output apimServiceName string = apimService.name
107107
output apimResourceGatewayURL string = apimService.properties.gatewayUrl
108-
// [ADD RELEVANT OUTPUTS HERE]
108+
109+
// Product outputs
110+
output productOutputs array = [for i in range(0, length(products)): {
111+
productResourceId: productModule[i].outputs.productResourceId
112+
productName: productModule[i].outputs.productName
113+
productDisplayName: productModule[i].outputs.productDisplayName
114+
productState: productModule[i].outputs.productState
115+
subscriptionRequired: productModule[i].outputs.subscriptionRequired
116+
approvalRequired: productModule[i].outputs.approvalRequired
117+
policyResourceId: productModule[i].outputs.policyResourceId
118+
hasPolicyAttached: productModule[i].outputs.hasPolicyAttached
119+
subscriptionResourceId: productModule[i].outputs.subscriptionResourceId
120+
subscriptionPrimaryKey: productModule[i].outputs.subscriptionPrimaryKey
121+
subscriptionSecondaryKey: productModule[i].outputs.subscriptionSecondaryKey
122+
}]

shared/apim-policies/fragments/pf-product-match-any.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
<!-- Check if NONE of the allowed products match the context product name -->
1010
<when condition="@{
1111
var allowedProducts = context.Variables.GetValueOrDefault<string>("products", "").ToString().Split(',');
12-
var contextProduct = context.Product != null ? context.Product.Name.ToLower().Trim() : string.Empty;
12+
var contextProduct = context.Product?.Id ?? string.Empty;
1313
1414
// Check if NO allowed product matches the product names in the context
15-
return !allowedProducts.Any(product => contextProduct.Contains(product.Trim().ToLower()));
15+
return !allowedProducts.Any(product => contextProduct.Contains(product));
1616
}">
1717
<return-response>
1818
<set-status code="403" reason="Forbidden" />

shared/bicep/modules/apim/v1/product.bicep

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ resource productPolicy 'Microsoft.ApiManagement/service/products/policies@2024-0
7979
}
8080
}
8181

82+
// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/subscriptions
83+
resource productSubscription 'Microsoft.ApiManagement/service/subscriptions@2024-05-01' = if (subscriptionRequired) {
84+
name: 'subscription-${productName}'
85+
parent: apimService
86+
properties: {
87+
allowTracing: true
88+
displayName: 'Subscription for ${productDisplayName}'
89+
scope: '/products/${productName}'
90+
state: 'active'
91+
}
92+
dependsOn: [
93+
product
94+
]
95+
}
96+
8297

8398
// ------------------------------
8499
// OUTPUTS
@@ -107,3 +122,12 @@ output policyResourceId string = !empty(policyXml) ? productPolicy.id : ''
107122

108123
@description('Whether a policy is attached to this product.')
109124
output hasPolicyAttached bool = !empty(policyXml)
125+
126+
@description('The resource ID of the product subscription, if created.')
127+
output subscriptionResourceId string = subscriptionRequired ? productSubscription.id : ''
128+
129+
@description('The primary key of the product subscription, if created.')
130+
output subscriptionPrimaryKey string = subscriptionRequired ? listSecrets('${apimService.id}/subscriptions/subscription-${productName}', '2024-05-01').primaryKey : ''
131+
132+
@description('The secondary key of the product subscription, if created.')
133+
output subscriptionSecondaryKey string = subscriptionRequired ? listSecrets('${apimService.id}/subscriptions/subscription-${productName}', '2024-05-01').secondaryKey : ''

shared/python/apimtypes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _get_project_root() -> Path:
4141
REQUEST_HEADERS_XML_POLICY_PATH = str(_SHARED_XML_POLICY_BASE_PATH / 'request-headers.xml')
4242
BACKEND_XML_POLICY_PATH = str(_SHARED_XML_POLICY_BASE_PATH / 'backend.xml')
4343

44-
SUBSCRIPTION_KEY_PARAMETER_NAME = 'api_key'
44+
SUBSCRIPTION_KEY_PARAMETER_NAME = 'api-key'
4545
SLEEP_TIME_BETWEEN_REQUESTS_MS = 50
4646

4747

shared/python/utils.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Module providing utility functions.
33
"""
44

5+
import ast
56
import datetime
67
import json
78
import os
@@ -152,6 +153,74 @@ def get(self, key: str, label: str = '', secure: bool = False) -> str | None:
152153

153154
return None
154155

156+
def getJson(self, key: str, label: str = '', secure: bool = False) -> any:
157+
"""
158+
Retrieve a deployment output property by key and return it as a JSON object.
159+
This method is independent from get() and retrieves the raw deployment output value.
160+
161+
Args:
162+
key (str): The output key to retrieve.
163+
label (str, optional): Optional label for logging.
164+
secure (bool, optional): If True, masks the value in logs.
165+
166+
Returns:
167+
any: The value as a JSON object (dict, list, etc.), or the original value if not JSON, or None if not found.
168+
"""
169+
170+
try:
171+
if not isinstance(self.json_data, dict):
172+
raise KeyError("json_data is not a dict")
173+
174+
if 'properties' in self.json_data:
175+
properties = self.json_data.get('properties')
176+
if not isinstance(properties, dict):
177+
raise KeyError("'properties' is not a dict in deployment result")
178+
179+
outputs = properties.get('outputs')
180+
if not isinstance(outputs, dict):
181+
raise KeyError("'outputs' is missing or not a dict in deployment result")
182+
183+
output_entry = outputs.get(key)
184+
if not isinstance(output_entry, dict) or 'value' not in output_entry:
185+
raise KeyError(f"Output key '{key}' not found in deployment outputs")
186+
187+
deployment_output = output_entry['value']
188+
elif key in self.json_data:
189+
deployment_output = self.json_data[key]['value']
190+
191+
if label:
192+
if secure and isinstance(deployment_output, str) and len(deployment_output) >= 4:
193+
print_val(label, f"****{deployment_output[-4:]}")
194+
else:
195+
print_val(label, deployment_output)
196+
197+
# If the result is a string, try to parse it as JSON
198+
if isinstance(deployment_output, str):
199+
# First try JSON parsing (handles double quotes)
200+
try:
201+
return json.loads(deployment_output)
202+
except json.JSONDecodeError:
203+
pass
204+
205+
# If JSON fails, try Python literal evaluation (handles single quotes)
206+
try:
207+
return ast.literal_eval(deployment_output)
208+
except (ValueError, SyntaxError) as e:
209+
print_error(e)
210+
pass
211+
212+
# Return the original result if it's not a string or can't be parsed
213+
return deployment_output
214+
215+
except Exception as e:
216+
error = f"Failed to retrieve output property: '{key}'\nError: {e}"
217+
print_error(error)
218+
219+
if label:
220+
raise Exception(error)
221+
222+
return None
223+
155224

156225
class NotebookHelper:
157226
def __init__(self, sample_folder: str, rg_name: str, rg_location: str, deployment: INFRASTRUCTURE, supported_infrastructures = list[INFRASTRUCTURE], use_jwt: bool = False):
@@ -221,7 +290,6 @@ def deploy_bicep(self, bicep_parameters: dict) -> Output:
221290

222291

223292

224-
225293
# ------------------------------
226294
# PRIVATE METHODS
227295
# ------------------------------
@@ -836,11 +904,21 @@ def is_string_json(text: str) -> bool:
836904
if not isinstance(text, (str, bytes, bytearray)):
837905
return False
838906

907+
# First try JSON parsing (handles double quotes)
839908
try:
840909
json.loads(text)
841910
return True
842-
except (ValueError, TypeError):
843-
return False
911+
except:
912+
pass
913+
914+
# If JSON fails, try Python literal evaluation (handles single quotes)
915+
try:
916+
ast.literal_eval(text)
917+
return True
918+
except (ValueError, SyntaxError):
919+
pass
920+
921+
return False
844922

845923
def get_account_info() -> Tuple[str, str, str]:
846924
"""

0 commit comments

Comments
 (0)