Skip to content
7 changes: 6 additions & 1 deletion src/managedcleanroom/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ Release History

1.0.0b1
++++++
* Initial release.
* Initial release.

1.0.0b2
++++++
* Add frontend commandlets
* Add MSAL device code flow authentication
45 changes: 44 additions & 1 deletion src/managedcleanroom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,47 @@ az managedcleanroom consortium create \
--location westus
```

If you have issues, please give feedback by opening an issue at https://github.com/Azure/azure-cli-extensions/issues.
## SDK Regeneration Guide ##

When regenerating `analytics_frontend_api/` SDK using autorest:

```bash
autorest --input-file=../frontend.yaml --python --output-folder=./generated-cmdlets-frontend
```

Update the following files to match SDK changes:

### 1. Map SDK Method Names
**File:** `_frontend_custom.py`
- Update `client.collaboration.<old_method>()` → `client.collaboration.<new_method>()`
- Update function parameter names to match SDK

### 2. Update Parameter Definitions
**File:** `_params.py`
- Rename argument types (e.g., `query_id_type` → `document_id_type`)
- Update `options_list` and `help` text
- Add/remove parameter contexts in `with self.argument_context()` blocks

### 3. Update Command Registration
**File:** `_frontend_commands.py`
- Update command group paths if SDK URLs changed
- Update function names in `g.custom_command()` calls

### 4. Update Help Documentation
**File:** `_help.py`
- Update parameter names in examples
- Update command paths for new/renamed commands
- Add help entries for new commands

### 5. Update Unit Tests
**Files:** `tests/latest/test_frontend_*.py`
- Update mock paths: `mock_client.collaboration.<old_method>` → `<new_method>`
- Update function parameter names in test calls
- Update function imports if renamed

### 6. Validate Changes
```bash
# Run tests
cd src/managedcleanroom
python -m pytest azext_managedcleanroom/tests/latest/ -v
```
3 changes: 3 additions & 0 deletions src/managedcleanroom/azext_managedcleanroom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#
# Code generated by aaz-dev-tools
# --------------------------------------------------------------------------------------------
# flake8: noqa

from azure.cli.core import AzCommandsLoader
from azext_managedcleanroom._help import helps # pylint: disable=unused-import
Expand All @@ -20,6 +21,7 @@ def __init__(self, cli_ctx=None):

def load_command_table(self, args):
from azext_managedcleanroom.commands import load_command_table
from azext_managedcleanroom._frontend_commands import load_frontend_command_table
from azure.cli.core.aaz import load_aaz_command_table
try:
from . import aaz
Expand All @@ -32,6 +34,7 @@ def load_command_table(self, args):
args=args
)
load_command_table(self, args)
load_frontend_command_table(self, args)
return self.command_table

def load_arguments(self, command):
Expand Down
165 changes: 165 additions & 0 deletions src/managedcleanroom/azext_managedcleanroom/_frontend_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

"""Authentication and client management for Analytics Frontend API"""

from azure.cli.core._profile import Profile
from azure.cli.core.commands.client_factory import get_subscription_id
from azure.cli.core.util import CLIError
from knack.log import get_logger

logger = get_logger(__name__)


def get_frontend_token(cmd):
"""Get access token from MSAL cache or Azure context

Authentication priority:
1. MSAL token (device code flow) - if user ran 'frontend login'
2. Azure CLI token (az login) - fallback

:param cmd: CLI command context
:return: Tuple of (access_token, subscription, tenant)
:raises: CLIError if token cannot be obtained
"""
from ._msal_auth import get_msal_token, get_auth_scope

profile = Profile(cli_ctx=cmd.cli_ctx)
subscription = get_subscription_id(cmd.cli_ctx)
auth_scope = get_auth_scope(cmd)

logger.debug("Using auth scope: %s", auth_scope)

try:
msal_token = get_msal_token(cmd)
if msal_token:
logger.debug("Using MSAL device code flow token")
return (msal_token[0], subscription, msal_token[2])

logger.debug("Using Azure CLI (az login) token")
return profile.get_raw_token(
subscription=subscription,
resource=auth_scope
)

except Exception as ex:
raise CLIError(
f'Failed to get access token: {str(ex)}\n\n'
'Please authenticate using one of:\n'
' 1. az managedcleanroom frontend login (MSAL device code flow)\n'
' 2. az login (Azure CLI authentication)\n')


def get_frontend_config(cmd):
"""Read frontend endpoint configuration from Azure CLI config

:param cmd: CLI command context
:return: Configured endpoint URL or None
:rtype: str or None
"""
config = cmd.cli_ctx.config
return config.get('managedcleanroom-frontend', 'endpoint', fallback=None)


def set_frontend_config(cmd, endpoint):
"""Store frontend endpoint in Azure CLI config

:param cmd: CLI command context
:param endpoint: API endpoint URL to store
:type endpoint: str
"""
cmd.cli_ctx.config.set_value(
'managedcleanroom-frontend',
'endpoint',
endpoint)


def get_frontend_client(cmd, endpoint=None):
"""Create Analytics Frontend API client with Azure authentication

Uses Profile.get_raw_token() to fetch access token from Azure context.
Token is fetched fresh on every invocation.

:param cmd: CLI command context
:param endpoint: Optional explicit endpoint URL (overrides config)
:type endpoint: str
:return: Configured AnalyticsFrontendAPI client
:raises: CLIError if token fetch fails or endpoint not configured
"""
from .analytics_frontend_api import AnalyticsFrontendAPI
from azure.core.pipeline.policies import BearerTokenCredentialPolicy, SansIOHTTPPolicy

api_endpoint = endpoint or get_frontend_config(cmd)
if not api_endpoint:
raise CLIError(
'Analytics Frontend API endpoint not configured.\n'
'Configure using: az config set managedcleanroom-frontend.endpoint=<url>\n'
'Or use the --endpoint flag with your command.')

access_token_obj, _, _ = get_frontend_token(cmd)

logger.debug(
"Creating Analytics Frontend API client for endpoint: %s",
api_endpoint)

# Check if this is a local development endpoint
is_local = api_endpoint.startswith(
'http://localhost') or api_endpoint.startswith('http://127.0.0.1')

# Create simple credential wrapper for the access token
credential = type('TokenCredential', (), {
'get_token': lambda self, *args, **kwargs: access_token_obj
})()

if is_local:
# For local development, create a custom auth policy that bypasses
# HTTPS check
class LocalBearerTokenPolicy(SansIOHTTPPolicy):
"""Bearer token policy that allows HTTP for local development"""

def __init__(self, token_obj):
self._token = token_obj # AccessToken object

def on_request(self, request):
"""Add authorization header with bearer token"""
# Extract token string from AccessToken object
# The token might be a tuple ('Bearer', 'token_string') or just
# the token string
if hasattr(self._token, 'token'):
token_value = self._token.token
else:
token_value = self._token

# If it's a tuple, extract the actual token (second element)
if isinstance(token_value, tuple) and len(token_value) >= 2:
token_string = token_value[1]
else:
token_string = str(token_value)

auth_header = f'Bearer {token_string}'
logger.debug(
"Setting Authorization header: Bearer %s...", token_string[:50])
request.http_request.headers['Authorization'] = auth_header

auth_policy = LocalBearerTokenPolicy(access_token_obj)
else:
# For production, use standard bearer token policy with HTTPS
# enforcement
# Use configured auth_scope with .default suffix for Azure SDK
from ._msal_auth import get_auth_scope
scope = get_auth_scope(cmd)
if not scope.endswith('/.default'):
scope = f'{scope}/.default'

auth_policy = BearerTokenCredentialPolicy(
credential,
scope
)

# Return configured client
return AnalyticsFrontendAPI(
endpoint=api_endpoint,
authentication_policy=auth_policy
)
115 changes: 115 additions & 0 deletions src/managedcleanroom/azext_managedcleanroom/_frontend_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

"""Command registration for Analytics Frontend API"""

from azure.cli.core.commands import CliCommandType


def load_frontend_command_table(loader, _):
"""Register all Analytics Frontend API commands

Registers 26 commands across command groups for frontend collaboration.

:param loader: Command loader instance
"""

frontend_custom = CliCommandType(
operations_tmpl='azext_managedcleanroom._frontend_custom#{}')

# Base collaboration commands (only list - no --collaboration-id needed)
with loader.command_group('managedcleanroom frontend collaboration', custom_command_type=frontend_custom) as g:
g.custom_command('list', 'frontend_collaboration_list')

# Show command at frontend level (requires --collaboration-id)
with loader.command_group('managedcleanroom frontend', custom_command_type=frontend_custom) as g:
g.custom_show_command('show', 'frontend_collaboration_show')
Copy link
Copy Markdown

@anantshankar17 anantshankar17 Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All cmds below this require the --collaboration-id param, should those cmd grps be derived from g then or be sub groups ? #Resolved

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can create everything as a subgroup - but then we will have to build parsing on the commands to know what method to call - right now it structured in a way where all cmdlets call the sdk directly and so this was the simplest way to register the commands without building some parsing logic


# Workloads commands
with loader.command_group('managedcleanroom frontend workloads', custom_command_type=frontend_custom) as g:
Comment thread
sakshamgargMS marked this conversation as resolved.
g.custom_command('list', 'frontend_collaboration_workloads_list')

# Analytics commands
with loader.command_group('managedcleanroom frontend analytics', custom_command_type=frontend_custom) as g:
g.custom_show_command('show', 'frontend_collaboration_analytics_show')
g.custom_command(
'deploymentinfo',
'frontend_collaboration_analytics_deploymentinfo')
g.custom_command(
'cleanroompolicy',
'frontend_collaboration_analytics_cleanroompolicy')

# OIDC commands
with loader.command_group('managedcleanroom frontend oidc issuerinfo', custom_command_type=frontend_custom) as g:
g.custom_show_command(
'show', 'frontend_collaboration_oidc_issuerinfo_show')

# Invitation commands
with loader.command_group('managedcleanroom frontend invitation', custom_command_type=frontend_custom) as g:
g.custom_command('list', 'frontend_collaboration_invitation_list')
g.custom_show_command('show', 'frontend_collaboration_invitation_show')
g.custom_command('accept', 'frontend_collaboration_invitation_accept')

# Dataset commands
with loader.command_group('managedcleanroom frontend analytics dataset', custom_command_type=frontend_custom) as g:
g.custom_command('list', 'frontend_collaboration_dataset_list')
g.custom_show_command('show', 'frontend_collaboration_dataset_show')
g.custom_command('publish', 'frontend_collaboration_dataset_publish')

# Consent commands
with loader.command_group('managedcleanroom frontend consent', custom_command_type=frontend_custom) as g:
g.custom_command('check', 'frontend_collaboration_consent_check')
g.custom_command('set', 'frontend_collaboration_consent_set')

# Query commands
with loader.command_group('managedcleanroom frontend analytics query', custom_command_type=frontend_custom) as g:
g.custom_command('list', 'frontend_collaboration_query_list')
g.custom_show_command('show', 'frontend_collaboration_query_show')
g.custom_command('publish', 'frontend_collaboration_query_publish')
g.custom_command('run', 'frontend_collaboration_query_run')

# Query vote commands
with loader.command_group(
'managedcleanroom frontend analytics query vote',
custom_command_type=frontend_custom) as g:
g.custom_command('accept', 'frontend_collaboration_query_vote_accept')
g.custom_command('reject', 'frontend_collaboration_query_vote_reject')

# Query run history commands
with loader.command_group(
'managedcleanroom frontend analytics query runhistory',
custom_command_type=frontend_custom) as g:
g.custom_command(
'list', 'frontend_collaboration_query_runhistory_list')

# Query run result commands
with loader.command_group(
'managedcleanroom frontend analytics query runresult',
custom_command_type=frontend_custom) as g:
g.custom_show_command(
'show', 'frontend_collaboration_query_runresult_show')

# Audit event commands
with loader.command_group(
'managedcleanroom frontend analytics auditevent',
custom_command_type=frontend_custom) as g:
g.custom_command('list', 'frontend_collaboration_audit_list')

# Attestation commands
with loader.command_group('managedcleanroom frontend attestation', custom_command_type=frontend_custom) as g:
g.custom_command('cgs', 'frontend_collaboration_attestation_cgs')

with loader.command_group(
'managedcleanroom frontend analytics attestationreport',
custom_command_type=frontend_custom) as g:
g.custom_command(
'cleanroom',
'frontend_collaboration_attestation_cleanroom')

# Configuration and authentication commands
with loader.command_group('managedcleanroom frontend', custom_command_type=frontend_custom) as g:
g.custom_command('configure', 'frontend_configure')
g.custom_command('login', 'frontend_login')
g.custom_command('logout', 'frontend_logout')
Loading
Loading