raindancers-cloudfront 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bicep/azure-functions/custom-claims-provider/function_app.py +164 -0
- package/lib/bicep/azure-functions/custom-claims-provider/host.json +10 -0
- package/lib/bicep/azure-functions/custom-claims-provider/requirements.txt +8 -0
- package/lib/bicep/deploy/lambda/Dockerfile +10 -0
- package/lib/bicep/deploy/lambda/index.py +341 -0
- package/lib/bicep/patterns/scripts/LOCALDEBUGREADME.md +86 -0
- package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh +122 -0
- package/lib/bicep/patterns/scripts/wire-custom-claims-extension.sh.bak +118 -0
- package/lib/cloudfront/cloudfront-functions/auth-check.js +220 -0
- package/lib/cloudfront/cloudfront-functions/modules/auth-check.js +263 -0
- package/lib/cloudfront/cloudfront-functions/modules/cognito-auth-check.js +223 -0
- package/lib/cloudfront/cloudfront-functions/modules/shared-utils.js +47 -0
- package/lib/cloudfront/cloudfront-functions/modules/tls-check.js +39 -0
- package/lib/cloudfront/cloudfront-functions/modules/url-rewrite.js +6 -0
- package/lib/cloudfront/cloudfront-functions/role-enforcer.js +93 -0
- package/lib/cloudfront/cloudfront-functions/tls-enforcer.js +55 -0
- package/lib/cloudfront/cloudfront-functions/userinfo-endpoint.js +208 -0
- package/lib/cloudfront/lambda/certificate/index.py +219 -0
- package/lib/cloudfront/lambda/cognito-auth/oauth-callback.py +215 -0
- package/lib/cloudfront/lambda/cognito-auth/requirements.txt +3 -0
- package/lib/cloudfront/lambda/edge-auth/config.py +6 -0
- package/lib/cloudfront/lambda/edge-auth/config_generated.py +12 -0
- package/lib/cloudfront/lambda/edge-auth/oauth-callback.py +538 -0
- package/lib/cloudfront/lambda/edge-auth/requirements.txt +3 -0
- package/lib/cloudfront/lambda/hmacSecret/index.py +129 -0
- package/lib/cloudfront/lambda/hmacSecret/requirements.txt +1 -0
- package/lib/cloudfront/lambda/jwt-decoder/index.py +88 -0
- package/lib/cloudfront/lambda/pre-token/index.py +11 -0
- package/lib/cloudfront/lambda/rotateSecret/index.py +86 -0
- package/lib/cloudfront/lambda/session-revocation/index.py +80 -0
- package/lib/cloudfront/lambda/ssm-writer/index.py +44 -0
- package/lib/cloudfront/lambda/stream-processor/index.py +53 -0
- package/lib/cloudfront/lambda/webacl/index.py +356 -0
- package/lib/cloudfront/logging/README.md +144 -0
- package/lib/cloudfront/patterns/SPLIT_STACK_USAGE.md +138 -0
- package/package.json +1 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import azure.functions as func
|
|
2
|
+
import logging
|
|
3
|
+
import json
|
|
4
|
+
import asyncio
|
|
5
|
+
from azure.identity import DefaultAzureCredential
|
|
6
|
+
from msgraph import GraphServiceClient
|
|
7
|
+
|
|
8
|
+
app = func.FunctionApp()
|
|
9
|
+
|
|
10
|
+
@app.function_name(name="CustomClaimsProvider")
|
|
11
|
+
@app.route(route="CustomClaimsProvider", auth_level=func.AuthLevel.ANONYMOUS)
|
|
12
|
+
async def token_issuance_start(req: func.HttpRequest) -> func.HttpResponse:
|
|
13
|
+
"""
|
|
14
|
+
Azure Function for Custom Claims Provider - Token Issuance Start Event
|
|
15
|
+
|
|
16
|
+
Receives token issuance event from Azure AD, queries user's app role assignments,
|
|
17
|
+
and returns separate AWS session tag claims for each role.
|
|
18
|
+
"""
|
|
19
|
+
logging.info('Token issuance start event received')
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
# Parse request body from Azure AD
|
|
23
|
+
req_body = req.get_json()
|
|
24
|
+
|
|
25
|
+
# Extract user ID and application service principal ID from authentication context
|
|
26
|
+
user_id = req_body['data']['authenticationContext']['user']['id']
|
|
27
|
+
app_service_principal_id = req_body['data']['authenticationContext']['clientServicePrincipal']['id']
|
|
28
|
+
|
|
29
|
+
logging.info(f'Processing token issuance for user: {user_id}, app: {app_service_principal_id}')
|
|
30
|
+
|
|
31
|
+
# Get user's app role assignments from Microsoft Graph API
|
|
32
|
+
roles = await get_user_roles(user_id, app_service_principal_id)
|
|
33
|
+
|
|
34
|
+
claims = {}
|
|
35
|
+
if roles:
|
|
36
|
+
claims['Roles'] = ':' + ':'.join(roles) + ':'
|
|
37
|
+
logging.info(f'Emitting Roles claim: {claims.get("Roles", "(empty)")}')
|
|
38
|
+
|
|
39
|
+
# Return response in format Azure AD expects
|
|
40
|
+
response = {
|
|
41
|
+
"data": {
|
|
42
|
+
"@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
|
|
43
|
+
"actions": [
|
|
44
|
+
{
|
|
45
|
+
"@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
|
|
46
|
+
"claims": claims
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return func.HttpResponse(
|
|
53
|
+
body=json.dumps(response),
|
|
54
|
+
mimetype="application/json",
|
|
55
|
+
status_code=200
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logging.error(f'Error processing token issuance event: {str(e)}')
|
|
60
|
+
# Return empty claims on error to allow authentication to proceed
|
|
61
|
+
return create_empty_response()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def get_user_roles(user_id: str, app_service_principal_id: str) -> list:
|
|
65
|
+
"""
|
|
66
|
+
Query Microsoft Graph API to get user's app role assignments for a specific application.
|
|
67
|
+
|
|
68
|
+
Uses Managed Identity (DefaultAzureCredential) for authentication.
|
|
69
|
+
Returns list of role display names (e.g., ["Animals", "PowerUsers"]).
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
user_id: User's object ID
|
|
73
|
+
app_service_principal_id: Service principal ID of the application requesting the token
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
# Authenticate using Managed Identity
|
|
77
|
+
credential = DefaultAzureCredential()
|
|
78
|
+
scopes = ['https://graph.microsoft.com/.default']
|
|
79
|
+
|
|
80
|
+
# Create Graph API client
|
|
81
|
+
client = GraphServiceClient(credentials=credential, scopes=scopes)
|
|
82
|
+
|
|
83
|
+
# Query user's app role assignments
|
|
84
|
+
# GET /users/{user-id}/appRoleAssignments
|
|
85
|
+
result = await client.users.by_user_id(user_id).app_role_assignments.get()
|
|
86
|
+
|
|
87
|
+
if not result or not result.value:
|
|
88
|
+
logging.info(f'No app role assignments found for user {user_id}')
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
# Extract role display names from assignments for THIS application only
|
|
92
|
+
roles = []
|
|
93
|
+
for assignment in result.value:
|
|
94
|
+
# Only process assignments for the application requesting the token
|
|
95
|
+
if str(assignment.resource_id) == app_service_principal_id:
|
|
96
|
+
app_role_id = assignment.app_role_id
|
|
97
|
+
|
|
98
|
+
if app_role_id:
|
|
99
|
+
role_name = await get_role_name(client, app_service_principal_id, app_role_id)
|
|
100
|
+
if role_name:
|
|
101
|
+
roles.append(role_name)
|
|
102
|
+
|
|
103
|
+
logging.info(f'Found {len(roles)} roles for user {user_id}: {roles}')
|
|
104
|
+
return roles
|
|
105
|
+
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logging.error(f'Error querying user roles from Graph API: {str(e)}')
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def get_role_name(client: GraphServiceClient, resource_id: str, app_role_id: str) -> str:
|
|
112
|
+
"""
|
|
113
|
+
Get role display name by querying the service principal's app role definitions.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
client: Graph API client
|
|
117
|
+
resource_id: Service principal object ID
|
|
118
|
+
app_role_id: App role ID (GUID)
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Role display name (e.g., "Animals") or None if not found
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
# GET /servicePrincipals/{resource-id}
|
|
125
|
+
service_principal = await client.service_principals.by_service_principal_id(resource_id).get()
|
|
126
|
+
|
|
127
|
+
if not service_principal or not service_principal.app_roles:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# Find the app role by ID
|
|
131
|
+
for app_role in service_principal.app_roles:
|
|
132
|
+
if str(app_role.id) == str(app_role_id):
|
|
133
|
+
return app_role.display_name
|
|
134
|
+
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logging.error(f'Error getting role name: {str(e)}')
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_empty_response() -> func.HttpResponse:
|
|
143
|
+
"""
|
|
144
|
+
Create response with empty claims object.
|
|
145
|
+
Used when no roles found or error occurs.
|
|
146
|
+
"""
|
|
147
|
+
response = {
|
|
148
|
+
"data": {
|
|
149
|
+
"@odata.type": "microsoft.graph.onTokenIssuanceStartResponseData",
|
|
150
|
+
"actions": [
|
|
151
|
+
{
|
|
152
|
+
"@odata.type": "microsoft.graph.tokenIssuanceStart.provideClaimsForToken",
|
|
153
|
+
"claims": {}
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return func.HttpResponse(
|
|
160
|
+
body=json.dumps(response),
|
|
161
|
+
mimetype="application/json",
|
|
162
|
+
status_code=200
|
|
163
|
+
)
|
|
164
|
+
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
import boto3
|
|
6
|
+
import urllib.request
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import traceback
|
|
9
|
+
|
|
10
|
+
sts = boto3.client('sts')
|
|
11
|
+
s3 = boto3.client('s3')
|
|
12
|
+
|
|
13
|
+
def handler(event, context):
|
|
14
|
+
"""Main handler with CloudFormation response handling"""
|
|
15
|
+
response_url = event.get('ResponseURL')
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
request_type = event['RequestType']
|
|
19
|
+
|
|
20
|
+
if request_type == 'Create':
|
|
21
|
+
result = on_create(event)
|
|
22
|
+
elif request_type == 'Update':
|
|
23
|
+
result = on_update(event)
|
|
24
|
+
elif request_type == 'Delete':
|
|
25
|
+
result = on_delete(event)
|
|
26
|
+
else:
|
|
27
|
+
raise Exception(f'Unknown request type: {request_type}')
|
|
28
|
+
|
|
29
|
+
# Send success response to CloudFormation
|
|
30
|
+
if response_url:
|
|
31
|
+
send_response(event, context, 'SUCCESS', result)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f'Error: {str(e)}')
|
|
36
|
+
print(traceback.format_exc())
|
|
37
|
+
|
|
38
|
+
# Send failure response to CloudFormation
|
|
39
|
+
if response_url:
|
|
40
|
+
send_response(event, context, 'FAILED', {
|
|
41
|
+
'PhysicalResourceId': event.get('PhysicalResourceId', 'NONE'),
|
|
42
|
+
}, reason=str(e)[:3000])
|
|
43
|
+
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
def send_response(event, context, status, data, reason=None):
|
|
47
|
+
"""Send response to CloudFormation"""
|
|
48
|
+
response_body = {
|
|
49
|
+
'Status': status,
|
|
50
|
+
'Reason': reason or f'See CloudWatch Log Stream: {context.log_stream_name}',
|
|
51
|
+
'PhysicalResourceId': data.get('PhysicalResourceId', context.log_stream_name),
|
|
52
|
+
'StackId': event['StackId'],
|
|
53
|
+
'RequestId': event['RequestId'],
|
|
54
|
+
'LogicalResourceId': event['LogicalResourceId'],
|
|
55
|
+
'Data': data.get('Data', {})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
json_response = json.dumps(response_body)
|
|
59
|
+
print(f'DEBUG: Response body size: {len(json_response)} bytes')
|
|
60
|
+
print(f'DEBUG: Response body: {json_response[:500]}...') # Log first 500 chars
|
|
61
|
+
|
|
62
|
+
headers = {
|
|
63
|
+
'Content-Type': '',
|
|
64
|
+
'Content-Length': str(len(json_response))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
req = urllib.request.Request(
|
|
68
|
+
event['ResponseURL'],
|
|
69
|
+
data=json_response.encode('utf-8'),
|
|
70
|
+
headers=headers,
|
|
71
|
+
method='PUT'
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with urllib.request.urlopen(req) as response:
|
|
76
|
+
print(f'CloudFormation response status: {response.status}')
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print(f'Failed to send response to CloudFormation: {str(e)}')
|
|
79
|
+
|
|
80
|
+
def on_create(event):
|
|
81
|
+
props = event['ResourceProperties']
|
|
82
|
+
|
|
83
|
+
login_azure_federated(
|
|
84
|
+
props['AzureClientId'],
|
|
85
|
+
props['AzureTenantId'],
|
|
86
|
+
props['AzureSubscriptionId']
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
deployment_name = props['DeploymentName']
|
|
90
|
+
resource_group = props['ResourceGroupName']
|
|
91
|
+
template_file = props['TemplateFile']
|
|
92
|
+
parameters = json.loads(props['Parameters'])
|
|
93
|
+
|
|
94
|
+
result = deploy_bicep(deployment_name, resource_group, template_file, parameters)
|
|
95
|
+
|
|
96
|
+
if props.get('FunctionCodeS3Bucket'):
|
|
97
|
+
deploy_function_code(
|
|
98
|
+
s3_bucket=props['FunctionCodeS3Bucket'],
|
|
99
|
+
s3_key=props['FunctionCodeS3Key'],
|
|
100
|
+
function_app_name=result['outputs']['functionAppName'],
|
|
101
|
+
resource_group=resource_group,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Only return essential outputs to stay under CloudFormation 4KB response limit
|
|
105
|
+
essential_outputs = {k: v for k, v in result['outputs'].items() if k in ['appId', 'tenantId']}
|
|
106
|
+
|
|
107
|
+
response_data = {
|
|
108
|
+
'PhysicalResourceId': deployment_name,
|
|
109
|
+
'Data': essential_outputs
|
|
110
|
+
}
|
|
111
|
+
print(f'DEBUG: Returning response data: {json.dumps(response_data, indent=2)}')
|
|
112
|
+
return response_data
|
|
113
|
+
|
|
114
|
+
def deploy_function_code(s3_bucket, s3_key, function_app_name, resource_group):
|
|
115
|
+
"""Upload zip to Azure blob storage, set WEBSITE_RUN_FROM_PACKAGE to SAS URL"""
|
|
116
|
+
zip_path = '/tmp/function-code.zip'
|
|
117
|
+
|
|
118
|
+
print(f'Downloading function code from s3://{s3_bucket}/{s3_key}')
|
|
119
|
+
s3.download_file(s3_bucket, s3_key, zip_path)
|
|
120
|
+
|
|
121
|
+
# Get storage account name from function app
|
|
122
|
+
result = subprocess.run([
|
|
123
|
+
'az', 'functionapp', 'show',
|
|
124
|
+
'--name', function_app_name,
|
|
125
|
+
'--resource-group', resource_group,
|
|
126
|
+
'--query', 'storageAccountRequired',
|
|
127
|
+
'--output', 'tsv',
|
|
128
|
+
], capture_output=True, text=True, check=True)
|
|
129
|
+
|
|
130
|
+
# Get storage account linked to the function app
|
|
131
|
+
result = subprocess.run([
|
|
132
|
+
'az', 'functionapp', 'config', 'appsettings', 'list',
|
|
133
|
+
'--name', function_app_name,
|
|
134
|
+
'--resource-group', resource_group,
|
|
135
|
+
'--query', "[?name=='AzureWebJobsStorage'].value",
|
|
136
|
+
'--output', 'tsv',
|
|
137
|
+
], capture_output=True, text=True, check=True)
|
|
138
|
+
conn_str = result.stdout.strip()
|
|
139
|
+
|
|
140
|
+
# Extract account name from connection string
|
|
141
|
+
storage_account_name = next(
|
|
142
|
+
part.split('=', 1)[1] for part in conn_str.split(';') if part.startswith('AccountName=')
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
container = 'function-releases'
|
|
146
|
+
blob_name = f'{function_app_name}.zip'
|
|
147
|
+
|
|
148
|
+
# Create container if it doesn't exist
|
|
149
|
+
subprocess.run([
|
|
150
|
+
'az', 'storage', 'container', 'create',
|
|
151
|
+
'--name', container,
|
|
152
|
+
'--account-name', storage_account_name,
|
|
153
|
+
], check=True)
|
|
154
|
+
|
|
155
|
+
# Upload zip
|
|
156
|
+
subprocess.run([
|
|
157
|
+
'az', 'storage', 'blob', 'upload',
|
|
158
|
+
'--account-name', storage_account_name,
|
|
159
|
+
'--container-name', container,
|
|
160
|
+
'--name', blob_name,
|
|
161
|
+
'--file', zip_path,
|
|
162
|
+
'--overwrite',
|
|
163
|
+
], check=True)
|
|
164
|
+
|
|
165
|
+
# Generate SAS URL with account key (long expiry)
|
|
166
|
+
result = subprocess.run([
|
|
167
|
+
'az', 'storage', 'blob', 'generate-sas',
|
|
168
|
+
'--account-name', storage_account_name,
|
|
169
|
+
'--container-name', container,
|
|
170
|
+
'--name', blob_name,
|
|
171
|
+
'--permissions', 'r',
|
|
172
|
+
'--expiry', '2036-01-01T00:00:00Z',
|
|
173
|
+
'--output', 'tsv',
|
|
174
|
+
], capture_output=True, text=True, check=True)
|
|
175
|
+
sas_token = result.stdout.strip()
|
|
176
|
+
|
|
177
|
+
blob_url = f'https://{storage_account_name}.blob.core.windows.net/{container}/{blob_name}?{sas_token}'
|
|
178
|
+
|
|
179
|
+
# Set WEBSITE_RUN_FROM_PACKAGE
|
|
180
|
+
subprocess.run([
|
|
181
|
+
'az', 'functionapp', 'config', 'appsettings', 'set',
|
|
182
|
+
'--name', function_app_name,
|
|
183
|
+
'--resource-group', resource_group,
|
|
184
|
+
'--settings', f'WEBSITE_RUN_FROM_PACKAGE={blob_url}',
|
|
185
|
+
], check=True)
|
|
186
|
+
|
|
187
|
+
print(f'Deployed function code to {function_app_name} via WEBSITE_RUN_FROM_PACKAGE')
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def on_update(event):
|
|
191
|
+
return on_create(event)
|
|
192
|
+
|
|
193
|
+
def on_delete(event):
|
|
194
|
+
"""Handle delete - if resource never created successfully, just return success"""
|
|
195
|
+
physical_resource_id = event.get('PhysicalResourceId', 'NONE')
|
|
196
|
+
|
|
197
|
+
# If the resource was never created (PhysicalResourceId is NONE or missing),
|
|
198
|
+
# just return success without trying to delete anything
|
|
199
|
+
if not physical_resource_id or physical_resource_id == 'NONE':
|
|
200
|
+
return {'PhysicalResourceId': physical_resource_id}
|
|
201
|
+
|
|
202
|
+
props = event['ResourceProperties']
|
|
203
|
+
deployment_name = physical_resource_id
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
login_azure_federated(
|
|
207
|
+
props['AzureClientId'],
|
|
208
|
+
props['AzureTenantId'],
|
|
209
|
+
props['AzureSubscriptionId']
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
subprocess.run([
|
|
213
|
+
'az', 'deployment', 'group', 'delete',
|
|
214
|
+
'--name', deployment_name,
|
|
215
|
+
'--resource-group', props['ResourceGroupName'],
|
|
216
|
+
'--yes'
|
|
217
|
+
], check=True)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
# Log the error but don't fail the delete
|
|
220
|
+
# CloudFormation should be able to clean up even if Azure delete fails
|
|
221
|
+
print(f'Warning: Failed to delete Azure deployment: {str(e)}')
|
|
222
|
+
|
|
223
|
+
return {'PhysicalResourceId': deployment_name}
|
|
224
|
+
|
|
225
|
+
def login_azure_federated(client_id, tenant_id, subscription_id):
|
|
226
|
+
"""Authenticate to Azure using AWS IAM Outbound Identity Federation"""
|
|
227
|
+
|
|
228
|
+
# Set HOME to /tmp so all tools use writable directory
|
|
229
|
+
os.environ['HOME'] = '/tmp'
|
|
230
|
+
# Set Azure CLI config directory to /tmp (Lambda's writable directory)
|
|
231
|
+
os.environ['AZURE_CONFIG_DIR'] = '/tmp/.azure'
|
|
232
|
+
# Set .NET bundle extract directory to /tmp (required for Bicep)
|
|
233
|
+
os.environ['DOTNET_BUNDLE_EXTRACT_BASE_DIR'] = '/tmp'
|
|
234
|
+
# Run Bicep without globalization support (sufficient for IaC)
|
|
235
|
+
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1'
|
|
236
|
+
|
|
237
|
+
# Use AWS IAM Outbound Identity Federation to get a JWT token
|
|
238
|
+
# This requires the feature to be enabled in the AWS account
|
|
239
|
+
# https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound.html
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
# GetWebIdentityToken requires specific parameters
|
|
243
|
+
response = sts._make_api_call(
|
|
244
|
+
'GetWebIdentityToken',
|
|
245
|
+
{
|
|
246
|
+
'Audience': ['api://AzureADTokenExchange'], # Must be a list
|
|
247
|
+
'DurationSeconds': 3600,
|
|
248
|
+
'SigningAlgorithm': 'RS256' # Required parameter
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
aws_jwt_token = response['WebIdentityToken']
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
error_msg = str(e)
|
|
256
|
+
if 'Unknown operation' in error_msg or 'InvalidAction' in error_msg:
|
|
257
|
+
raise Exception(
|
|
258
|
+
'GetWebIdentityToken API not available. '
|
|
259
|
+
'This requires: 1) boto3 version 1.35.36 or later, '
|
|
260
|
+
'2) Outbound identity federation enabled in your AWS account. '
|
|
261
|
+
'See: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_outbound_getting_started.html'
|
|
262
|
+
)
|
|
263
|
+
raise Exception(f'Failed to get AWS OIDC token: {error_msg}')
|
|
264
|
+
|
|
265
|
+
# Login to Azure CLI with the AWS JWT token (federated token)
|
|
266
|
+
# The Azure CLI will exchange this token internally
|
|
267
|
+
subprocess.run([
|
|
268
|
+
'az', 'login',
|
|
269
|
+
'--service-principal',
|
|
270
|
+
'--username', client_id,
|
|
271
|
+
'--tenant', tenant_id,
|
|
272
|
+
'--federated-token', aws_jwt_token
|
|
273
|
+
], check=True)
|
|
274
|
+
|
|
275
|
+
# Set subscription
|
|
276
|
+
subprocess.run([
|
|
277
|
+
'az', 'account', 'set',
|
|
278
|
+
'--subscription', subscription_id
|
|
279
|
+
], check=True)
|
|
280
|
+
|
|
281
|
+
def wait_for_deployments(resource_group, poll_interval=15, timeout=600):
|
|
282
|
+
"""Wait until no deployments in the resource group are in Running state"""
|
|
283
|
+
deadline = time.time() + timeout
|
|
284
|
+
while time.time() < deadline:
|
|
285
|
+
result = subprocess.run([
|
|
286
|
+
'az', 'deployment', 'group', 'list',
|
|
287
|
+
'--resource-group', resource_group,
|
|
288
|
+
'--query', "[?properties.provisioningState=='Running'].name",
|
|
289
|
+
'--output', 'json'
|
|
290
|
+
], capture_output=True, text=True, check=True)
|
|
291
|
+
running = json.loads(result.stdout)
|
|
292
|
+
if not running:
|
|
293
|
+
return
|
|
294
|
+
print(f'Waiting for running deployments: {running}')
|
|
295
|
+
time.sleep(poll_interval)
|
|
296
|
+
raise Exception(f'Timed out waiting for running deployments in {resource_group}')
|
|
297
|
+
|
|
298
|
+
def deploy_bicep(name, resource_group, template_file, parameters):
|
|
299
|
+
# Write template content to a file in /tmp
|
|
300
|
+
template_path = f'/tmp/{name}.bicep'
|
|
301
|
+
with open(template_path, 'w') as f:
|
|
302
|
+
f.write(template_file)
|
|
303
|
+
|
|
304
|
+
param_args = []
|
|
305
|
+
for key, value in parameters.items():
|
|
306
|
+
param_args.extend(['--parameters', f'{key}={value}'])
|
|
307
|
+
|
|
308
|
+
cmd = [
|
|
309
|
+
'az', 'deployment', 'group', 'create',
|
|
310
|
+
'--name', name,
|
|
311
|
+
'--resource-group', resource_group,
|
|
312
|
+
'--template-file', template_path,
|
|
313
|
+
*param_args,
|
|
314
|
+
'--query', '{outputs:properties.outputs}',
|
|
315
|
+
'--output', 'json'
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
319
|
+
|
|
320
|
+
if result.returncode != 0 and 'DeploymentActive' in result.stderr:
|
|
321
|
+
print('DeploymentActive detected — waiting for running deployments to finish')
|
|
322
|
+
wait_for_deployments(resource_group)
|
|
323
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
324
|
+
|
|
325
|
+
if result.returncode != 0:
|
|
326
|
+
error_msg = f'Bicep deployment failed.\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}'
|
|
327
|
+
print(error_msg)
|
|
328
|
+
raise Exception(error_msg)
|
|
329
|
+
|
|
330
|
+
deployment = json.loads(result.stdout)
|
|
331
|
+
print(f'DEBUG: Deployment response: {json.dumps(deployment, indent=2)}')
|
|
332
|
+
|
|
333
|
+
outputs = {}
|
|
334
|
+
deployment_outputs = deployment.get('outputs', {})
|
|
335
|
+
print(f'DEBUG: Deployment outputs: {json.dumps(deployment_outputs, indent=2)}')
|
|
336
|
+
|
|
337
|
+
for key, value in deployment_outputs.items():
|
|
338
|
+
outputs[key] = value['value']
|
|
339
|
+
|
|
340
|
+
print(f'DEBUG: Extracted outputs: {json.dumps(outputs, indent=2)}')
|
|
341
|
+
return {'outputs': outputs}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Local Debugging: wire-custom-claims-extension.sh
|
|
2
|
+
|
|
3
|
+
## How the script runs in deployment vs locally
|
|
4
|
+
|
|
5
|
+
### Deployment mode (default)
|
|
6
|
+
In deployment, this script runs inside an Azure `DeploymentScript` resource, executed by a managed identity that has been granted the required Graph application permissions. The managed identity authenticates via the Azure CLI (`az account get-access-token`), which works automatically in that container environment.
|
|
7
|
+
|
|
8
|
+
### Local mode
|
|
9
|
+
Locally, the `az` CLI uses your personal delegated token, which does **not** have the Graph application permissions (`CustomAuthenticationExtension.ReadWrite.All`, `EventListener.ReadWrite.All`, `Policy.ReadWrite.ApplicationConfiguration`, `Policy.Read.All`, `Application.ReadWrite.All`) needed by this script. You must use a service principal with those permissions granted via `client_credentials` instead.
|
|
10
|
+
|
|
11
|
+
## Running locally
|
|
12
|
+
|
|
13
|
+
### Step 1: Create a test service principal
|
|
14
|
+
|
|
15
|
+
Run the setup script once to create an app registration with the required Graph application permissions:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bash scripts/setup-test-sp.sh
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This outputs `TEST_TENANT_ID`, `TEST_CLIENT_ID`, and `TEST_CLIENT_SECRET`. Export them:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
export TEST_TENANT_ID=<value>
|
|
25
|
+
export TEST_CLIENT_ID=<value>
|
|
26
|
+
export TEST_CLIENT_SECRET=<value>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Step 2: Set the script's required environment variables
|
|
30
|
+
|
|
31
|
+
These are the same env vars the `DeploymentScript` injects at deployment time. Get the values from the deployed Azure resources:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export FUNCTION_HOSTNAME="<function-app-hostname>" # e.g. clf-claims-func-6qx5xi3p.azurewebsites.net
|
|
35
|
+
export FUNCTION_APP_ID="<function-app-registration-appId>"
|
|
36
|
+
export CLF_APP_ID="<clf-app-registration-appId>"
|
|
37
|
+
export EXT_DISPLAY_NAME="<namePrefix>-claims-provider" # e.g. clf-claims-provider
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Step 3: Run the script
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bash src/constructsForPackaging/cdk-bicep/src/patterns/scripts/wire-custom-claims-extension.sh
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
When `TEST_CLIENT_ID` is set, the script automatically uses `client_credentials` to obtain a token. When it is not set (deployment), it uses `az account get-access-token`.
|
|
47
|
+
|
|
48
|
+
### Step 4: Clean up the test service principal
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
az ad app delete --id $TEST_CLIENT_ID
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Why we use curl instead of az rest
|
|
55
|
+
|
|
56
|
+
Microsoft's recommended pattern for calling Graph API from Azure CLI DeploymentScripts is `az rest`, which handles authentication automatically using the managed identity context. However, this script uses raw `curl` with manual token management for a specific reason: the permission propagation retry loop.
|
|
57
|
+
|
|
58
|
+
The retry loop acquires a token, decodes the JWT payload, and inspects the `roles` claim to verify all required Graph permissions have propagated before proceeding. `az rest` acquires its own token internally and doesn't expose it, making JWT inspection impossible. Manual token management via `az account get-access-token` is therefore required to support this retry logic.
|
|
59
|
+
|
|
60
|
+
If the propagation retry is ever removed, the script should be refactored to use `az rest`.
|
|
61
|
+
|
|
62
|
+
## Why delegated az CLI tokens don't work
|
|
63
|
+
|
|
64
|
+
The `az` CLI authenticates as the Microsoft Azure CLI app registration, which has not been granted the required Graph **application** permissions in your tenant. You will see errors like `"The application does not have any of the required delegated permissions"`. The test SP uses `client_credentials` which grants application permissions — exactly replicating what the managed identity does in deployment.
|
|
65
|
+
|
|
66
|
+
## Known issue: `set -x` causes deployment failures
|
|
67
|
+
|
|
68
|
+
Do **not** add `set -x` to this script for deployment debugging. The script acquires a Graph JWT (~2KB) and `set -x` prints every command with its full arguments, including `TOKEN=<full JWT>` and `Authorization: Bearer <full JWT>` on every curl call. With 6 retry iterations this easily exceeds Azure's 256KB aggregated error message limit, causing the deployment to fail with:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
The aggregated deployment error is too large. Please list deployment operations to get the deployment details.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To debug deployment failures, use `az deployment-scripts show-log` after the script runs, or add targeted `echo` statements instead of `set -x`.
|
|
75
|
+
|
|
76
|
+
## Known issue: permission propagation race condition
|
|
77
|
+
|
|
78
|
+
The managed identity's Graph app role assignments (`Policy.Read.All`, `Policy.ReadWrite.ApplicationConfiguration`, `Application.ReadWrite.All`, etc.) are created in the same Bicep deployment as the DeploymentScript. Entra may not have propagated these permissions by the time the script runs, causing the token to be missing required roles.
|
|
79
|
+
|
|
80
|
+
The script handles this with a retry loop at startup: it acquires a token, decodes the JWT, checks all required roles are present, and retries up to 6 times with 20s gaps (120s total) before failing. You will see output like:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Waiting for role propagation (attempt 1/6), missing: Policy.Read.All Policy.ReadWrite.ApplicationConfiguration
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This is expected on fresh deployments. If it fails after 120s, check the managed identity's app role assignments in Entra.
|