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
1diff --git a/store_admin/cli/common.py b/store_admin/cli/common.py
2index 754cc35..3033c61 100644
3--- a/store_admin/cli/common.py
4+++ b/store_admin/cli/common.py
5@@ -6,11 +6,14 @@ import click
6 import requests.exceptions
7
8 from store_admin.defaults import get_sso_url
9+from store_admin.logic import accounts
10 from store_admin.logic.login import login as logic_login
11 from store_admin.logic.registration import register as logic_register
12
13 logger = logging.getLogger(__name__)
14
15+one_year_in_secs = 60 * 60 * 24 * 365
16+
17
18 @click.command()
19 @click.argument("proxy_url")
20@@ -102,3 +105,31 @@ def register(url, output, offline, channel, arch):
21 f"Could not write to the specified file path: {output}. Please "
22 "provide a path that is writable"
23 )
24+
25+
26+@click.command(name="token")
27+@click.option(
28+ "--ttl",
29+ type=int,
30+ default=one_year_in_secs, # 1 year (seconds)
31+ help=(
32+ "How long the authentication token should be valid, in seconds "
33+ f"(must be between 60s and 1 year) [default: {one_year_in_secs} (1 year)]"
34+ ),
35+)
36+def export_token(ttl):
37+ """Export an admin token for your IoT App Store.
38+
39+ The resulting token is valid for `ttl` seconds (max. 1 year) and has permissions
40+ "store_admin" and "package_access".
41+
42+ Set the environment variable `STORE_ADMIN_TOKEN=<token>` to skip
43+ interactive login prompts in the `export snaps` and `export store` subcommands
44+ (e.g. for CI/automation).
45+ """
46+ if not (60 <= ttl <= one_year_in_secs):
47+ raise click.BadParameter(
48+ f"ttl must be between 60 and {one_year_in_secs} seconds (1 year)"
49+ )
50+
51+ accounts.export_token(ttl=ttl)
52diff --git a/store_admin/cli/runner.py b/store_admin/cli/runner.py
53index 9778936..03c172e 100644
54--- a/store_admin/cli/runner.py
55+++ b/store_admin/cli/runner.py
56@@ -81,4 +81,5 @@ remove.add_command(overrides.remove_overrides)
57
58 # Commands under export
59 export.add_command(stores.export_store)
60+export.add_command(common.export_token)
61 export.add_command(snaps.export_snap)
62diff --git a/store_admin/cli/snaps.py b/store_admin/cli/snaps.py
63index 9be410c..0afac13 100644
64--- a/store_admin/cli/snaps.py
65+++ b/store_admin/cli/snaps.py
66@@ -76,6 +76,8 @@ def export_snap(snap_names, channels, architectures, store_id, export_dir):
67
68 To export IoT App Store snaps, use the --store option - you will be asked to
69 log in. Only authorized accounts can export not publicly available snaps.
70+
71+ See the `export token` subcommand for non-interactive authentication.
72 """
73 tarball_paths = snaps.export(
74 snap_names, channels, architectures, store_id, export_dir
75diff --git a/store_admin/cli/stores.py b/store_admin/cli/stores.py
76index 47e0c34..053986a 100644
77--- a/store_admin/cli/stores.py
78+++ b/store_admin/cli/stores.py
79@@ -62,5 +62,7 @@ def export_store(store_id, channels, architectures, skip_snaps, account_key_ids)
80 """Export your IoT App Store data for import to the offline store.
81
82 STORE_ID: Store ID of a "device view" app store.
83+
84+ See the `export token` subcommand for non-interactive authentication.
85 """
86 stores.export(store_id, channels, architectures, skip_snaps, account_key_ids)
87diff --git a/store_admin/logic/accounts.py b/store_admin/logic/accounts.py
88index 8eca23d..5440af1 100644
89--- a/store_admin/logic/accounts.py
90+++ b/store_admin/logic/accounts.py
91@@ -8,7 +8,8 @@ from urllib.parse import urljoin
92 import requests
93 from pymacaroons import Macaroon
94
95-from .. import exceptions
96+from .http_client import STORE_ADMIN_ENV_AUTH_KEY, get_developer_session
97+from .. import defaults, exceptions
98
99
100 logger = logging.getLogger(__name__)
101@@ -53,3 +54,20 @@ def get_account_details(dashboard_root, authed_session, auth_token):
102 "validation": "unproven",
103 },
104 }
105+
106+
107+def export_token(ttl):
108+ logger.info("Logging in as store admin...")
109+ # store_admin permission for accessing store data.
110+ # package_access permission for downloading store snaps.
111+ permissions = ["store_admin", "package_access"]
112+
113+ _, auth_token = get_developer_session(
114+ defaults.get_dashboard_url(),
115+ permissions=permissions,
116+ ttl=ttl,
117+ use_env=False,
118+ )
119+ logger.info(f"Exported token with permissions={permissions!r} and ttl={ttl!r}")
120+ logger.info("Usage:")
121+ logger.info(f" export {STORE_ADMIN_ENV_AUTH_KEY}={auth_token}")
122diff --git a/store_admin/logic/http_client.py b/store_admin/logic/http_client.py
123index 067f9c8..110e278 100644
124--- a/store_admin/logic/http_client.py
125+++ b/store_admin/logic/http_client.py
126@@ -2,13 +2,20 @@
127 # Copyright 2022 Canonical Ltd. This software is licensed under the
128 # GNU General Public License version 3 (see the file LICENSE).
129
130+import logging
131+import os
132+
133 import requests
134 from requests.adapters import HTTPAdapter, Retry
135 from craft_store import Auth, StoreClient, endpoints
136
137
138+logger = logging.getLogger(__name__)
139+
140 APP_NAME = "store-admin"
141 USER_AGENT = APP_NAME
142+STORE_ADMIN_ENV_AUTH_KEY = "STORE_ADMIN_TOKEN"
143+CRAFT_STORE_ENV_AUTH_KEY = "CRAFT_STORE_TOKEN"
144 _session = None
145
146
147@@ -34,33 +41,34 @@ def get_session(unique=False):
148 return session
149
150
151-def get_store_client(base_url):
152- store_client = StoreClient(
153- base_url=base_url,
154+def get_developer_session(dashboard_root, permissions=None, ttl=None, use_env=True):
155+ auth_token = None
156+ if use_env and STORE_ADMIN_ENV_AUTH_KEY in os.environ:
157+ # Non-interactive authentication
158+ logger.debug("Using auth token found in env for non-interactive login")
159+ auth_token = os.getenv(STORE_ADMIN_ENV_AUTH_KEY)
160+ os.environ[CRAFT_STORE_ENV_AUTH_KEY] = Auth.encode_credentials(auth_token)
161+
162+ client = StoreClient(
163+ base_url=dashboard_root,
164 storage_base_url="", # No uploads expected.
165 endpoints=endpoints.SNAP_STORE, # SCA.
166 user_agent=USER_AGENT,
167 application_name=APP_NAME,
168+ environment_auth=CRAFT_STORE_ENV_AUTH_KEY if use_env else None,
169 ephemeral=True,
170 )
171- return store_client
172-
173-
174-def get_store_admin_auth_token(store_client, permissions):
175- credentials = store_client.login(
176- # https://dashboard.snapcraft.io/docs/v2/en/tokens.html
177- permissions=permissions,
178- description="store-admin export",
179- ttl=60 * 60, # 1h.
180- )
181- # Decode from craft-store internal credential storage.
182- return Auth.decode_credentials(credentials)
183-
184
185-def get_developer_session(dashboard_root, permissions=None):
186- client = get_store_client(dashboard_root)
187- # User/browser interaction
188- auth_token = get_store_admin_auth_token(client, permissions)
189+ if not auth_token:
190+ # User/browser interaction
191+ logger.debug("No auth token found in env: proceeding with interactive login")
192+ craft_store_auth = client.login(
193+ # https://dashboard.snapcraft.io/docs/v2/en/tokens.html
194+ permissions=permissions,
195+ description="store-admin export",
196+ ttl=ttl or 60 * 60, # 1h.
197+ )
198+ auth_token = Auth.decode_credentials(craft_store_auth)
199
200 session = get_session(unique=True)
201 session.headers["Authorization"] = f"Macaroon {auth_token}"
202diff --git a/store_admin/logic/tests/test_accounts.py b/store_admin/logic/tests/test_accounts.py
203index 72ef9c0..b11bec0 100644
204--- a/store_admin/logic/tests/test_accounts.py
205+++ b/store_admin/logic/tests/test_accounts.py
206@@ -5,10 +5,15 @@
207 import pytest
208 import requests
209 from pymacaroons import Macaroon, MACAROON_V2
210+from unittest import mock
211
212 from store_admin import exceptions
213 from store_admin.logic import accounts
214
215+
216+PATCH_PREFIX = "store_admin.logic.accounts."
217+
218+
219 # extract_external_id() tests
220
221
222@@ -60,3 +65,22 @@ def test_get_account_details_whoami_api_error(
223 assert caplog.records[-1].msg.startswith(
224 "Failed to fetch account details: 503 Server Error"
225 )
226+
227+
228+# export_token() tests
229+
230+
231+def test_export_token(caplog, dashboard_root):
232+ with mock.patch(
233+ PATCH_PREFIX + "get_developer_session",
234+ return_value=(requests.Session(), "mock-token"),
235+ ) as mock_get_developer_session:
236+ accounts.export_token(42)
237+
238+ mock_get_developer_session.assert_called_once_with(
239+ dashboard_root,
240+ permissions=["store_admin", "package_access"],
241+ ttl=42,
242+ use_env=False,
243+ )
244+ assert caplog.records[-1].msg == " export STORE_ADMIN_TOKEN=mock-token"
245diff --git a/store_admin/logic/tests/test_http_client.py b/store_admin/logic/tests/test_http_client.py
246index 4497940..f805dbb 100644
247--- a/store_admin/logic/tests/test_http_client.py
248+++ b/store_admin/logic/tests/test_http_client.py
249@@ -2,6 +2,7 @@
250 # Copyright 2022 Canonical Ltd. This software is licensed under the
251 # GNU General Public License version 3 (see the file LICENSE).
252
253+import os
254 from unittest import mock
255
256 from craft_store import Auth
257@@ -9,9 +10,6 @@ from craft_store import Auth
258 from store_admin.logic import http_client
259
260
261-PATCH_PREFIX = "store_admin.logic.http_client."
262-
263-
264 # get_session() tests
265
266
267@@ -26,18 +24,17 @@ def test_get_session_unique():
268 # get_developer_session() tests
269
270
271+@mock.patch.dict(os.environ, {}, clear=True)
272 def test_get_developer_session(dashboard_root, requests_mock):
273 mock_credentials = Auth.encode_credentials("mock-credentials")
274-
275- with mock.patch(
276- PATCH_PREFIX + "StoreClient.login", return_value=mock_credentials
277+ with mock.patch.object(
278+ http_client.StoreClient, "login", return_value=mock_credentials
279 ) as mock_craft_store_client_login:
280 session, auth_token = http_client.get_developer_session(
281 dashboard_root, permissions=["package_view"]
282 )
283
284 assert auth_token == "mock-credentials"
285-
286 mock_craft_store_client_login.assert_called_once_with(
287 permissions=["package_view"], description="store-admin export", ttl=3600
288 )
289@@ -46,3 +43,54 @@ def test_get_developer_session(dashboard_root, requests_mock):
290 session.get(dashboard_root)
291 [call] = requests_mock.calls
292 assert call.request.headers["Authorization"] == "Macaroon mock-credentials"
293+
294+
295+@mock.patch.dict(os.environ, {}, clear=True)
296+def test_get_developer_session_with_custom_ttl(dashboard_root, requests_mock):
297+ mock_credentials = Auth.encode_credentials("mock-credentials")
298+ with mock.patch.object(
299+ http_client.StoreClient, "login", return_value=mock_credentials
300+ ) as mock_craft_store_client_login:
301+ session, auth_token = http_client.get_developer_session(
302+ dashboard_root, permissions=["package_view"], ttl=42
303+ )
304+
305+ assert auth_token == "mock-credentials"
306+ mock_craft_store_client_login.assert_called_once_with(
307+ permissions=["package_view"], description="store-admin export", ttl=42
308+ )
309+
310+
311+@mock.patch.dict(os.environ, {http_client.STORE_ADMIN_ENV_AUTH_KEY: "mock-credentials"})
312+def test_get_developer_session_with_store_admin_token(dashboard_root, requests_mock):
313+ mock_credentials = Auth.encode_credentials("mock-credentials")
314+ with mock.patch.object(
315+ http_client.StoreClient, "login", return_value=mock_credentials
316+ ) as mock_craft_store_client_login:
317+ session, auth_token = http_client.get_developer_session(
318+ dashboard_root, permissions=["package_view"]
319+ )
320+
321+ assert auth_token == "mock-credentials"
322+ mock_craft_store_client_login.assert_not_called()
323+
324+ requests_mock.add("GET", dashboard_root, json={})
325+ session.get(dashboard_root)
326+ [call] = requests_mock.calls
327+ assert call.request.headers["Authorization"] == "Macaroon mock-credentials"
328+
329+
330+@mock.patch.dict(os.environ, {http_client.STORE_ADMIN_ENV_AUTH_KEY: "ignore-me"})
331+def test_get_developer_session_with_no_env_flag(dashboard_root, requests_mock):
332+ mock_credentials = Auth.encode_credentials("mock-credentials")
333+ with mock.patch.object(
334+ http_client.StoreClient, "login", return_value=mock_credentials
335+ ) as mock_craft_store_client_login:
336+ session, auth_token = http_client.get_developer_session(
337+ dashboard_root, permissions=["package_view"], use_env=False
338+ )
339+
340+ assert auth_token == "mock-credentials"
341+ mock_craft_store_client_login.assert_called_once_with(
342+ permissions=["package_view"], description="store-admin export", ttl=3600
343+ )

Subscribers

People subscribed via source and target branches

to all changes: