diff --git a/CHANGELOG.md b/CHANGELOG.md index d21b9fd809..a6757c5564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ FEATURES: * Display workspace and shared services total costs for admin role in UI [#2738](https://github.com/microsoft/AzureTRE/pull/2772) * Automatically validate all resources have tre_id tag via TFLint [#2774](https://github.com/microsoft/AzureTRE/pull/2774) +* Add metadata endpoint and simplify `tre` CLI login (also adds API version to UI) (#2794) ENHANCEMENTS: * Renamed several airlock fields to make them more descriptive and added a createdBy field. Included migration for backwards compatibility ([#2779](https://github.com/microsoft/AzureTRE/pull/2779)) diff --git a/api_app/_version.py b/api_app/_version.py index a779a44262..1cc82e6b87 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.5.6" +__version__ = "0.5.7" diff --git a/api_app/api/routes/api.py b/api_app/api/routes/api.py index 4da2ab0183..78cbaf4d0f 100644 --- a/api_app/api/routes/api.py +++ b/api_app/api/routes/api.py @@ -8,7 +8,7 @@ from api.dependencies.database import get_repository from db.repositories.workspaces import WorkspaceRepository from api.routes import health, ping, workspaces, workspace_templates, workspace_service_templates, user_resource_templates, \ - shared_services, shared_service_templates, migrations, costs, airlock, operations + shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata from core import config core_tags_metadata = [ @@ -30,11 +30,13 @@ router = APIRouter() router.include_router(health.router, tags=["health"]) router.include_router(ping.router, tags=["health"]) +router.include_router(metadata.router, tags=["metadata"]) # Core API core_router = APIRouter(prefix=config.API_PREFIX) core_router.include_router(health.router, tags=["health"]) core_router.include_router(ping.router, tags=["health"]) +core_router.include_router(metadata.router, tags=["metadata"]) core_router.include_router(workspace_templates.workspace_templates_admin_router, tags=["workspace templates"]) core_router.include_router(workspace_service_templates.workspace_service_templates_core_router, tags=["workspace service templates"]) core_router.include_router(user_resource_templates.user_resource_templates_core_router, tags=["user resource templates"]) @@ -77,7 +79,7 @@ async def get_swagger(request: Request): init_oauth={ "usePkceWithAuthorizationCodeGrant": True, "clientId": config.SWAGGER_UI_CLIENT_ID, - "scopes": ["openid", "offline_access", f"api://{config.API_CLIENT_ID}/user_impersonation"] + "scopes": ["openid", "offline_access", config.API_ROOT_SCOPE] } ) diff --git a/api_app/api/routes/metadata.py b/api_app/api/routes/metadata.py new file mode 100644 index 0000000000..b7cbfc2177 --- /dev/null +++ b/api_app/api/routes/metadata.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter +from resources import strings +from _version import __version__ +from core import config +from models.schemas.metadata import Metadata + +router = APIRouter() + + +@router.get("/.metadata", name=strings.API_GET_PING) +def metadata() -> Metadata: + return Metadata( + api_version=__version__, + api_client_id=config.API_CLIENT_ID, + api_root_scope=config.API_ROOT_SCOPE, + aad_tenant_id=config.AAD_TENANT_ID + ) diff --git a/api_app/core/config.py b/api_app/core/config.py index db0e2a8ec3..dbc9e12596 100644 --- a/api_app/core/config.py +++ b/api_app/core/config.py @@ -54,3 +54,5 @@ API_AUDIENCE: str = config("API_AUDIENCE", default=API_CLIENT_ID) AIRLOCK_SAS_TOKEN_EXPIRY_PERIOD_IN_HOURS: int = config("AIRLOCK_SAS_TOKEN_EXPIRY_PERIOD_IN_HOURS", default=1) + +API_ROOT_SCOPE: str = f"api://{API_CLIENT_ID}/user_impersonation" diff --git a/api_app/models/schemas/metadata.py b/api_app/models/schemas/metadata.py new file mode 100644 index 0000000000..0870866e89 --- /dev/null +++ b/api_app/models/schemas/metadata.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class Metadata(BaseModel): + api_version: str + api_client_id: str + api_root_scope: str + aad_tenant_id: str diff --git a/api_app/resources/strings.py b/api_app/resources/strings.py index edceb1a050..a8ba22dfb8 100644 --- a/api_app/resources/strings.py +++ b/api_app/resources/strings.py @@ -3,6 +3,7 @@ # API Descriptions API_GET_HEALTH_STATUS = "Get health status" API_GET_PING = "Simple endpoint to test calling the API" +API_GET_METADATA = "Get public API metadata (e.g. to support the UI and CLI)" API_MIGRATE_DATABASE = "Migrate documents in the database" API_GET_MY_OPERATIONS = "Get Operations that the current user has initiated" diff --git a/cli/README.md b/cli/README.md index f1d8e015ab..10b404602a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -20,6 +20,16 @@ The CLI allows you to log in using either a device code flow or client credentia To log in using device code flow, run: +```bash +tre login device-code --base-url https://mytre.westeurope.cloudapp.azure.com/ +``` + +This will prompt you to copy a device code and nagivate to to complete the login flow interactively. + +You can specify `--no-verify` to disable SSL cert verification. + +On versions of the API prior to '0.5.7', you will need to pass some additional parameters: + ```bash tre login device-code \ --base-url https://mytre.westeurope.cloudapp.azure.com/ \ @@ -28,9 +38,6 @@ tre login device-code \ --api-scope ``` -This will prompt you to copy a device code and nagivate to to complete the login flow interactively. - -You can specify `--no-verify` to disable SSL cert verification. NOTE: the api scope is usually of the form `api:///user_impersonation` @@ -47,6 +54,17 @@ You can pre-emptively get an authentication token for a workspace using the `--w To log in using client credentials flow (for a service principal), run: +```bash +tre login client-credentials \ + --base-url https://mytre.westeurope.cloudapp.azure.com/ \ + --client-id \ + --client-secret +``` + +You can specify `--no-verify` to disable SSL cert verification. + +On versions of the API prior to '0.5.7', you will need to pass some additional parameters: + ```bash tre login client-credentials \ --base-url https://mytre.westeurope.cloudapp.azure.com/ \ @@ -56,8 +74,6 @@ tre login client-credentials \ --api-scope ``` -You can specify `--no-verify` to disable SSL cert verification. - NOTE: the api scope is usually of the form `api:///user_impersonation` diff --git a/cli/tre/api_client.py b/cli/tre/api_client.py index 1d6cff2fc4..b42351a93e 100644 --- a/cli/tre/api_client.py +++ b/cli/tre/api_client.py @@ -72,6 +72,18 @@ def get_api_client_from_config() -> "ApiClient": else: raise click.ClickException(f"Unhandled login method: {login_method}") + @staticmethod + def get_api_metadata(api_base_url: str) -> "Union[dict[str, str], None]": + with Client() as client: + url = f"{api_base_url}/api/.metadata" + + response = client.get(url) + if response.status_code == 200: + response_json = response.json() + return response_json + else: + return None + def call_api( self, log: Logger, diff --git a/cli/tre/commands/login.py b/cli/tre/commands/login.py index 9f435c69d0..c2a52983e2 100644 --- a/cli/tre/commands/login.py +++ b/cli/tre/commands/login.py @@ -1,3 +1,4 @@ +import sys import click import json import logging @@ -12,6 +13,25 @@ from typing import List +def all_or_none(values: "list(bool)") -> bool: + """Returns: + True if all set + False if all unset + None otherwise + """ + + if len(values) == 0: + return None + + first_value = True if values[0] else False # convert to truthy + for value in values[1:]: + current_value = True if value else False + if first_value is not current_value: + # value doesn't match first version + return None + return first_value + + @click.group(name="login", help="Set the TRE credentials and base URL") def login(): pass @@ -23,14 +43,14 @@ def login(): help='The TRE base URL, e.g. ' + 'https://..cloudapp.azure.com/') @click.option('--client-id', - required=True, - help='The Client ID of the Azure AD application for the API') + required=False, + help='The Client ID of the Azure AD application for the API (optional for API versions >= v0.5.7)') @click.option('--aad-tenant-id', - required=True, - help='The Tenant ID for the AAD tenant to authenticate with') + required=False, + help='The Tenant ID for the AAD tenant to authenticate with (optional for API versions >= v0.5.7)') @click.option('--api-scope', - required=True, - help='The API scope for the base API') + required=False, + help='The API scope for the base API (optional for API versions >= v0.5.7)') @click.option('--verify/--no-verify', help='Enable/disable SSL verification', default=True) @@ -50,6 +70,21 @@ def login_device_code(base_url: str, client_id: str, aad_tenant_id: str, api_sco if all_workspaces: raise click.ClickException("Cannot use `--all-workspaces and --workspace") + have_aad_tenant_etc = all_or_none([client_id, aad_tenant_id, api_scope]) + if have_aad_tenant_etc is None: + click.echo("Either all or none of --client-id, --aad-tenant-id and --api-scope must be specified") + sys.exit(1) + + # Load metadata from API if required + if not have_aad_tenant_etc: + metadata = ApiClient.get_api_metadata(base_url) + if not metadata: + click.echo("Unable to query API metadata endpoint - please pass --aad-tenant-id and --api-scope") + sys.exit(1) + client_id = metadata["api_client_id"] + aad_tenant_id = metadata["aad_tenant_id"] + api_scope = metadata["api_root_scope"] + # Set up token cache Path('~/.config/tre').expanduser().mkdir(parents=True, exist_ok=True) token_cache_file = Path('~/.config/tre/token_cache.json').expanduser() @@ -126,7 +161,7 @@ def login_device_code(base_url: str, client_id: str, aad_tenant_id: str, api_sco @click.command( name="client-credentials", - help="Use client credentials flow (client ID + secret) to authenticate", + help="Use client credentials flow (client ID + secret) to authenticate.", ) @click.option( "--base-url", @@ -141,10 +176,10 @@ def login_device_code(base_url: str, client_id: str, aad_tenant_id: str, api_sco ) @click.option( "--aad-tenant-id", - required=True, - help="The Tenant ID for the AAD tenant to authenticate with", + required=False, + help="The Tenant ID for the AAD tenant to authenticate with (optional for API versions >= v0.5.7)", ) -@click.option("--api-scope", required=True, help="The API scope for the base API") +@click.option("--api-scope", required=False, help="The API scope for the base API (optional for API versions >= v0.5.7)") @click.option( "--verify/--no-verify", help="Enable/disable SSL verification", default=True ) @@ -157,6 +192,25 @@ def login_client_credentials( verify: bool, ): log = logging.getLogger(__name__) + + have_aad_tenant_etc = all_or_none([aad_tenant_id, api_scope]) + if have_aad_tenant_etc is None: + click.echo("Either both or none of --aad-tenant-id and --api-scope must be specified") + sys.exit(1) + + # Load metadata from API if required + if not have_aad_tenant_etc: + metadata = ApiClient.get_api_metadata(base_url) + if not metadata: + click.echo("Unable to query API metadata endpoint - please pass --aad-tenant-id and --api-scope") + sys.exit(1) + aad_tenant_id = metadata["aad_tenant_id"] + api_scope = metadata["api_root_scope"] + + # metadata includes /user_impersonation which works for device_code flow but not client credentials + if api_scope.endswith("/user_impersonation"): + api_scope = api_scope[:-1 * len("/user_impersonation")] + # Test the auth succeeds try: log.info("Attempting sign-in...") diff --git a/docs/tre-developers/api-permissions-map.md b/docs/tre-developers/api-permissions-map.md index 627157b605..6b974e8aa0 100644 --- a/docs/tre-developers/api-permissions-map.md +++ b/docs/tre-developers/api-permissions-map.md @@ -56,7 +56,7 @@ These tables specify each endpoint that exists today in TRE API and the permissi | GET /shared-service/{shared\_service\_id} | V | V | | | POST /shared-service | V | X | | | PATCH /shared-service/{shared\_service\_id} | V | X | | -| DELETE /shared-service/{shared\_service\_id} | V | X | | +| DELETE /shared-service/{shared\_service\_id} | V | X | |**** | POST /shared-service/{shared\_service\_id}/invoke-action | V | X | | | GET /shared-service/{shared\_service\_id}/operations | V | X | | | GET /shared-service/{shared\_service\_id}/operations/{operation\_id} | V | X | | @@ -64,3 +64,5 @@ These tables specify each endpoint that exists today in TRE API and the permissi | GET /costs | V | X | X | | GET /workspaces/{workspace\_id}/costs | V | X | V | | GET /health | \- | \- | \- | +| GET /ping | \- | \- | \- | +| GET /.metadata | \- | \- | \- | diff --git a/ui/app/src/components/shared/Footer.tsx b/ui/app/src/components/shared/Footer.tsx index 5a2d5e5512..22e55bdffc 100644 --- a/ui/app/src/components/shared/Footer.tsx +++ b/ui/app/src/components/shared/Footer.tsx @@ -1,15 +1,62 @@ -import React from 'react'; -import { AnimationClassNames, getTheme, mergeStyles } from '@fluentui/react'; +import React, { useEffect, useState } from 'react'; +import { AnimationClassNames, Callout, IconButton, FontWeights, Stack, Text, getTheme, mergeStyles, mergeStyleSets, StackItem } from '@fluentui/react'; +import { HttpMethod, useAuthApiCall } from '../../hooks/useAuthApiCall'; +import { ApiEndpoint } from '../../models/apiEndpoints'; // TODO: // - change text to link // - include any small print + + export const Footer: React.FunctionComponent = () => { + const [showInfo, setShowInfo] = useState(false); + const [apiMetadata, setApiMetadata] = useState({} as any); + const apiCall = useAuthApiCall(); + + useEffect(() => { + const getMeta = async() => { + const result = await apiCall(ApiEndpoint.Metadata, HttpMethod.Get); + setApiMetadata(result); + } + getMeta(); + }, [apiCall]); + return ( -
- Azure Trusted Research Environment -
+
+ + Azure Trusted Research Environment + setShowInfo(!showInfo)} /> + + + {apiMetadata.api_version && showInfo && + setShowInfo(false)} + setInitialFocus + > + + Azure TRE + + + + + + API Version: + {apiMetadata.api_version} + + + + + + } + +
); }; @@ -23,3 +70,31 @@ const contentClass = mergeStyles([ }, AnimationClassNames.scaleUpIn100, ]); + + + +const calloutKeyStyles: React.CSSProperties = { + width: 120 +} + +const calloutValueStyles: React.CSSProperties = { + width: 180 +} + +const styles = mergeStyleSets({ + button: { + width: 130, + }, + callout: { + width: 350, + padding: '20px 24px', + }, + title: { + marginBottom: 12, + fontWeight: FontWeights.semilight + }, + link: { + display: 'block', + marginTop: 20, + } +}); diff --git a/ui/app/src/models/apiEndpoints.ts b/ui/app/src/models/apiEndpoints.ts index af9a8e3533..db8831ebd1 100644 --- a/ui/app/src/models/apiEndpoints.ts +++ b/ui/app/src/models/apiEndpoints.ts @@ -15,5 +15,6 @@ export enum ApiEndpoint { SharedServiceTemplates = 'shared-service-templates', Operations = 'operations', InvokeAction = 'invoke-action', - Costs = 'costs' + Costs = 'costs', + Metadata = ".metadata" }