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
41 changes: 41 additions & 0 deletions linode_api4/groups/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Payment,
PaymentMethod,
ServiceTransfer,
TFAOptionalUser,
User,
)

Expand Down Expand Up @@ -185,6 +186,46 @@ def users(self, *filters):
"""
return self.client._get_and_filter(User, *filters)

def tfa_optional_users(self):
"""
Lists users exempted from MFA enforcement, regardless of the current
account-wide policy, using ``GET /account/tfa-optional-users``.

:returns: References containing each exempted user's username and URL.
:rtype: PaginatedList of TFAOptionalUser
"""
return self.client._get_and_filter(
TFAOptionalUser, endpoint="/account/tfa-optional-users"
)

def tfa_optional_users_update(self, usernames):
"""
Replaces the entire list of users exempted from MFA enforcement using
``PUT /account/tfa-optional-users``. Omitted users lose their exemptions.
This does not change the account-wide ``tfa_enforced`` setting.

:param usernames: Usernames to exempt. Pass an empty list to remove all
exemptions.
:type usernames: list[str]

:returns: The API's ``tfa_optional_users_count`` and collection ``url``.
:rtype: MappedObject
:raises ApiError: If a username is invalid or access is denied.
"""
result = self.client.put(
"/account/tfa-optional-users", data={"usernames": usernames}
)

if not all(
key in result for key in ("tfa_optional_users_count", "url")
):
raise UnexpectedResponseError(
"Unexpected response when updating TFA optional users!",
json=result,
)

return MappedObject(**result)

def logins(self):
"""
Returns a collection of successful logins for all users on the account during the last 90 days.
Expand Down
27 changes: 26 additions & 1 deletion linode_api4/objects/account.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from typing import Optional

import requests
from deprecated import deprecated
Expand All @@ -17,7 +19,7 @@
from linode_api4.objects.networking import Firewall
from linode_api4.objects.nodebalancer import NodeBalancer
from linode_api4.objects.profile import PersonalAccessToken
from linode_api4.objects.serializable import StrEnum
from linode_api4.objects.serializable import JSONObject, StrEnum
from linode_api4.objects.support import SupportTicket
from linode_api4.objects.volume import Volume
from linode_api4.objects.vpc import VPC
Expand Down Expand Up @@ -203,6 +205,10 @@ class AccountSettings(Base):
"""
Information related to your Account settings.

``tfa_enforced`` is the mutable account-wide default for MFA enforcement.
User exemptions are managed with :meth:`AccountGroup.tfa_optional_users_update`
and are retained when account-wide enforcement is disabled.

API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-settings
"""

Expand All @@ -219,6 +225,7 @@ class AccountSettings(Base):
"backups_enabled": Property(mutable=True),
"interfaces_for_new_linodes": Property(mutable=True),
"maintenance_policy": Property(mutable=True),
"tfa_enforced": Property(mutable=True),
}


Expand Down Expand Up @@ -531,10 +538,27 @@ class Payment(Base):
}


@dataclass
class TFAOptionalUser(JSONObject):
"""
A reference to an account user exempted from MFA enforcement.

Returned by :meth:`AccountGroup.tfa_optional_users`. The ``username`` and
``url`` fields identify the user; load a :class:`User` for full details.
"""

username: Optional[str] = None
url: Optional[str] = None


class User(Base):
"""
An object representing a single user on this account.

``tfa_enforced`` is the read-only effective MFA enforcement policy for this
user, distinct from whether MFA is enabled (``tfa_enabled``). It can be used
to filter :meth:`AccountGroup.users`.

API Documentation: https://techdocs.akamai.com/linode-api/reference/get-user
"""

Expand All @@ -548,6 +572,7 @@ class User(Base):
"restricted": Property(mutable=True),
"ssh_keys": Property(),
"tfa_enabled": Property(),
"tfa_enforced": Property(),
}

@property
Expand Down
8 changes: 8 additions & 0 deletions linode_api4/objects/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class Profile(Base):
"""
A Profile containing information about the current User.

``tfa_enforced`` is the read-only effective MFA enforcement policy, distinct
from whether MFA is enabled (``two_factor_auth``).

API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile
"""

