Repository: PrefectHQ/prefect

Issue:
More Comprehensive Auth CLI commands
## Use Case
Currently `prefect auth` only contains a `login` command and we need to support all the new hooks in #1410 



## Solution
`prefect auth logout` (logout tenant, delete token) w/ confirm prompt
`prefect auth list` (list available tenants)
`prefect auth switch` (switch tenant)
`prefect auth create-token` (create token scoped to a role)
`prefect auth revoke-token` (revoke an API token)

login w/ user token -> verify user

show list of tenants -> choose tenant


## Alternatives
N/A


Discussion:
RFC on command names but I definitely want to scope them under the `auth` group in the CLI
login w/ user token (verified via `authInfo` GQL call) -> log into default membership

Resolving patch:
diff --git a/src/prefect/cli/auth.py b/src/prefect/cli/auth.py
--- a/src/prefect/cli/auth.py
+++ b/src/prefect/cli/auth.py
@@ -1,9 +1,17 @@
 import click
+from click.exceptions import Abort
+from tabulate import tabulate
 
 from prefect import Client, config
 from prefect.utilities.exceptions import AuthorizationError, ClientError
 
 
+def check_override_auth_token():
+    if config.cloud.get("auth_token"):
+        click.secho("Auth token already present in config.", fg="red")
+        raise Abort
+
+
 @click.group(hidden=True)
 def auth():
     """
@@ -15,11 +23,34 @@ def auth():
 
     \b
     Arguments:
-        login       Login to Prefect Cloud
+        login           Log in to Prefect Cloud
+        logout          Log out of Prefect Cloud
+        list-tenants    List your available tenants
+        switch-tenants  Switch to a different tenant
+        create-token    Create an API token
 
     \b
     Examples:
         $ prefect auth login --token MY_TOKEN
+        Log in successful
+
+    \b
+        $ prefect auth logout
+        Logged out from tenant TENANT_ID
+
+    \b
+        $ prefect auth list-tenants
+        NAME                        SLUG                        ID
+        Test Person                 test-person                 816sghf2-4d51-4338-a333-1771gns7614d
+        test@prefect.io's Account   test-prefect-io-s-account   1971hs9f-e8ha-4a33-8c33-64512gds86g1  *
+
+    \b
+        $ prefect auth switch-tenants --slug test-person
+        Tenant switched
+
+    \b
+        $ prefect auth create-token -n MyToken -r RUNNER
+        ...token output...
     """
     pass
 
@@ -30,25 +61,30 @@ def auth():
 )
 def login(token):
     """
-    Login to Prefect Cloud with an api token to use for Cloud communication.
+    Log in to Prefect Cloud with an api token to use for Cloud communication.
 
     \b
     Options:
         --token, -t         TEXT    A Prefect Cloud api token  [required]
     """
-
-    if config.cloud.get("auth_token"):
-        click.confirm(
-            "Prefect Cloud API token already set in config. Do you want to override?",
-            default=True,
-            abort=True,
-        )
+    check_override_auth_token()
 
     client = Client(api_token=token)
 
     # Verify login obtained a valid api token
     try:
