Skip to content

Commit 7dc6b1b

Browse files
committed
TPT-4175: cli: Update interactive config token access check
1 parent c738a61 commit 7dc6b1b

4 files changed

Lines changed: 109 additions & 69 deletions

File tree

linodecli/configuration/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
# Private methods need to be imported explicitly
66
from .auth import *
77
from .auth import (
8-
_check_full_access,
98
_do_get_request,
109
_get_token_terminal,
1110
_get_token_web,

linodecli/configuration/auth.py

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -152,42 +152,6 @@ def _do_request(
152152
return result.json()
153153

154154

155-
def _check_full_access(base_url: str, token: str) -> bool:
156-
"""
157-
Checks whether the given token has full-access permissions.
158-
159-
:param base_url: The base URL for the API.
160-
:type base_url: str
161-
:param token: The access token to use.
162-
:type token :str
163-
164-
:returns: Whether the user has full access.
165-
:rtype: bool
166-
"""
167-
headers = {
168-
"Authorization": f"Bearer {token}",
169-
"Content-Type": "application/json",
170-
}
171-
172-
result = requests.get(
173-
base_url + "/profile/grants",
174-
headers=headers,
175-
timeout=120,
176-
verify=API_CA_PATH,
177-
)
178-
179-
# IAM-enrolled users receive a 403 from /profile/grants since that
180-
# endpoint is not accessible to them. Treat 403 as a valid response
181-
# (i.e. not full access) rather than a fatal error.
182-
_handle_response_status(
183-
result,
184-
exit_on_error=True,
185-
status_validator=lambda status: status == 403,
186-
)
187-
188-
return result.status_code == 204
189-
190-
191155
def _username_for_token(base_url: str, token: str) -> str:
192156
"""
193157
A helper function that returns the username associated with a token by

linodecli/configuration/config.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from linodecli.exit_codes import ExitCodes
1111

1212
from .auth import (
13-
_check_full_access,
1413
_do_get_request,
1514
_get_token_terminal,
1615
_get_token_web,
@@ -430,18 +429,33 @@ def configure(
430429
[i["id"] for i in _do_get_request(self.base_url, "/images")["data"]]
431430
)
432431

433-
is_full_access = _check_full_access(self.base_url, token)
434-
435432
auth_users = []
436433

437-
if is_full_access:
434+
# Use /profile/sshkeys as a lightweight capability check. If the token
435+
# lacks profile access (401) or the user is IAM-enrolled (403), skip
436+
# the authorized_users prompt entirely. Capture the status code via a
437+
# closure passed as status_validator so no new API surface is needed.
438+
sshkeys_status_code = None
439+
440+
def _capture_sshkeys_status(status: int) -> bool:
441+
nonlocal sshkeys_status_code
442+
sshkeys_status_code = status
443+
return True # suppress _handle_response_status error output
444+
445+
_do_get_request(
446+
self.base_url,
447+
"/profile/sshkeys",
448+
token=token,
449+
exit_on_error=False,
450+
status_validator=_capture_sshkeys_status,
451+
)
452+
453+
if sshkeys_status_code == 200:
438454
users = _do_get_request(
439455
self.base_url,
440456
"/account/users",
441457
token=token,
442-
# Allow 401 responses so tokens without
443-
# account perms can be configured
444-
status_validator=lambda status: status == 401,
458+
status_validator=lambda status: status in (401, 403),
445459
)
446460

447461
if "data" in users:

tests/unit/test_configuration.py

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from linodecli import configuration
1717
from linodecli.configuration import (
1818
_bool_input,
19-
_check_full_access,
2019
_default_text_input,
2120
_default_thing_input,
2221
)
@@ -299,7 +298,7 @@ def mock_input(prompt):
299298
requests_mock.Mocker() as m,
300299
):
301300
m.get(f"{self.base_url}/profile", json={"username": "cli-dev"})
302-
m.get(f"{self.base_url}/profile/grants", status_code=204)
301+
m.get(f"{self.base_url}/profile/sshkeys", json={"data": []})
303302
m.get(
304303
f"{self.base_url}/regions",
305304
json={"data": [{"id": "test-region"}]},
@@ -350,7 +349,7 @@ def mock_input(prompt):
350349
requests_mock.Mocker() as m,
351350
):
352351
m.get(f"{self.base_url}/profile", json={"username": "cli-dev"})
353-
m.get(f"{self.base_url}/profile/grants", status_code=204)
352+
m.get(f"{self.base_url}/profile/sshkeys", json={"data": []})
354353
m.get(
355354
f"{self.base_url}/regions",
356355
json={"data": [{"id": "test-region"}]},
@@ -679,39 +678,103 @@ def test_custom_config_path(self, monkeypatch, tmp_path):
679678
assert expected_configs[i] == configs[i]
680679

681680

682-
class TestCheckFullAccess:
681+
class TestConfigureAuthorizedUsers:
683682
"""
684-
Unit tests for _check_full_access
683+
Unit tests for the authorized_users gate in CLIConfig.configure(),
684+
which uses GET /profile/sshkeys as a capability check instead of
685+
GET /profile/grants (which is inaccessible to IAM-enrolled users).
685686
"""
686687

687688
base_url = "https://linode-test.com"
688689
test_token = "cli-dev-token"
689690

690-
def test_full_access_returns_true(self):
691+
def _base_mocks(self, m, *, sshkeys_status=200, users_json=None):
692+
"""Register the common mocks needed for a configure() call."""
693+
m.get(f"{self.base_url}/profile", json={"username": "cli-dev"})
694+
m.get(
695+
f"{self.base_url}/profile/sshkeys",
696+
status_code=sshkeys_status,
697+
json={"data": []},
698+
)
699+
m.get(f"{self.base_url}/regions", json={"data": [{"id": "us-east"}]})
700+
m.get(
701+
f"{self.base_url}/linode/types",
702+
json={"data": [{"id": "g6-nanode-1"}]},
703+
)
704+
m.get(
705+
f"{self.base_url}/images",
706+
json={"data": [{"id": "linode/debian12"}]},
707+
)
708+
if users_json is not None:
709+
m.get(f"{self.base_url}/account/users", json=users_json)
710+
711+
def test_sshkeys_success_populates_authorized_users(self):
691712
"""
692-
204 No Content means the token has full (unrestricted) access.
713+
When /profile/sshkeys succeeds, /account/users is fetched and
714+
authorized_users is offered.
693715
"""
694-
with requests_mock.Mocker() as m:
695-
m.get(f"{self.base_url}/profile/grants", status_code=204)
696-
assert _check_full_access(self.base_url, self.test_token) is True
716+
conf = configuration.CLIConfig(self.base_url, skip_config=True)
717+
answers = iter(["1", "1", "1", "1", "n", "n"])
697718

698-
def test_restricted_access_returns_false(self):
719+
with (
720+
patch("linodecli.configuration.open", mock_open()),
721+
patch("os.chmod", lambda a, b: None),
722+
patch("builtins.input", lambda _: next(answers)),
723+
contextlib.redirect_stdout(io.StringIO()),
724+
patch("linodecli.configuration._check_browsers", lambda: False),
725+
patch.dict(os.environ, {"LINODE_CLI_TOKEN": self.test_token}),
726+
requests_mock.Mocker() as m,
727+
):
728+
self._base_mocks(
729+
m,
730+
users_json={
731+
"data": [{"username": "cli-dev", "ssh_keys": ["key1"]}]
732+
},
733+
)
734+
conf.configure()
735+
736+
assert conf.get_value("authorized_users") == "cli-dev"
737+
738+
def test_sshkeys_401_skips_authorized_users(self):
699739
"""
700-
200 with a grants body means the token has restricted access.
740+
When /profile/sshkeys returns 401 (restricted token), the
741+
authorized_users prompt is skipped.
701742
"""
702-
with requests_mock.Mocker() as m:
703-
m.get(
704-
f"{self.base_url}/profile/grants",
705-
status_code=200,
706-
json={"linode": []},
707-
)
708-
assert _check_full_access(self.base_url, self.test_token) is False
743+
conf = configuration.CLIConfig(self.base_url, skip_config=True)
744+
answers = iter(["1", "1", "1", "n", "n"])
709745

710-
def test_iam_user_403_returns_false(self):
746+
with (
747+
patch("linodecli.configuration.open", mock_open()),
748+
patch("os.chmod", lambda a, b: None),
749+
patch("builtins.input", lambda _: next(answers)),
750+
contextlib.redirect_stdout(io.StringIO()),
751+
patch("linodecli.configuration._check_browsers", lambda: False),
752+
patch.dict(os.environ, {"LINODE_CLI_TOKEN": self.test_token}),
753+
requests_mock.Mocker() as m,
754+
):
755+
self._base_mocks(m, sshkeys_status=401)
756+
conf.configure()
757+
758+
assert conf.get_value("authorized_users") is None
759+
760+
def test_sshkeys_403_iam_skips_authorized_users(self):
711761
"""
712-
IAM-enrolled users receive a 403 from /profile/grants.
713-
This should be treated as "not full access" rather than a fatal error.
762+
When /profile/sshkeys returns 403 (IAM-enrolled user), the
763+
authorized_users prompt is skipped.
714764
"""
715-
with requests_mock.Mocker() as m:
716-
m.get(f"{self.base_url}/profile/grants", status_code=403)
717-
assert _check_full_access(self.base_url, self.test_token) is False
765+
conf = configuration.CLIConfig(self.base_url, skip_config=True)
766+
answers = iter(["1", "1", "1", "n", "n"])
767+
768+
with (
769+
patch("linodecli.configuration.open", mock_open()),
770+
patch("os.chmod", lambda a, b: None),
771+
patch("builtins.input", lambda _: next(answers)),
772+
contextlib.redirect_stdout(io.StringIO()),
773+
patch("linodecli.configuration._check_browsers", lambda: False),
774+
patch.dict(os.environ, {"LINODE_CLI_TOKEN": self.test_token}),
775+
requests_mock.Mocker() as m,
776+
):
777+
self._base_mocks(m, sshkeys_status=403)
778+
conf.configure()
779+
780+
assert conf.get_value("authorized_users") is None

0 commit comments

Comments
 (0)