Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support CORS policy and mTLS for CLI #6420

Merged
merged 10 commits into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/containerapp/azext_containerapp/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,50 @@
az containerapp ingress sticky-sessions show -n MyContainerapp -g MyResourceGroup
"""

helps['containerapp ingress cors'] = """
type: group
short-summary: Commands to set CORS policy for a container app.
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
"""

helps['containerapp ingress cors enable'] = """
type: command
short-summary: Commands to enable CORS policy for a container app.
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
examples:
- name: Set allowed origins and allowed methods for a container app.
text: |
az containerapp ingress cors enable -n MyContainerapp -g MyResourceGroup --allowed-origins http://www.contoso.com https://www.contoso.com --allowed-methods GET POST
- name: Set allowed origins, allowed methods and allowed headers for a container app.
text: |
az containerapp ingress cors enable -n MyContainerapp -g MyResourceGroup --allowed-origins * --allowed-methods * --allowed-headers header1 header2
"""

helps['containerapp ingress cors disable'] = """
type: command
short-summary: Commands to disable CORS policy for a container app.
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
examples:
- name: Disable CORS policy for a container app.
text: |
az containerapp ingress cors disable -n MyContainerapp -g MyResourceGroup
"""

helps['containerapp ingress cors update'] = """
type: command
short-summary: Commands to update CORS policy for a container app.
examples:
- name: Update allowed origins and allowed methods for a container app while keeping other cors settings.
text: |
az containerapp ingress cors update -n MyContainerapp -g MyResourceGroup --allowed-origins http://www.contoso.com https://www.contoso.com --allowed-methods GET POST
"""

helps['containerapp ingress cors show'] = """
type: command
short-summary: Commands to show CORS policy for a container app.
examples:
- name: Show CORS policy for a container app.
text: |
az containerapp ingress cors show -n MyContainerapp -g MyResourceGroup
"""

# Registry Commands
helps['containerapp registry'] = """
type: group
Expand Down
13 changes: 12 additions & 1 deletion src/containerapp/azext_containerapp/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from ._validators import (validate_memory, validate_cpu, validate_managed_env_name_or_id, validate_registry_server,
validate_registry_user, validate_registry_pass, validate_target_port, validate_ingress,
validate_storage_name_or_id)
validate_storage_name_or_id, validate_cors_max_age)
from ._constants import UNAUTHENTICATED_CLIENT_ACTION, FORWARD_PROXY_CONVENTION, MAXIMUM_CONTAINER_APP_NAME_LENGTH, LOG_TYPE_CONSOLE, LOG_TYPE_SYSTEM


Expand Down Expand Up @@ -178,6 +178,9 @@ def load_arguments(self, _):
c.argument('certificate_file', options_list=['--custom-domain-certificate-file', '--certificate-file'], help='The filepath of the certificate file (.pfx or .pem) for the environment\'s custom domain. To manage certificates for container apps, use `az containerapp env certificate`.')
c.argument('certificate_password', options_list=['--custom-domain-certificate-password', '--certificate-password'], help='The certificate file password for the environment\'s custom domain.')

with self.argument_context('containerapp env', arg_group='Peer Authentication') as c:
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
c.argument('mtls_peer_authentication_enabled', arg_type=get_three_state_flag(), options_list=['--mtls-peer-authentication-enabled'], help='Boolean indicating if mTLS peer authentication is enabled for the environment.')
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved

with self.argument_context('containerapp service') as c:
c.argument('service_name', options_list=['--name', '-n'], help="The service name.")
c.argument('environment_name', options_list=['--environment'], help="The environment name.")
Expand Down Expand Up @@ -299,6 +302,14 @@ def load_arguments(self, _):
with self.argument_context('containerapp ingress sticky-sessions') as c:
c.argument('affinity', arg_type=get_enum_type(['sticky', 'none']), help='Whether the affinity for the container app is Sticky or None.')

