Skip to content

Commit

Permalink
Add metadata endpoint (#2794)
Browse files Browse the repository at this point in the history
* Add API metadata endpoint

* Bump API version

* Update tre login to enable querying metadata endpoint

* Add API version to UI

* Update CHANGELOG
  • Loading branch information
stuartleeks authored Oct 27, 2022
1 parent 0648ca5 commit 2d70632
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 25 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.5.6"
__version__ = "0.5.7"
6 changes: 4 additions & 2 deletions api_app/api/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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"])
Expand Down Expand Up @@ -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]
}
)

Expand Down
17 changes: 17 additions & 0 deletions api_app/api/routes/metadata.py
Original file line number Diff line number Diff line change
@@ -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
)
2 changes: 2 additions & 0 deletions api_app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 8 additions & 0 deletions api_app/models/schemas/metadata.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions api_app/resources/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 21 additions & 5 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://microsoft.com/devicelogin> 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/ \
Expand All @@ -28,9 +38,6 @@ tre login device-code \
--api-scope <ROOT_API_SCOPE>
```

This will prompt you to copy a device code and nagivate to <https://microsoft.com/devicelogin> 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://<API_CLIENT_ID>/user_impersonation`

Expand All @@ -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 <SERVICE_PRINICPAL_CLIENT_ID> \
--client-secret <SERVICE_PRINCIPAL_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/ \
Expand All @@ -56,8 +74,6 @@ tre login client-credentials \
--api-scope <ROOT_API_SCOPE>
```

You can specify `--no-verify` to disable SSL cert verification.

NOTE: the api scope is usually of the form `api://<API_CLIENT_ID>/user_impersonation`


Expand Down
12 changes: 12 additions & 0 deletions cli/tre/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 64 additions & 10 deletions cli/tre/commands/login.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sys
import click
import json
import logging
Expand All @@ -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
Expand All @@ -23,14 +43,14 @@ def login():
help='The TRE base URL, e.g. '
+ 'https://<id>.<location>.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)
Expand All @@ -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()
Expand Down Expand Up @@ -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",
Expand All @@ -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
)
Expand All @@ -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...")
Expand Down
4 changes: 3 additions & 1 deletion docs/tre-developers/api-permissions-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ 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 | |
| POST /migrations | V | X | |
| GET /costs | V | X | X |
| GET /workspaces/{workspace\_id}/costs | V | X | V |
| GET /health | \- | \- | \- |
| GET /ping | \- | \- | \- |
| GET /.metadata | \- | \- | \- |
85 changes: 80 additions & 5 deletions ui/app/src/components/shared/Footer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={contentClass}>
Azure Trusted Research Environment
</div>
<div className={contentClass}>
<Stack horizontal>
<StackItem grow={1}>Azure Trusted Research Environment</StackItem>
<StackItem><IconButton style={{color:'#fff'}} iconProps={{ iconName: 'Info' }} id="info" onClick={() => setShowInfo(!showInfo)} /></StackItem>
</Stack>

{apiMetadata.api_version && showInfo &&
<Callout
className={styles.callout}
ariaLabelledBy="info-label"
ariaDescribedBy="info-description"
role="dialog"
gapSpace={0}
target="#info"
onDismiss={() => setShowInfo(false)}
setInitialFocus
>
<Text block variant="xLarge" className={styles.title} id="info-label">
Azure TRE
</Text>
<Text block variant="small" id="version-description">
<Stack>
<Stack.Item>
<Stack horizontal tokens={{ childrenGap: 5 }}>
<Stack.Item style={calloutKeyStyles}>API Version:</Stack.Item>
<Stack.Item style={calloutValueStyles}>{apiMetadata.api_version}</Stack.Item>
</Stack>
</Stack.Item>
</Stack>
</Text>
</Callout>
}

</div>
);
};

Expand All @@ -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,
}
});
Loading

0 comments on commit 2d70632

Please sign in to comment.