Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion linodecli/configuration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# Private methods need to be imported explicitly
from .auth import *
from .auth import (
_check_full_access,
_do_get_request,
_get_token_terminal,
_get_token_web,
Expand Down
29 changes: 0 additions & 29 deletions linodecli/configuration/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,35 +152,6 @@ def _do_request(
return result.json()


def _check_full_access(base_url: str, token: str) -> bool:
"""
Checks whether the given token has full-access permissions.

:param base_url: The base URL for the API.
:type base_url: str
:param token: The access token to use.
:type token :str

:returns: Whether the user has full access.
:rtype: bool
"""
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}

result = requests.get(
base_url + "/profile/grants",
headers=headers,
timeout=120,
verify=API_CA_PATH,
)

_handle_response_status(result, exit_on_error=True)

return result.status_code == 204


def _username_for_token(base_url: str, token: str) -> str:
"""
A helper function that returns the username associated with a token by
Expand Down
28 changes: 21 additions & 7 deletions linodecli/configuration/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from linodecli.exit_codes import ExitCodes

from .auth import (
_check_full_access,
_do_get_request,
_get_token_terminal,
_get_token_web,
Expand Down Expand Up @@ -430,18 +429,33 @@ def configure(
[i["id"] for i in _do_get_request(self.base_url, "/images")["data"]]
)

is_full_access = _check_full_access(self.base_url, token)

auth_users = []

if is_full_access:
# Use /profile/sshkeys as a lightweight capability check. If the token
# lacks profile access (401) or the user is IAM-enrolled (403), skip
# the authorized_users prompt entirely. Capture the status code via a
# closure passed as status_validator so no new API surface is needed.
sshkeys_status_code = None

def _capture_sshkeys_status(status: int) -> bool:
nonlocal sshkeys_status_code
sshkeys_status_code = status
return True # suppress _handle_response_status error output

_do_get_request(
self.base_url,
"/profile/sshkeys",
token=token,
exit_on_error=False,
status_validator=_capture_sshkeys_status,
)

if sshkeys_status_code == 200:
users = _do_get_request(
self.base_url,
"/account/users",
token=token,
# Allow 401 responses so tokens without
# account perms can be configured
status_validator=lambda status: status == 401,
status_validator=lambda status: status in (401, 403),
)

if "data" in users:
Expand Down
106 changes: 104 additions & 2 deletions tests/unit/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ def mock_input(prompt):
requests_mock.Mocker() as m,
):
m.get(f"{self.base_url}/profile", json={"username": "cli-dev"})
m.get(f"{self.base_url}/profile/grants", status_code=204)
m.get(f"{self.base_url}/profile/sshkeys", json={"data": []})
m.get(
f"{self.base_url}/regions",
json={"data": [{"id": "test-region"}]},
Expand Down Expand Up @@ -349,7 +349,7 @@ def mock_input(prompt):
requests_mock.Mocker() as m,
):
m.get(f"{self.base_url}/profile", json={"username": "cli-dev"})
m.get(f"{self.base_url}/profile/grants", status_code=204)
m.get(f"{self.base_url}/profile/sshkeys", json={"data": []})
m.get(
f"{self.base_url}/regions",
json={"data": [{"id": "test-region"}]},
Expand Down Expand Up @@ -676,3 +676,105 @@ def test_custom_config_path(self, monkeypatch, tmp_path):

for i, _ in enumerate(expected_configs):
assert expected_configs[i] == configs[i]


class TestConfigureAuthorizedUsers:
"""
Unit tests for the authorized_users gate in CLIConfig.configure(),
which uses GET /profile/sshkeys as a capability check instead of
GET /profile/grants (which is inaccessible to IAM-enrolled users).
"""

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

def _base_mocks(self, m, *, sshkeys_status=200, users_json=None):
"""Register the common mocks needed for a configure() call."""
m.get(f"{self.base_url}/profile", json={"username": "cli-dev"})
m.get(
f"{self.base_url}/profile/sshkeys",
status_code=sshkeys_status,
json={"data": []},
)
m.get(f"{self.base_url}/regions", json={"data": [{"id": "us-east"}]})
m.get(
f"{self.base_url}/linode/types",
json={"data": [{"id": "g6-nanode-1"}]},
)
m.get(
f"{self.base_url}/images",
json={"data": [{"id": "linode/debian12"}]},
)
if users_json is not None:
m.get(f"{self.base_url}/account/users", json=users_json)

def test_sshkeys_success_populates_authorized_users(self):
"""
When /profile/sshkeys succeeds, /account/users is fetched and
authorized_users is offered.
"""
conf = configuration.CLIConfig(self.base_url, skip_config=True)
answers = iter(["1", "1", "1", "1", "n", "n"])

with (
patch("linodecli.configuration.open", mock_open()),
patch("os.chmod", lambda a, b: None),
patch("builtins.input", lambda _: next(answers)),
contextlib.redirect_stdout(io.StringIO()),
patch("linodecli.configuration._check_browsers", lambda: False),
patch.dict(os.environ, {"LINODE_CLI_TOKEN": self.test_token}),
requests_mock.Mocker() as m,
):
self._base_mocks(
m,
users_json={
"data": [{"username": "cli-dev", "ssh_keys": ["key1"]}]
},
)
conf.configure()

assert conf.get_value("authorized_users") == "cli-dev"

def test_sshkeys_401_skips_authorized_users(self):
"""
When /profile/sshkeys returns 401 (restricted token), the
authorized_users prompt is skipped.
"""
conf = configuration.CLIConfig(self.base_url, skip_config=True)
answers = iter(["1", "1", "1", "n", "n"])

with (
patch("linodecli.configuration.open", mock_open()),
patch("os.chmod", lambda a, b: None),
patch("builtins.input", lambda _: next(answers)),
contextlib.redirect_stdout(io.StringIO()),
patch("linodecli.configuration._check_browsers", lambda: False),
patch.dict(os.environ, {"LINODE_CLI_TOKEN": self.test_token}),
requests_mock.Mocker() as m,
):
self._base_mocks(m, sshkeys_status=401)
conf.configure()

assert conf.get_value("authorized_users") is None

def test_sshkeys_403_iam_skips_authorized_users(self):
"""
When /profile/sshkeys returns 403 (IAM-enrolled user), the
authorized_users prompt is skipped.
"""
conf = configuration.CLIConfig(self.base_url, skip_config=True)
answers = iter(["1", "1", "1", "n", "n"])

with (
patch("linodecli.configuration.open", mock_open()),
patch("os.chmod", lambda a, b: None),
patch("builtins.input", lambda _: next(answers)),
contextlib.redirect_stdout(io.StringIO()),
patch("linodecli.configuration._check_browsers", lambda: False),
patch.dict(os.environ, {"LINODE_CLI_TOKEN": self.test_token}),
requests_mock.Mocker() as m,
):
self._base_mocks(m, sshkeys_status=403)
conf.configure()

assert conf.get_value("authorized_users") is None
Loading