with self.argument_context('containerapp ingress cors') as c:
c.argument('allowed_origins', nargs='*', options_list=['--allowed-origins', '-r'], help="A list of allowed origin(s) for the container app. Values are space-separated.")
c.argument('allowed_methods', nargs='*', options_list=['--allowed-methods', '-m'], help="A list of allowed method(s) for the container app. Values are space-separated.")
c.argument('allowed_headers', nargs='*', options_list=['--allowed-headers', '-a'], help="A list of allowed header(s) for the container app. Values are space-separated.")
c.argument('expose_headers', nargs='*', options_list=['--expose-headers', '-e'], help="A list of expose header(s) for the container app. Values are space-separated.")
c.argument('allow_credentials', options_list=['--allow-credentials'], arg_type=get_three_state_flag(), help='Whether the credential is allowed for the container app.')
c.argument('max_age', nargs='?', const='', validator=validate_cors_max_age, help="The maximum age of the allowed origin in seconds.")
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved

with self.argument_context('containerapp secret') as c:
c.argument('secrets', nargs='+', options_list=['--secrets', '-s'], help="A list of secret(s) for the container app. Space-separated values in 'key=value' or 'key=keyvaultref:keyvaulturl,identityref:identity' format (where 'key' cannot be longer than 20 characters).")
c.argument('secret_name', help="The name of the secret to show.")
Expand Down
12 changes: 12 additions & 0 deletions src/containerapp/azext_containerapp/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,15 @@ def validate_ssh(cmd, namespace):
_validate_revision_exists(cmd, namespace)
_validate_replica_exists(cmd, namespace)
_validate_container_exists(cmd, namespace)

def validate_cors_max_age(cmd, namespace):
if namespace.max_age:
try:
if namespace.max_age == "":
return

max_age = int(namespace.max_age)
if max_age < 0:
raise ValidationError("max-age must be a positive integer.")
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
except ValueError:
raise ValidationError("max-age must be an integer.")
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions src/containerapp/azext_containerapp/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ def load_command_table(self, _):
g.custom_command('remove', 'remove_ip_restriction')
g.custom_show_command('list', 'show_ip_restrictions')

with self.command_group('containerapp ingress cors') as g:
g.custom_command('enable', 'enable_cors_policy', exception_handler=ex_handler_factory())
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
g.custom_command('disable', 'disable_cors_policy', exception_handler=ex_handler_factory())
g.custom_command('update', 'update_cors_policy', exception_handler=ex_handler_factory())
g.custom_show_command('show', 'show_cors_policy')

with self.command_group('containerapp registry') as g:
g.custom_command('set', 'set_registry', exception_handler=ex_handler_factory())
g.custom_show_command('show', 'show_registry')
Expand Down
121 changes: 121 additions & 0 deletions src/containerapp/azext_containerapp/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,7 @@ def create_managed_environment(cmd,
certificate_file=None,
certificate_password=None,
enable_workload_profiles=False,
mtls_peer_authentication_enabled=None,
no_wait=False):
if zone_redundant:
if not infrastructure_subnet_resource_id:
Expand Down Expand Up @@ -1378,6 +1379,8 @@ def create_managed_environment(cmd,
raise ValidationError('Infrastructure subnet resource ID needs to be supplied for internal only environments.')
managed_env_def["properties"]["vnetConfiguration"]["internal"] = True

if mtls_peer_authentication_enabled is not None:
managed_env_def["properties"]["peerAuthentication"]["mtls"]["enabled"] = mtls_peer_authentication_enabled
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
try:
r = ManagedEnvironmentClient.create(
cmd=cmd, resource_group_name=resource_group_name, name=name, managed_environment_envelope=managed_env_def, no_wait=no_wait)
Expand Down Expand Up @@ -1412,6 +1415,7 @@ def update_managed_environment(cmd,
workload_profile_name=None,
min_nodes=None,
max_nodes=None,
mtls_peer_authentication_enabled=None,
no_wait=False):
if logs_destination == "log-analytics" or logs_customer_id or logs_key:
if logs_destination != "log-analytics":
Expand Down Expand Up @@ -1482,6 +1486,9 @@ def update_managed_environment(cmd,

safe_set(env_def, "properties", "workloadProfiles", value=workload_profiles)

if mtls_peer_authentication_enabled is not None:
safe_set(env_def, "properties", "peerAuthentication", "mtls", "enabled", value=mtls_peer_authentication_enabled)

try:
r = ManagedEnvironmentClient.update(
cmd=cmd, resource_group_name=resource_group_name, name=name, managed_environment_envelope=env_def, no_wait=no_wait)
Expand Down Expand Up @@ -3509,6 +3516,120 @@ def show_ingress_sticky_session(cmd, name, resource_group_name):
raise ValidationError("Ingress must be enabled to enable sticky sessions. Try running `az containerapp ingress -h` for more info.") from e


def enable_cors_policy(cmd, name, resource_group_name, allowed_origins, allowed_methods=None, allowed_headers=None, expose_headers=None, allow_credentials=None, max_age=None, no_wait=False):
_validate_subscription_registered(cmd, CONTAINER_APPS_RP)

if not allowed_origins:
raise ValidationError("Allowed origins must be specified.")
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved

containerapp_def = None
try:
containerapp_def = ContainerAppClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name)
except:
pass
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved

if not containerapp_def:
raise ResourceNotFoundError(f"The containerapp '{name}' does not exist in group '{resource_group_name}'")

containerapp_patch = {}
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowedOrigins", value=allowed_origins)
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowedMethods", value=allowed_methods)
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowedHeaders", value=allowed_headers)
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "exposeHeaders", value=expose_headers)
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowCredentials", value=allow_credentials)
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "maxAge", value=max_age)
try:
r = ContainerAppClient.update(
cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_patch, no_wait=no_wait)
return r['properties']['configuration']['ingress']['corsPolicy']
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
except Exception as e:
handle_raw_exception(e)


