C-6: ConnectorRuntime enforces capability_mask per operation.
READ-only ACs cannot invoke MUTATE operations (wipe, lock, retire).
C-7: AC validated against database (exists, active, not expired)
before connector invocation.
C-9: Delegated AC capability bounded by delegator's capability.
C-10: Command counter uses atomic SQL increment with limit check.
M-23: expire_stale() uses same atomic SQL pattern.
H-1: Sensitive credential fields hidden from repr/logs via repr=False.
H-2: Stub backend requires ALLOW_STUB_CREDENTIALS=true to activate.
H-3: Kerberos backend raises CredentialResolutionError instead of
returning stub ticket.
H-4: Chronicle INTENT emitted before execution, RESULT after.
H-5: device_id validated as UUID before Graph API URL interpolation.
H-8: ConnectorRuntime enforces governance for all connector invocations.
Signed-off-by: Tyler King <tking@guildhouse.dev>
126 lines
5.2 KiB
Python
126 lines
5.2 KiB
Python
# Copyright (c) Microsoft Corporation.
|
|
# All rights reserved.
|
|
#
|
|
# This code is licensed under the MIT License.
|
|
|
|
"""This module wraps Cloud Shell's IMDS-like interface inside an OAuth2-like helper"""
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
try: # Python 2
|
|
from urlparse import urlparse
|
|
except: # Python 3
|
|
from urllib.parse import urlparse
|
|
from .oauth2cli.oidc import decode_part
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _is_running_in_cloud_shell():
|
|
return os.environ.get("AZUREPS_HOST_ENVIRONMENT", "").startswith("cloud-shell")
|
|
|
|
|
|
def _scope_to_resource(scope): # This is an experimental reasonable-effort approach
|
|
cloud_shell_supported_audiences = [
|
|
"https://analysis.windows.net/powerbi/api", # Came from https://msazure.visualstudio.com/One/_git/compute-CloudShell?path=/src/images/agent/env/envconfig.PROD.json
|
|
"https://pas.windows.net/CheckMyAccess/Linux/.default", # Cloud Shell accepts it as-is
|
|
]
|
|
for a in cloud_shell_supported_audiences:
|
|
if scope.startswith(a):
|
|
return a
|
|
u = urlparse(scope)
|
|
if not u.scheme and not u.netloc: # Typically the "GUID/scope" case
|
|
return u.path.split("/")[0]
|
|
if u.scheme:
|
|
trailer = ( # https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc#trailing-slash-and-default
|
|
"/" if u.path.startswith("//") else "")
|
|
return "{}://{}{}".format(u.scheme, u.netloc, trailer)
|
|
return scope # There is no much else we can do here
|
|
|
|
|
|
def _obtain_token(http_client, scopes, client_id=None, data=None):
|
|
resp = http_client.post(
|
|
"http://localhost:50342/oauth2/token",
|
|
data=dict(
|
|
data or {},
|
|
resource=" ".join(map(_scope_to_resource, scopes))),
|
|
headers={"Metadata": "true"},
|
|
)
|
|
if resp.status_code >= 300:
|
|
logger.debug("Cloud Shell IMDS error: %s", resp.text)
|
|
cs_error = json.loads(resp.text).get("error", {})
|
|
return {k: v for k, v in {
|
|
"error": cs_error.get("code"),
|
|
"error_description": cs_error.get("message"),
|
|
}.items() if v}
|
|
imds_payload = json.loads(resp.text)
|
|
BEARER = "Bearer"
|
|
oauth2_response = {
|
|
"access_token": imds_payload["access_token"],
|
|
"expires_in": int(imds_payload["expires_in"]),
|
|
"token_type": imds_payload.get("token_type", BEARER),
|
|
}
|
|
expected_token_type = (data or {}).get("token_type", BEARER)
|
|
if oauth2_response["token_type"] != expected_token_type:
|
|
return { # Generate a normal error (rather than an intrusive exception)
|
|
"error": "broker_error",
|
|
"error_description": "token_type {} is not supported by this version of Azure Portal".format(
|
|
expected_token_type),
|
|
}
|
|
parts = imds_payload["access_token"].split(".")
|
|
|
|
# The following default values are useful in SSH Cert scenario
|
|
client_info = { # Default value, in case the real value will be unavailable
|
|
"uid": "user",
|
|
"utid": "cloudshell",
|
|
}
|
|
now = time.time()
|
|
preferred_username = "currentuser@cloudshell"
|
|
oauth2_response["id_token_claims"] = { # First 5 claims are required per OIDC
|
|
"iss": "cloudshell",
|
|
"sub": "user",
|
|
"aud": client_id,
|
|
"exp": now + 3600,
|
|
"iat": now,
|
|
"preferred_username": preferred_username, # Useful as MSAL account's username
|
|
}
|
|
|
|
if len(parts) == 3: # Probably a JWT. Use it to derive client_info and id token.
|
|
try:
|
|
# Data defined in https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens#payload-claims
|
|
jwt_payload = json.loads(decode_part(parts[1]))
|
|
client_info = {
|
|
# Mimic a real home_account_id,
|
|
# so that this pseudo account and a real account would interop.
|
|
"uid": jwt_payload.get("oid", "user"),
|
|
"utid": jwt_payload.get("tid", "cloudshell"),
|
|
}
|
|
oauth2_response["id_token_claims"] = {
|
|
"iss": jwt_payload["iss"],
|
|
"sub": jwt_payload["sub"], # Could use oid instead
|
|
"aud": client_id,
|
|
"exp": jwt_payload["exp"],
|
|
"iat": jwt_payload["iat"],
|
|
"preferred_username": jwt_payload.get("preferred_username") # V2
|
|
or jwt_payload.get("unique_name") # V1
|
|
or preferred_username,
|
|
}
|
|
except ValueError:
|
|
logger.debug("Unable to decode jwt payload: %s", parts[1])
|
|
oauth2_response["client_info"] = base64.b64encode(
|
|
# Mimic a client_info, so that MSAL would create an account
|
|
json.dumps(client_info).encode("utf-8")).decode("utf-8")
|
|
oauth2_response["id_token_claims"]["tid"] = client_info["utid"] # TBD
|
|
|
|
## Note: Decided to not surface resource back as scope,
|
|
## because they would cause the downstream OAuth2 code path to
|
|
## cache the token with a different scope and won't hit them later.
|
|
#if imds_payload.get("resource"):
|
|
# oauth2_response["scope"] = imds_payload["resource"]
|
|
if imds_payload.get("refresh_token"):
|
|
oauth2_response["refresh_token"] = imds_payload["refresh_token"]
|
|
return oauth2_response
|
|
|