-        client.graphql(query={"query": {"tenant": "id"}})
+        output = client.graphql(
+            query={"query": {"user": {"default_membership": "tenant_id"}}}
+        )
+
+        # Log into default membership
+        success_login = client.login_to_tenant(
+            tenant_id=output.data.user[0].default_membership.tenant_id
+        )
+
+        if not success_login:
+            raise AuthorizationError
+
     except AuthorizationError:
         click.secho(
             "Error attempting to use Prefect API token {}".format(token), fg="red"
@@ -61,4 +97,179 @@ def login(token):
     # save token
     client.save_api_token()
 
-    click.secho("Login successful", fg="green")
+    click.secho("Log in successful", fg="green")
+
+
+@auth.command(hidden=True)
+def logout():
+    """
+    Log out of Prefect Cloud
+    """
+    check_override_auth_token()
+
+    click.confirm(
+        "Are you sure you want to log out of Prefect Cloud?", default=False, abort=True
+    )
+
+    client = Client()
+    tenant_id = client._active_tenant_id
+
+    if not tenant_id:
+        click.secho("No tenant currently active", fg="red")
+        return
+
+    client.logout_from_tenant()
+
+    click.secho("Logged out from tenant {}".format(tenant_id), fg="green")
+
+
+@auth.command(hidden=True)
+def list_tenants():
+    """
+    List available tenants
+    """
+    check_override_auth_token()
+
+    client = Client()
+
+    tenants = client.get_available_tenants()
+    active_tenant_id = client._active_tenant_id
+
+    output = []
+    for item in tenants:
+        active = None
+        if item.id == active_tenant_id:
+            active = "*"
+        output.append([item.name, item.slug, item.id, active])
+
+    click.echo(
+        tabulate(
+            output,
+            headers=["NAME", "SLUG", "ID", ""],
+            tablefmt="plain",
+            numalign="left",
+            stralign="left",
+        )
+    )
+
+
+@auth.command(hidden=True)
+@click.option(
+    "--id", "-i", required=False, help="A Prefect Cloud tenant id.", hidden=True
+)
+@click.option(
+    "--slug", "-s", required=False, help="A Prefect Cloud tenant slug.", hidden=True
+)
+def switch_tenants(id, slug):
+    """
+    Switch active tenant
+
+    \b
+    Options:
+        --id, -i    TEXT    A Prefect Cloud tenant id
+        --slug, -s  TEXT    A Prefect Cloud tenant slug
+    """
+    check_override_auth_token()
+
+    client = Client()
+
+    login_success = client.login_to_tenant(tenant_slug=slug, tenant_id=id)
+    if not login_success:
+        click.secho("Unable to switch tenant", fg="red")
+        return
+
+    click.secho("Tenant switched", fg="green")
+
+
+@auth.command(hidden=True)
+@click.option("--name", "-n", required=True, help="A token name.", hidden=True)
+@click.option("--role", "-r", required=True, help="A token role.", hidden=True)
+def create_token(name, role):
+    """
+    Create a Prefect Cloud API token.
+
+    For more info on API tokens visit https://docs.prefect.io/cloud/cloud_concepts/api.html
+
+    \b
+    Options:
+        --name, -n      TEXT    A name to give the generated token
+        --role, -r      TEXT    A role for the token
+    """
+    check_override_auth_token()
+
+    client = Client()
+
+    output = client.graphql(
+        query={
+            "mutation($input: createAPITokenInput!)": {
+                "createAPIToken(input: $input)": {"token"}
+            }
+        },
+        variables=dict(input=dict(name=name, role=role)),
+    )
+
+    if not output.get("data", None):
+        click.secho("Issue creating API token", fg="red")
+        return
+
+    click.echo(output.data.createAPIToken.token)
+
+
+@auth.command(hidden=True)
+def list_tokens():
+    """
+    List your available Prefect Cloud API tokens.
+    """
+    check_override_auth_token()
+
+    client = Client()
+
+    output = client.graphql(query={"query": {"api_token": {"id", "name"}}})
+
+    if not output.get("data", None):
+        click.secho("Unable to list API tokens", fg="red")
+        return
+
+    tokens = []
+    for item in output.data.api_token:
+        tokens.append([item.name, item.id])
+
+    click.echo(
+        tabulate(
+            tokens,
+            headers=["NAME", "ID"],
+            tablefmt="plain",
+            numalign="left",
+            stralign="left",
+        )
+    )
+
+
+@auth.command(hidden=True)
+@click.option("--id", "-i", required=True, help="A token ID.", hidden=True)
+def revoke_token(id):
+    """
+    Revote a Prefect Cloud API token
+
+    \b
+    Options:
+        --id, -i    TEXT    The id of a token to revoke
+    """
+    check_override_auth_token()
+
+    client = Client()
+
+    output = client.graphql(
+        query={
+            "mutation($input: deleteAPITokenInput!)": {
+                "deleteAPIToken(input: $input)": {"success"}
+            }
+        },
+        variables=dict(input=dict(tokenId=id)),
+    )
+
+    if not output.get("data", None) or not output.data.deleteAPIToken.success:
+        click.secho("Unable to revoke token with ID {}".format(id), fg="red")
+        return
+
+    click.secho("Token successfully revoked", fg="green")


Test patch:
diff --git a/tests/cli/test_auth.py b/tests/cli/test_auth.py
--- a/tests/cli/test_auth.py
+++ b/tests/cli/test_auth.py
@@ -1,10 +1,9 @@
-import tempfile
+import uuid
 from unittest.mock import MagicMock
 
 import click
-import requests
-import toml
 from click.testing import CliRunner
+import pytest
 
 import prefect
 from prefect.cli.auth import auth
@@ -25,48 +24,193 @@ def test_auth_help():
     assert "Handle Prefect Cloud authorization." in result.output
 
 
-def test_auth_login(patch_post):
-    patch_post(dict(data=dict(tenant="id")))
+def test_auth_login(patch_post, monkeypatch):
+    patch_post(
+        dict(
+            data=dict(
+                tenant="id",
+                user=[dict(default_membership=dict(tenant_id=str(uuid.uuid4())))],
+            )
+        )
+    )
+
+    client = MagicMock()
+    client.return_value.login_to_tenant = MagicMock(return_value=True)
+    monkeypatch.setattr("prefect.cli.auth.Client", client)
 
-    with set_temporary_config(
-        {"cloud.graphql": "http://my-cloud.foo", "cloud.auth_token": "secret_token"}
-    ):
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
         runner = CliRunner()
         result = runner.invoke(auth, ["login", "--token", "test"])
         assert result.exit_code == 0
-        assert "Login successful" in result.output
 
 
 def test_auth_login_client_error(patch_post):
     patch_post(dict(errors=dict(error="bad")))
 
-    with set_temporary_config(
-        {"cloud.graphql": "http://my-cloud.foo", "cloud.auth_token": "secret_token"}
-    ):
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
         runner = CliRunner()
         result = runner.invoke(auth, ["login", "--token", "test"])
         assert result.exit_code == 0
         assert "Error attempting to communicate with Prefect Cloud" in result.output
 
 
-def test_auth_login_confirm(patch_post):
-    patch_post(dict(data=dict(hello="hi")))
+def test_auth_logout_after_login(patch_post, monkeypatch):
+    patch_post(
+        dict(
+            data=dict(
+                tenant="id",
+                user=[dict(default_membership=dict(tenant_id=str(uuid.uuid4())))],
+            )
+        )
+    )
+
+    client = MagicMock()
+    client.return_value.login_to_tenant = MagicMock(return_value=True)
+    monkeypatch.setattr("prefect.cli.auth.Client", client)
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+
+        result = runner.invoke(auth, ["login", "--token", "test"])
+        assert result.exit_code == 0
+
+        result = runner.invoke(auth, ["logout"], input="Y")
+        assert result.exit_code == 0
+
+
+def test_auth_logout_not_confirm(patch_post):
+    patch_post(dict(data=dict(tenant="id")))
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["logout"], input="N")
+        assert result.exit_code == 1
+
+
+def test_auth_logout_no_active_tenant(patch_post):
+    patch_post(dict(data=dict(tenant="id")))
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["logout"], input="Y")
+        assert result.exit_code == 0
+        assert "No tenant currently active" in result.output
+
+
+def test_list_tenants(patch_post):
+    patch_post(
+        dict(
+            data=dict(
+                tenant=[{"id": "id", "slug": "slug", "name": "name"}],
+                switchTenant={
+                    "accessToken": "accessToken",
+                    "expiresIn": "expiresIn",
+                    "refreshToken": "refreshToken",
+                },
+            )
+        )
+    )
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["list-tenants"])
+        assert result.exit_code == 0
+        assert "id" in result.output
+        assert "slug" in result.output
+        assert "name" in result.output
+
+
+def test_switch_tenants(monkeypatch):
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        monkeypatch.setattr("prefect.cli.auth.Client", MagicMock())
+
+        runner = CliRunner()
+        result = runner.invoke(auth, ["switch-tenants", "--slug", "slug"])
+        assert result.exit_code == 0
+        assert "Tenant switched" in result.output
+
+
+def test_switch_tenants(monkeypatch):
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        client = MagicMock()
+        client.return_value.login_to_tenant = MagicMock(return_value=False)
+        monkeypatch.setattr("prefect.cli.auth.Client", client)
 