def disable_cors_policy(cmd, name, resource_group_name, no_wait=False):
_validate_subscription_registered(cmd, CONTAINER_APPS_RP)

containerapp_def = None
try:
containerapp_def = ContainerAppClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name)
except:
pass
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved

if not containerapp_def:
raise ResourceNotFoundError(f"The containerapp '{name}' does not exist in group '{resource_group_name}'")

containerapp_patch = {}
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", value=None)
try:
r = ContainerAppClient.update(
cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_patch, no_wait=no_wait)
return r['properties']['configuration']['ingress']
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
except Exception as e:
handle_raw_exception(e)


def update_cors_policy(cmd, name, resource_group_name, allowed_origins=None, allowed_methods=None, allowed_headers=None, expose_headers=None, allow_credentials=None, max_age=None, no_wait=False):
_validate_subscription_registered(cmd, CONTAINER_APPS_RP)

containerapp_def = None
try:
containerapp_def = ContainerAppClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name)
except:
pass

if not containerapp_def:
raise ResourceNotFoundError(f"The containerapp '{name}' does not exist in group '{resource_group_name}'")

if allowed_origins is not None and len(allowed_origins) == 0:
raise ValidationError("allowed-origins must be specified if provided.")
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved

reset_max_age = False
if max_age == "":
reset_max_age = True

containerapp_patch = {}
if allowed_origins is not None:
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowedOrigins", value=allowed_origins)
if allowed_methods is not None:
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowedMethods", value=allowed_methods)
if allowed_headers is not None:
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowedHeaders", value=allowed_headers)
if expose_headers is not None:
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "exposeHeaders", value=expose_headers)
if allow_credentials is not None:
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "allowCredentials", value=allow_credentials)
if max_age is not None:
if reset_max_age:
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "maxAge", value=None)
else:
safe_set(containerapp_patch, "properties", "configuration", "ingress", "corsPolicy", "maxAge", value=max_age)

try:
r = ContainerAppClient.update(
cmd=cmd, resource_group_name=resource_group_name, name=name, container_app_envelope=containerapp_patch, no_wait=no_wait)
return r['properties']['configuration']['ingress']['corsPolicy']
except Exception as e:
handle_raw_exception(e)


def show_cors_policy(cmd, name, resource_group_name):
_validate_subscription_registered(cmd, CONTAINER_APPS_RP)

containerapp_def = None
try:
containerapp_def = ContainerAppClient.show(cmd=cmd, resource_group_name=resource_group_name, name=name)
except:
pass
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved

if not containerapp_def:
raise ResourceNotFoundError(f"The containerapp '{name}' does not exist in group '{resource_group_name}'")

try:
return containerapp_def["properties"]["configuration"]['ingress']["corsPolicy"]
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
except Exception as e:
raise ValidationError("CORS must be enabled to enable CORS policy. Try running `az containerapp cors enable -h` for more info.") from e
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved


def show_registry(cmd, name, resource_group_name, server):
_validate_subscription_registered(cmd, CONTAINER_APPS_RP)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ def test_containerapp_identity_user(self, resource_group):
JMESPathCheck('type', 'None'),
])


class ContainerappIngressTests(ScenarioTest):
@AllowLargeResponse(8192)
@ResourceGroupPreparer(location="eastus2")
Expand Down Expand Up @@ -556,6 +555,63 @@ def test_containerapp_ip_restrictions_deny(self, resource_group):
JMESPathCheck('length(@)', 0),
])

@AllowLargeResponse(8192)
@ResourceGroupPreparer(location="northeurope")
def test_containerapp_cors_policy(self, resource_group):
zhenqxuMSFT marked this conversation as resolved.
Show resolved Hide resolved
self.cmd('configure --defaults location={}'.format(TEST_LOCATION))

env_name = self.create_random_name(prefix='containerapp-env', length=24)
ca_name = self.create_random_name(prefix='containerapp', length=24)

create_containerapp_env(self, env_name, resource_group)

self.cmd('containerapp create -g {} -n {} --environment {} --ingress external --target-port 80'.format(resource_group, ca_name, env_name))

self.cmd('containerapp ingress cors enable -g {} -n {} --allowed-origins "http://www.contoso.com" "https://www.contoso.com" --allowed-methods "GET" "POST" --allowed-headers "header1" "header2" --expose-headers "header3" "header4" --allow-credentials true --max-age 100'.format(resource_group, ca_name), checks=[
JMESPathCheck('length(allowedOrigins)', 2),
JMESPathCheck('allowedOrigins[0]', "http://www.contoso.com"),
JMESPathCheck('allowedOrigins[1]', "https://www.contoso.com"),
JMESPathCheck('length(allowedMethods)', 2),
JMESPathCheck('allowedMethods[0]', "GET"),
JMESPathCheck('allowedMethods[1]', "POST"),
JMESPathCheck('length(allowedHeaders)', 2),
JMESPathCheck('allowedHeaders[0]', "header1"),
JMESPathCheck('allowedHeaders[1]', "header2"),
JMESPathCheck('length(exposeHeaders)', 2),
JMESPathCheck('exposeHeaders[0]', "header3"),
JMESPathCheck('exposeHeaders[1]', "header4"),
JMESPathCheck('allowCredentials', True),
JMESPathCheck('maxAge', 100),
])

self.cmd('containerapp ingress cors update -g {} -n {} --allowed-origins "*" --allowed-methods "GET" --allowed-headers "header1" --expose-headers --allow-credentials false --max-age 0'.format(resource_group, ca_name), checks=[
JMESPathCheck('length(allowedOrigins)', 1),
JMESPathCheck('allowedOrigins[0]', "*"),
JMESPathCheck('length(allowedMethods)', 1),
JMESPathCheck('allowedMethods[0]', "GET"),
JMESPathCheck('length(allowedHeaders)', 1),
JMESPathCheck('allowedHeaders[0]', "header1"),
JMESPathCheck('exposeHeaders', None),
JMESPathCheck('allowCredentials', False),
JMESPathCheck('maxAge', 0),
])

self.cmd('containerapp ingress cors show -g {} -n {}'.format(resource_group, ca_name), checks=[
JMESPathCheck('length(allowedOrigins)', 1),
JMESPathCheck('allowedOrigins[0]', "*"),
JMESPathCheck('length(allowedMethods)', 1),
JMESPathCheck('allowedMethods[0]', "GET"),
JMESPathCheck('length(allowedHeaders)', 1),
JMESPathCheck('allowedHeaders[0]', "header1"),
JMESPathCheck('exposeHeaders', None),
JMESPathCheck('allowCredentials', False),
JMESPathCheck('maxAge', 0),
])

self.cmd('containerapp ingress cors disable -g {} -n {}'.format(resource_group, ca_name), checks=[
JMESPathCheck('corsPolicy', None),
])


class ContainerappDaprTests(ScenarioTest):
@AllowLargeResponse(8192)
Expand Down