Skip to content
Merged
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
4 changes: 4 additions & 0 deletions account_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type AccountSettings struct {
// Whether or not the Network Helper is enabled for all new Linode Instance Configs on the account.
NetworkHelper bool `json:"network_helper"`

TFAEnforced bool `json:"tfa_enforced"`

// A plan name like "longview-3"..."longview-100", or a nil value for to cancel any existing subscription plan.
LongviewSubscription *string `json:"longview_subscription"`

Expand All @@ -45,6 +47,8 @@ type AccountSettingsUpdateOptions struct {
// The default network helper setting for all new Linodes and Linode Configs for all users on the account.
NetworkHelper *bool `json:"network_helper,omitzero"`

TFAEnforced *bool `json:"tfa_enforced,omitzero"`

// NOTE: Interfaces for new linode setting may not currently be available to all users.
// A new configuration flag defines whether new Linodes can use Linode and/or legacy config interfaces.
InterfacesForNewLinodes *InterfacesForNewLinodes `json:"interfaces_for_new_linodes"`
Expand Down
30 changes: 30 additions & 0 deletions account_tfa_optional_users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package linodego

import "context"

// TFAOptionalUser represents an account user exempted from TFA enforcement.
type TFAOptionalUser struct {
Username string `json:"username"`
URL string `json:"url"`
}

// TFAOptionalUsersUpdateOptions contains the replacement list of exempted users.
type TFAOptionalUsersUpdateOptions struct {
Usernames []string `json:"usernames"`
}

// TFAOptionalUsersUpdateResponse summarizes the updated TFA exemption list.
type TFAOptionalUsersUpdateResponse struct {
TFAOptionalUsersCount int `json:"tfa_optional_users_count"`
URL string `json:"url"`
}

// ListTFAOptionalUsers lists exempted users regardless of the account's TFA policy.
func (c *Client) ListTFAOptionalUsers(ctx context.Context, opts *ListOptions) ([]TFAOptionalUser, error) {
return getPaginatedResults[TFAOptionalUser](ctx, c, "account/tfa-optional-users", opts)
}

// UpdateTFAOptionalUsers replaces the exemption list. An empty slice clears it.
func (c *Client) UpdateTFAOptionalUsers(ctx context.Context, opts TFAOptionalUsersUpdateOptions) (*TFAOptionalUsersUpdateResponse, error) {
return doPUTRequest[TFAOptionalUsersUpdateResponse](ctx, c, "account/tfa-optional-users", opts)
}
1 change: 1 addition & 0 deletions account_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type User struct {
UserType UserType `json:"user_type"`
Restricted bool `json:"restricted"`
TFAEnabled bool `json:"tfa_enabled"`
TFAEnforced bool `json:"tfa_enforced"`
SSHKeys []string `json:"ssh_keys"`
PasswordCreated *time.Time `json:"-"`
VerifiedPhoneNumber *string `json:"verified_phone_number"`
Expand Down
1 change: 1 addition & 0 deletions profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Profile struct {
EmailNotifications bool `json:"email_notifications"`
IPWhitelistEnabled bool `json:"ip_whitelist_enabled"`
TwoFactorAuth bool `json:"two_factor_auth"`
TFAEnforced bool `json:"tfa_enforced"`
Restricted bool `json:"restricted"`
LishAuthMethod LishAuthMethod `json:"lish_auth_method"`
Referrals ProfileReferrals `json:"referrals"`
Expand Down
18 changes: 18 additions & 0 deletions test/integration/account_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,23 @@ func TestAccountSettings_Update(t *testing.T) {
require.Equal(t, "linode/migrate", settings.MaintenancePolicy, "Expected MaintenancePolicy to be 'linode/migrate'")
}

func TestAccountSettings_TFAEnforced(t *testing.T) {
client, teardown := createTestClient(t, "fixtures/TestAccountSettings_TFAEnforced")
t.Cleanup(teardown)

ctx := context.Background()
original, err := client.GetAccountSettings(ctx)
require.NoError(t, err)

opts := linodego.AccountSettingsUpdateOptions{TFAEnforced: new(original.TFAEnforced)}
updated, err := client.UpdateAccountSettings(ctx, opts)
require.NoError(t, err)
require.Equal(t, original.TFAEnforced, updated.TFAEnforced)

settings, err := client.GetAccountSettings(ctx)
require.NoError(t, err)
require.Equal(t, original.TFAEnforced, settings.TFAEnforced)
}

func Bool(v bool) *bool { return &v }
func String(v string) *string { return &v }
85 changes: 85 additions & 0 deletions test/integration/account_tfa_optional_users_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package integration

import (
"context"
"encoding/json"
"testing"

"github.com/linode/linodego/v2"
"github.com/stretchr/testify/require"
)

func TestAccountTFAOptionalUsers_List(t *testing.T) {
client, teardown := createTestClient(t, "fixtures/TestAccountTFAOptionalUsers_List")
t.Cleanup(teardown)

opts := linodego.NewListOptions(0, "")
opts.PageSize = 25
users, err := client.ListTFAOptionalUsers(context.Background(), opts)
require.NoError(t, err)
require.Len(t, users, opts.Results)

for _, user := range users {
require.NotEmpty(t, user.Username)
require.Equal(t, "/v4/account/users/"+user.Username, user.URL)
}
}

func TestAccountTFAOptionalUsers_Update(t *testing.T) {
client, teardown := createTestClient(t, "fixtures/TestAccountTFAOptionalUsers_Update")
t.Cleanup(teardown)

ctx := context.Background()
original, err := client.ListTFAOptionalUsers(ctx, nil)
require.NoError(t, err)
originalUsernames := make([]string, len(original))
for index, user := range original {
originalUsernames[index] = user.Username
}

user, deleteUser := createUser(t, client, func(opts *linodego.UserCreateOptions) {
opts.Username = usernamePrefix + "tfaoptional"
opts.Email = opts.Username + "@example.com"
opts.Restricted = true
})
t.Cleanup(deleteUser)
t.Cleanup(func() {
_, err := client.UpdateTFAOptionalUsers(ctx, linodego.TFAOptionalUsersUpdateOptions{Usernames: originalUsernames})
require.NoError(t, err, "failed to restore the original TFA exemption list")
})

usernames := append([]string{user.Username}, originalUsernames...)
response, err := client.UpdateTFAOptionalUsers(ctx, linodego.TFAOptionalUsersUpdateOptions{Usernames: usernames})
require.NoError(t, err)
require.Equal(t, len(usernames), response.TFAOptionalUsersCount)
require.Equal(t, "/v4/account/tfa-optional-users", response.URL)

users, err := client.ListTFAOptionalUsers(ctx, nil)
require.NoError(t, err)
require.Len(t, users, len(usernames))
require.Contains(t, users, linodego.TFAOptionalUser{Username: user.Username, URL: "/v4/account/users/" + user.Username})

exempted, err := client.GetUser(ctx, user.Username)
require.NoError(t, err)
require.False(t, exempted.TFAEnforced)

updated, err := client.UpdateUser(ctx, user.Username, linodego.UserUpdateOptions{Restricted: new(true)})
require.NoError(t, err)
require.False(t, updated.TFAEnforced)

filter, err := json.Marshal(map[string]interface{}{"username": user.Username, "tfa_enforced": false})
require.NoError(t, err)
filtered, err := client.ListUsers(ctx, linodego.NewListOptions(0, string(filter)))
require.NoError(t, err)
require.Len(t, filtered, 1)
require.Equal(t, user.Username, filtered[0].Username)
require.False(t, filtered[0].TFAEnforced)

response, err = client.UpdateTFAOptionalUsers(ctx, linodego.TFAOptionalUsersUpdateOptions{Usernames: originalUsernames})
require.NoError(t, err)
require.Equal(t, len(originalUsernames), response.TFAOptionalUsersCount)

users, err = client.ListTFAOptionalUsers(ctx, nil)
require.NoError(t, err)
require.ElementsMatch(t, original, users)
}
197 changes: 197 additions & 0 deletions test/integration/fixtures/TestAccountSettings_TFAEnforced.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
---
version: 1
interactions:
- request:
body: ""
form: {}
headers:
Accept:
- application/json
Content-Type:
- application/json
User-Agent:
- linodego/(devel) https://github.com/linode/linodego
url: https://api.linode.com/v4beta/account/settings
method: GET
response:
body: '{"managed": false, "network_helper": false, "longview_subscription": null,
"backups_enabled": false, "object_storage": "active", "interfaces_for_new_linodes":
"legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate",
"tfa_enforced": false}'
headers:
Access-Control-Allow-Credentials:
- "true"
Access-Control-Allow-Headers:
- Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter
Access-Control-Allow-Methods:
- HEAD, GET, OPTIONS, POST, PUT, DELETE
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status
Akamai-Internal-Account:
- '*'
Cache-Control:
- max-age=0, no-cache, no-store
Connection:
- keep-alive
Content-Length:
- "265"
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json
Expires:
- Wed, 21 Oct 2026 20:41:04 GMT
Pragma:
- no-cache
Strict-Transport-Security:
- max-age=31536000
Vary:
- Authorization, X-Filter
- Authorization, X-Filter
X-Accepted-Oauth-Scopes:
- account:read_only
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- DENY
- DENY
X-Oauth-Scopes:
- '*'
X-Ratelimit-Limit:
- "1840"
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: ""
- request:
body: '{"tfa_enforced":false,"interfaces_for_new_linodes":null}'
form: {}
headers:
Accept:
- application/json
Content-Type:
- application/json
User-Agent:
- linodego/(devel) https://github.com/linode/linodego
url: https://api.linode.com/v4beta/account/settings
method: PUT
response:
body: '{"managed": false, "network_helper": false, "longview_subscription": null,
"backups_enabled": false, "object_storage": "active", "interfaces_for_new_linodes":
"legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate",
"tfa_enforced": false}'
headers:
Access-Control-Allow-Credentials:
- "true"
Access-Control-Allow-Headers:
- Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter
Access-Control-Allow-Methods:
- HEAD, GET, OPTIONS, POST, PUT, DELETE
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status
Akamai-Internal-Account:
- '*'
Cache-Control:
- max-age=0, no-cache, no-store
Connection:
- keep-alive
Content-Length:
- "265"
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json
Expires:
- Wed, 21 Oct 2026 20:41:04 GMT
Pragma:
- no-cache
Strict-Transport-Security:
- max-age=31536000
Vary:
- Authorization, X-Filter
X-Accepted-Oauth-Scopes:
- account:read_write
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- DENY
- DENY
X-Oauth-Scopes:
- '*'
X-Ratelimit-Limit:
- "1840"
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: ""
- request:
body: ""
form: {}
headers:
Accept:
- application/json
Content-Type:
- application/json
User-Agent:
- linodego/(devel) https://github.com/linode/linodego
url: https://api.linode.com/v4beta/account/settings
method: GET
response:
body: '{"managed": false, "network_helper": false, "longview_subscription": null,
"backups_enabled": false, "object_storage": "active", "interfaces_for_new_linodes":
"legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate",
"tfa_enforced": false}'
headers:
Access-Control-Allow-Credentials:
- "true"
Access-Control-Allow-Headers:
- Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter
Access-Control-Allow-Methods:
- HEAD, GET, OPTIONS, POST, PUT, DELETE
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status
Akamai-Internal-Account:
- '*'
Cache-Control:
- max-age=0, no-cache, no-store
Connection:
- keep-alive
Content-Length:
- "265"
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json
Expires:
- Wed, 21 Oct 2026 20:41:04 GMT
Pragma:
- no-cache
Strict-Transport-Security:
- max-age=31536000
Vary:
- Authorization, X-Filter
- Authorization, X-Filter
X-Accepted-Oauth-Scopes:
- account:read_only
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- DENY
- DENY
X-Oauth-Scopes:
- '*'
X-Ratelimit-Limit:
- "1840"
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: ""
Loading
Loading