Expand All @@ -77,6 +80,7 @@ class Profile(Base):
"lish_auth_method": Property(mutable=True),
"authorized_keys": Property(mutable=True),
"two_factor_auth": Property(),
"tfa_enforced": Property(),
"restricted": Property(),
"authentication_type": Property(),
"authorized_keys": Property(),
Expand Down Expand Up @@ -120,10 +124,14 @@ def disable_tfa(self):
"""
Turns off TFA for this user's account.

The API rejects this operation when account-wide enforcement applies
and the user is not exempted.

API Documentation: https://techdocs.akamai.com/linode-api/reference/post-tfa-disable

:returns: Returns true if operation was successful
:rtype: bool
:raises ApiError: If the API disallows disabling MFA.
"""
self._client.post("/profile/tfa-disable")

Expand Down
3 changes: 2 additions & 1 deletion test/fixtures/account_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"object_storage": "active",
"backups_enabled": true,
"interfaces_for_new_linodes": "linode_default_but_legacy_config_allowed",
"maintenance_policy": "linode/migrate"
"maintenance_policy": "linode/migrate",
"tfa_enforced": false
}
11 changes: 11 additions & 0 deletions test/fixtures/account_tfa-optional-users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"data": [
{
"username": "test-user",
"url": "/account/users/test-user"
}
],
"page": 1,
"pages": 1,
"results": 1
}
1 change: 1 addition & 0 deletions test/fixtures/account_users_test-user.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"laptop"
],
"tfa_enabled": true,
"tfa_enforced": true,
"username": "test-user"
}
1 change: 1 addition & 0 deletions test/fixtures/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"restricted": false,
"timezone": "US/Eastern",
"two_factor_auth": true,
"tfa_enforced": false,
"uid": 1234,
"username": "exampleUser",
"verified_phone_number": "+5555555555"
Expand Down
76 changes: 76 additions & 0 deletions test/integration/models/account/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@

import pytest

from linode_api4.errors import ApiError
from linode_api4.objects import (
Account,
AccountSettings,
Capability,
ChildAccount,
Event,
Login,
TFAOptionalUser,
User,
)

Expand Down Expand Up @@ -71,6 +73,80 @@ def test_get_account_settings(test_linode_client):
assert "maintenance_policy" in str(account_settings._raw_json)


def test_account_tfa_enforced_round_trip(test_linode_client):
enforced = test_linode_client.account.settings().tfa_enforced
assert isinstance(enforced, bool)

settings = AccountSettings(test_linode_client, "")
settings.tfa_enforced = enforced

assert settings.save()
assert test_linode_client.account.settings().tfa_enforced is enforced


def test_get_user_tfa_enforced(test_linode_client):
profile = test_linode_client.profile()
user = test_linode_client.load(User, profile.username)

assert isinstance(user.tfa_enforced, bool)
assert user.tfa_enforced is profile.tfa_enforced


@pytest.mark.parametrize("enforced", [True, False])
def test_list_users_tfa_enforced(test_linode_client, enforced):
profile = test_linode_client.profile()
users = list(
test_linode_client.account.users(User.tfa_enforced == enforced)
)

assert all(user.tfa_enforced is enforced for user in users)
if profile.tfa_enforced is enforced:
assert profile.username in {user.username for user in users}


def test_tfa_optional_users_round_trip(test_linode_client):
client = test_linode_client
enforced = client.account.settings().tfa_enforced
usernames = sorted(
user.username for user in client.account.tfa_optional_users()
)

result = client.account.tfa_optional_users_update(usernames)

assert result.tfa_optional_users_count == len(usernames)
assert result.url in (
"/account/tfa-optional-users",
"/v4/account/tfa-optional-users",
)
users = list(client.account.tfa_optional_users())
assert sorted(user.username for user in users) == usernames
for user in users:
assert isinstance(user, TFAOptionalUser)
assert isinstance(user.username, str)
assert user.url in (
f"/account/users/{user.username}",
f"/v4/account/users/{user.username}",
)
assert client.account.settings().tfa_enforced is enforced


def test_tfa_optional_users_invalid_username(test_linode_client):
client = test_linode_client
usernames = sorted(
user.username for user in client.account.tfa_optional_users()
)
invalid_username = f"missing-{time.time_ns()}"

with pytest.raises(ApiError) as error:
client.account.tfa_optional_users_update(usernames + [invalid_username])

assert error.value.status == 400
assert (
sorted(user.username for user in client.account.tfa_optional_users())
== usernames
)


def test_update_maintenance_policy(test_linode_client):
client = test_linode_client
settings = client.load(AccountSettings(client, ""), "")
Expand Down
1 change: 1 addition & 0 deletions test/integration/models/profile/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def test_user_profile(test_linode_client):
profile = client.profile()

assert isinstance(profile, Profile)
assert isinstance(profile.tfa_enforced, bool)


def test_get_personal_access_token_objects(test_linode_client):
Expand Down
Loading
Loading