-    with set_temporary_config(
-        {"cloud.graphql": "http://my-cloud.foo", "cloud.auth_token": "secret_token"}
-    ):
         runner = CliRunner()
-        result = runner.invoke(auth, ["login", "--token", "test"], input="Y")
+        result = runner.invoke(auth, ["switch-tenants", "--slug", "slug"])
         assert result.exit_code == 0
-        assert "Login successful" in result.output
+        assert "Unable to switch tenant" in result.output
+
+
+def test_create_token(patch_post):
+    patch_post(dict(data=dict(createAPIToken={"token": "token"})))
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["create-token", "-n", "name", "-r", "role"])
+        assert result.exit_code == 0
+        assert "token" in result.output
+
+
+def test_create_token_fails(patch_post):
+    patch_post(dict())
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["create-token", "-n", "name", "-r", "role"])
+        assert result.exit_code == 0
+        assert "Issue creating API token" in result.output
+
+
+def test_list_tokens(patch_post):
+    patch_post(dict(data=dict(api_token=[{"id": "id", "name": "name"}])))
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["list-tokens"])
+        assert result.exit_code == 0
+        assert "id" in result.output
+        assert "name" in result.output
+
+
+def test_list_tokens_fails(patch_post):
+    patch_post(dict())
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["list-tokens"])
+        assert result.exit_code == 0
+        assert "Unable to list API tokens" in result.output
+
+
+def test_revoke_token(patch_post):
+    patch_post(dict(data=dict(deleteAPIToken={"success": True})))
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["revoke-token", "--id", "id"])
+        assert result.exit_code == 0
+        assert "Token successfully revoked" in result.output
+
+
+def test_revoke_token_fails(patch_post):
+    patch_post(dict())
+
+    with set_temporary_config({"cloud.graphql": "http://my-cloud.foo"}):
+        runner = CliRunner()
+        result = runner.invoke(auth, ["revoke-token", "--id", "id"])
+        assert result.exit_code == 0
+        assert "Unable to revoke token with ID id" in result.output
+
 
+def test_check_override_function():
+    with set_temporary_config({"cloud.auth_token": "TOKEN"}):
+        with pytest.raises(click.exceptions.Abort):
+            prefect.cli.auth.check_override_auth_token()
 
-def test_auth_login_not_confirm(patch_post):
-    patch_post(dict(data=dict(hello="hi")))
 
-    with set_temporary_config(
-        {"cloud.graphql": "http://my-cloud.foo", "cloud.auth_token": "secret_token"}
-    ):
+def test_override_functions_on_commands():
+    with set_temporary_config({"cloud.auth_token": "TOKEN"}):
         runner = CliRunner()
-        result = runner.invoke(auth, ["login", "--token", "test"], input="N")
+        result = runner.invoke(auth, ["revoke-token", "--id", "id"])
         assert result.exit_code == 1