Merge ~maxiberta/snapstore-client:env-auth into snapstore-client:master

Proposed by Maximiliano Bertacchini
Status: Merged
Approved by: Maximiliano Bertacchini
Approved revision: 7266727019f5d033932617ab71d4d599829f748e
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~maxiberta/snapstore-client:env-auth
Merge into: snapstore-client:master
Prerequisite: ~maxiberta/snapstore-client:craft-store-2.1.1
Diff against target: 343 lines (+162/-28)
8 files modified
store_admin/cli/common.py (+31/-0)
store_admin/cli/runner.py (+1/-0)
store_admin/cli/snaps.py (+2/-0)
store_admin/cli/stores.py (+2/-0)
store_admin/logic/accounts.py (+19/-1)
store_admin/logic/http_client.py (+28/-20)
store_admin/logic/tests/test_accounts.py (+24/-0)
store_admin/logic/tests/test_http_client.py (+55/-7)
Reviewer Review Type Date Requested Status
Przemysław Suliga Approve
Review via email: mp+425096@code.launchpad.net

Commit message

Add support for exporting and using auth token for non-interactive use cases

If found, the STORE_ADMIN_TOKEN env is used as authentication token, with the same format as the "admin-account.macaroon" included in exported store bundles.

Closes https://warthogs.atlassian.net/browse/SN-682.

To post a comment you must log in.
Revision history for this message
Przemysław Suliga (suligap) wrote :

Thanks! This works but has some issues I think. Left some comments.

~maxiberta/snapstore-client:env-auth updated
1eb3f6c... by Maximiliano Bertacchini

Extend the documentation around export token

16d37a0... by Maximiliano Bertacchini

Add validation to the ttl option in export token

5b57c24... by Maximiliano Bertacchini

Use plain request.Session instead of craft_store.StoreClient for authed sessions

Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

All comments addressed. Thanks!

Revision history for this message
Przemysław Suliga (suligap) wrote :

+1 thanks!

Optional suggestion is to move both newly added export_token() functions to separate modules vs adding them to cli.stores and logic.stores.

review: Approve
~maxiberta/snapstore-client:env-auth updated
fc4b705... by Maximiliano Bertacchini

Move the "export token" subcommand from cli.stores to cli.common

719faa3... by Maximiliano Bertacchini

Move export_token() from logic.stores to logic.accounts

7266727... by Maximiliano Bertacchini

Add some extra logging around exporting token

Revision history for this message
Przemysław Suliga (suligap) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/store_admin/cli/common.py b/store_admin/cli/common.py
index 754cc35..3033c61 100644
--- a/store_admin/cli/common.py
+++ b/store_admin/cli/common.py
@@ -6,11 +6,14 @@ import click
6import requests.exceptions6import requests.exceptions
77
8from store_admin.defaults import get_sso_url8from store_admin.defaults import get_sso_url
9from store_admin.logic import accounts
9from store_admin.logic.login import login as logic_login10from store_admin.logic.login import login as logic_login
10from store_admin.logic.registration import register as logic_register11from store_admin.logic.registration import register as logic_register
1112
12logger = logging.getLogger(__name__)13logger = logging.getLogger(__name__)
1314
15one_year_in_secs = 60 * 60 * 24 * 365
16
1417
15@click.command()18@click.command()
16@click.argument("proxy_url")19@click.argument("proxy_url")
@@ -102,3 +105,31 @@ def register(url, output, offline, channel, arch):
102 f"Could not write to the specified file path: {output}. Please "105 f"Could not write to the specified file path: {output}. Please "
103 "provide a path that is writable"106 "provide a path that is writable"
104 )107 )
108
109
110@click.command(name="token")
111@click.option(
112 "--ttl",
113 type=int,
114 default=one_year_in_secs, # 1 year (seconds)
115 help=(
116 "How long the authentication token should be valid, in seconds "
117 f"(must be between 60s and 1 year) [default: {one_year_in_secs} (1 year)]"
118 ),
119)
120def export_token(ttl):
121 """Export an admin token for your IoT App Store.
122
123 The resulting token is valid for `ttl` seconds (max. 1 year) and has permissions
124 "store_admin" and "package_access".
125
126 Set the environment variable `STORE_ADMIN_TOKEN=<token>` to skip
127 interactive login prompts in the `export snaps` and `export store` subcommands
128 (e.g. for CI/automation).
129 """
130 if not (60 <= ttl <= one_year_in_secs):
131 raise click.BadParameter(
132 f"ttl must be between 60 and {one_year_in_secs} seconds (1 year)"
133 )
134
135 accounts.export_token(ttl=ttl)
diff --git a/store_admin/cli/runner.py b/store_admin/cli/runner.py
index 9778936..03c172e 100644
--- a/store_admin/cli/runner.py
+++ b/store_admin/cli/runner.py
@@ -81,4 +81,5 @@ remove.add_command(overrides.remove_overrides)
8181
82# Commands under export82# Commands under export
83export.add_command(stores.export_store)83export.add_command(stores.export_store)
84export.add_command(common.export_token)
84export.add_command(snaps.export_snap)85export.add_command(snaps.export_snap)
diff --git a/store_admin/cli/snaps.py b/store_admin/cli/snaps.py
index 9be410c..0afac13 100644
--- a/store_admin/cli/snaps.py
+++ b/store_admin/cli/snaps.py
@@ -76,6 +76,8 @@ def export_snap(snap_names, channels, architectures, store_id, export_dir):
7676
77 To export IoT App Store snaps, use the --store option - you will be asked to77 To export IoT App Store snaps, use the --store option - you will be asked to
78 log in. Only authorized accounts can export not publicly available snaps.78 log in. Only authorized accounts can export not publicly available snaps.
79
80 See the `export token` subcommand for non-interactive authentication.
79 """81 """
80 tarball_paths = snaps.export(82 tarball_paths = snaps.export(
81 snap_names, channels, architectures, store_id, export_dir83 snap_names, channels, architectures, store_id, export_dir
diff --git a/store_admin/cli/stores.py b/store_admin/cli/stores.py
index 47e0c34..053986a 100644
--- a/store_admin/cli/stores.py
+++ b/store_admin/cli/stores.py
@@ -62,5 +62,7 @@ def export_store(store_id, channels, architectures, skip_snaps, account_key_ids)
62 """Export your IoT App Store data for import to the offline store.62 """Export your IoT App Store data for import to the offline store.
6363
64 STORE_ID: Store ID of a "device view" app store.64 STORE_ID: Store ID of a "device view" app store.
65
66 See the `export token` subcommand for non-interactive authentication.
65 """67 """
66 stores.export(store_id, channels, architectures, skip_snaps, account_key_ids)68 stores.export(store_id, channels, architectures, skip_snaps, account_key_ids)
diff --git a/store_admin/logic/accounts.py b/store_admin/logic/accounts.py
index 8eca23d..5440af1 100644
--- a/store_admin/logic/accounts.py
+++ b/store_admin/logic/accounts.py
@@ -8,7 +8,8 @@ from urllib.parse import urljoin
8import requests8import requests
9from pymacaroons import Macaroon9from pymacaroons import Macaroon
1010
11from .. import exceptions11from .http_client import STORE_ADMIN_ENV_AUTH_KEY, get_developer_session
12from .. import defaults, exceptions
1213
1314
14logger = logging.getLogger(__name__)15logger = logging.getLogger(__name__)
@@ -53,3 +54,20 @@ def get_account_details(dashboard_root, authed_session, auth_token):
53 "validation": "unproven",54 "validation": "unproven",
54 },55 },
55 }56 }
57
58
59def export_token(ttl):
60 logger.info("Logging in as store admin...")
61 # store_admin permission for accessing store data.
62 # package_access permission for downloading store snaps.
63 permissions = ["store_admin", "package_access"]
64
65 _, auth_token = get_developer_session(
66 defaults.get_dashboard_url(),
67 permissions=permissions,
68 ttl=ttl,
69 use_env=False,
70 )
71 logger.info(f"Exported token with permissions={permissions!r} and ttl={ttl!r}")
72 logger.info("Usage:")
73 logger.info(f" export {STORE_ADMIN_ENV_AUTH_KEY}={auth_token}")
diff --git a/store_admin/logic/http_client.py b/store_admin/logic/http_client.py
index 067f9c8..110e278 100644
--- a/store_admin/logic/http_client.py
+++ b/store_admin/logic/http_client.py
@@ -2,13 +2,20 @@
2# Copyright 2022 Canonical Ltd. This software is licensed under the2# Copyright 2022 Canonical Ltd. This software is licensed under the
3# GNU General Public License version 3 (see the file LICENSE).3# GNU General Public License version 3 (see the file LICENSE).
44
5import logging
6import os
7
5import requests8import requests
6from requests.adapters import HTTPAdapter, Retry9from requests.adapters import HTTPAdapter, Retry
7from craft_store import Auth, StoreClient, endpoints10from craft_store import Auth, StoreClient, endpoints
811
912
13logger = logging.getLogger(__name__)
14
10APP_NAME = "store-admin"15APP_NAME = "store-admin"
11USER_AGENT = APP_NAME16USER_AGENT = APP_NAME
17STORE_ADMIN_ENV_AUTH_KEY = "STORE_ADMIN_TOKEN"
18CRAFT_STORE_ENV_AUTH_KEY = "CRAFT_STORE_TOKEN"
12_session = None19_session = None
1320
1421
@@ -34,33 +41,34 @@ def get_session(unique=False):
34 return session41 return session
3542
3643
37def get_store_client(base_url):44def get_developer_session(dashboard_root, permissions=None, ttl=None, use_env=True):
38 store_client = StoreClient(45 auth_token = None
39 base_url=base_url,46 if use_env and STORE_ADMIN_ENV_AUTH_KEY in os.environ:
47 # Non-interactive authentication
48 logger.debug("Using auth token found in env for non-interactive login")
49 auth_token = os.getenv(STORE_ADMIN_ENV_AUTH_KEY)
50 os.environ[CRAFT_STORE_ENV_AUTH_KEY] = Auth.encode_credentials(auth_token)
51
52 client = StoreClient(
53 base_url=dashboard_root,
40 storage_base_url="", # No uploads expected.54 storage_base_url="", # No uploads expected.
41 endpoints=endpoints.SNAP_STORE, # SCA.55 endpoints=endpoints.SNAP_STORE, # SCA.
42 user_agent=USER_AGENT,56 user_agent=USER_AGENT,
43 application_name=APP_NAME,57 application_name=APP_NAME,
58 environment_auth=CRAFT_STORE_ENV_AUTH_KEY if use_env else None,
44 ephemeral=True,59 ephemeral=True,
45 )60 )
46 return store_client
47
48
49def get_store_admin_auth_token(store_client, permissions):
50 credentials = store_client.login(
51 # https://dashboard.snapcraft.io/docs/v2/en/tokens.html
52 permissions=permissions,
53 description="store-admin export",
54 ttl=60 * 60, # 1h.
55 )
56 # Decode from craft-store internal credential storage.
57 return Auth.decode_credentials(credentials)
58
5961
60def get_developer_session(dashboard_root, permissions=None):62 if not auth_token:
61 client = get_store_client(dashboard_root)63 # User/browser interaction
62 # User/browser interaction64 logger.debug("No auth token found in env: proceeding with interactive login")
63 auth_token = get_store_admin_auth_token(client, permissions)65 craft_store_auth = client.login(
66 # https://dashboard.snapcraft.io/docs/v2/en/tokens.html
67 permissions=permissions,
68 description="store-admin export",
69 ttl=ttl or 60 * 60, # 1h.
70 )
71 auth_token = Auth.decode_credentials(craft_store_auth)
6472
65 session = get_session(unique=True)73 session = get_session(unique=True)
66 session.headers["Authorization"] = f"Macaroon {auth_token}"74 session.headers["Authorization"] = f"Macaroon {auth_token}"
diff --git a/store_admin/logic/tests/test_accounts.py b/store_admin/logic/tests/test_accounts.py
index 72ef9c0..b11bec0 100644
--- a/store_admin/logic/tests/test_accounts.py
+++ b/store_admin/logic/tests/test_accounts.py
@@ -5,10 +5,15 @@
5import pytest5import pytest
6import requests6import requests
7from pymacaroons import Macaroon, MACAROON_V27from pymacaroons import Macaroon, MACAROON_V2
8from unittest import mock
89
9from store_admin import exceptions10from store_admin import exceptions
10from store_admin.logic import accounts11from store_admin.logic import accounts
1112
13
14PATCH_PREFIX = "store_admin.logic.accounts."
15
16
12# extract_external_id() tests17# extract_external_id() tests
1318
1419
@@ -60,3 +65,22 @@ def test_get_account_details_whoami_api_error(
60 assert caplog.records[-1].msg.startswith(65 assert caplog.records[-1].msg.startswith(
61 "Failed to fetch account details: 503 Server Error"66 "Failed to fetch account details: 503 Server Error"
62 )67 )
68
69
70# export_token() tests
71
72
73def test_export_token(caplog, dashboard_root):
74 with mock.patch(
75 PATCH_PREFIX + "get_developer_session",
76 return_value=(requests.Session(), "mock-token"),
77 ) as mock_get_developer_session:
78 accounts.export_token(42)
79
80 mock_get_developer_session.assert_called_once_with(
81 dashboard_root,
82 permissions=["store_admin", "package_access"],
83 ttl=42,
84 use_env=False,
85 )
86 assert caplog.records[-1].msg == " export STORE_ADMIN_TOKEN=mock-token"
diff --git a/store_admin/logic/tests/test_http_client.py b/store_admin/logic/tests/test_http_client.py
index 4497940..f805dbb 100644
--- a/store_admin/logic/tests/test_http_client.py
+++ b/store_admin/logic/tests/test_http_client.py
@@ -2,6 +2,7 @@
2# Copyright 2022 Canonical Ltd. This software is licensed under the2# Copyright 2022 Canonical Ltd. This software is licensed under the
3# GNU General Public License version 3 (see the file LICENSE).3# GNU General Public License version 3 (see the file LICENSE).
44
5import os
5from unittest import mock6from unittest import mock
67
7from craft_store import Auth8from craft_store import Auth
@@ -9,9 +10,6 @@ from craft_store import Auth
9from store_admin.logic import http_client10from store_admin.logic import http_client
1011
1112
12PATCH_PREFIX = "store_admin.logic.http_client."
13
14
15# get_session() tests13# get_session() tests
1614
1715
@@ -26,18 +24,17 @@ def test_get_session_unique():
26# get_developer_session() tests24# get_developer_session() tests
2725
2826
27@mock.patch.dict(os.environ, {}, clear=True)
29def test_get_developer_session(dashboard_root, requests_mock):28def test_get_developer_session(dashboard_root, requests_mock):
30 mock_credentials = Auth.encode_credentials("mock-credentials")29 mock_credentials = Auth.encode_credentials("mock-credentials")
3130 with mock.patch.object(
32 with mock.patch(31 http_client.StoreClient, "login", return_value=mock_credentials
33 PATCH_PREFIX + "StoreClient.login", return_value=mock_credentials
34 ) as mock_craft_store_client_login:32 ) as mock_craft_store_client_login:
35 session, auth_token = http_client.get_developer_session(33 session, auth_token = http_client.get_developer_session(
36 dashboard_root, permissions=["package_view"]34 dashboard_root, permissions=["package_view"]
37 )35 )
3836
39 assert auth_token == "mock-credentials"37 assert auth_token == "mock-credentials"
40
41 mock_craft_store_client_login.assert_called_once_with(38 mock_craft_store_client_login.assert_called_once_with(
42 permissions=["package_view"], description="store-admin export", ttl=360039 permissions=["package_view"], description="store-admin export", ttl=3600
43 )40 )
@@ -46,3 +43,54 @@ def test_get_developer_session(dashboard_root, requests_mock):
46 session.get(dashboard_root)43 session.get(dashboard_root)
47 [call] = requests_mock.calls44 [call] = requests_mock.calls
48 assert call.request.headers["Authorization"] == "Macaroon mock-credentials"45 assert call.request.headers["Authorization"] == "Macaroon mock-credentials"
46
47
48@mock.patch.dict(os.environ, {}, clear=True)
49def test_get_developer_session_with_custom_ttl(dashboard_root, requests_mock):
50 mock_credentials = Auth.encode_credentials("mock-credentials")
51 with mock.patch.object(
52 http_client.StoreClient, "login", return_value=mock_credentials
53 ) as mock_craft_store_client_login:
54 session, auth_token = http_client.get_developer_session(
55 dashboard_root, permissions=["package_view"], ttl=42
56 )
57
58 assert auth_token == "mock-credentials"
59 mock_craft_store_client_login.assert_called_once_with(
60 permissions=["package_view"], description="store-admin export", ttl=42
61 )
62
63
64@mock.patch.dict(os.environ, {http_client.STORE_ADMIN_ENV_AUTH_KEY: "mock-credentials"})
65def test_get_developer_session_with_store_admin_token(dashboard_root, requests_mock):
66 mock_credentials = Auth.encode_credentials("mock-credentials")
67 with mock.patch.object(
68 http_client.StoreClient, "login", return_value=mock_credentials
69 ) as mock_craft_store_client_login:
70 session, auth_token = http_client.get_developer_session(
71 dashboard_root, permissions=["package_view"]
72 )
73
74 assert auth_token == "mock-credentials"
75 mock_craft_store_client_login.assert_not_called()
76
77 requests_mock.add("GET", dashboard_root, json={})
78 session.get(dashboard_root)
79 [call] = requests_mock.calls
80 assert call.request.headers["Authorization"] == "Macaroon mock-credentials"
81
82
83@mock.patch.dict(os.environ, {http_client.STORE_ADMIN_ENV_AUTH_KEY: "ignore-me"})
84def test_get_developer_session_with_no_env_flag(dashboard_root, requests_mock):
85 mock_credentials = Auth.encode_credentials("mock-credentials")
86 with mock.patch.object(
87 http_client.StoreClient, "login", return_value=mock_credentials
88 ) as mock_craft_store_client_login:
89 session, auth_token = http_client.get_developer_session(
90 dashboard_root, permissions=["package_view"], use_env=False
91 )
92
93 assert auth_token == "mock-credentials"
94 mock_craft_store_client_login.assert_called_once_with(
95 permissions=["package_view"], description="store-admin export", ttl=3600
96 )

Subscribers

People subscribed via source and target branches

to all changes: