Add Account HTTP API (#691)

Extend HTTP API with Account endpoints to configure global peer login expiration.
GET /api/accounts
PUT /api/account/{id}/

The GET endpoint returns an array of accounts with
always one account in the list. No exceptions.

The PUT endpoint updates account settings:
PeerLoginExpiration and PeerLoginExpirationEnabled.

PeerLoginExpiration is a duration in seconds after which peers' logins will expire.
This commit is contained in:
Misha Bragin 2023-02-16 12:00:41 +01:00 committed by GitHub
parent d31219ba89
commit fe63a64b6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 527 additions and 0 deletions

View File

@ -97,6 +97,7 @@ type AccountManager interface {
SaveDNSSettings(accountID string, userID string, dnsSettingsToSave *DNSSettings) error
GetPeer(accountID, peerID, userID string) (*Peer, error)
UpdatePeerLastLogin(peerID string) error
UpdateAccountSettings(accountID, userID string, newSettings *Settings) (*Account, error)
}
type DefaultAccountManager struct {
@ -315,6 +316,12 @@ func (a *Account) GetPeers() []*Peer {
return peers
}
// UpdateSettings saves new account settings
func (a *Account) UpdateSettings(update *Settings) *Account {
a.Settings = update.Copy()
return a
}
// UpdatePeer saves new or replaces existing peer
func (a *Account) UpdatePeer(update *Peer) {
a.Peers[update.ID] = update
@ -596,6 +603,61 @@ func BuildManager(store Store, peersUpdateManager *PeersUpdateManager, idpManage
return am, nil
}
// UpdateAccountSettings updates Account settings.
// Only users with role UserRoleAdmin can update the account.
// User that performs the update has to belong to the account.
// Returns an updated Account
func (am *DefaultAccountManager) UpdateAccountSettings(accountID, userID string, newSettings *Settings) (*Account, error) {
halfYearLimit := 180 * 24 * time.Hour
if newSettings.PeerLoginExpiration > halfYearLimit {
return nil, status.Errorf(status.InvalidArgument, "peer login expiration can't be larger than 180 days")
}
if newSettings.PeerLoginExpiration < time.Hour {
return nil, status.Errorf(status.InvalidArgument, "peer login expiration can't be smaller than one hour")
}
unlock := am.Store.AcquireAccountLock(accountID)
defer unlock()
account, err := am.Store.GetAccountByUser(userID)
if err != nil {
return nil, err
}
user, err := account.FindUser(userID)
if err != nil {
return nil, err
}
if !user.IsAdmin() {
return nil, status.Errorf(status.PermissionDenied, "user is not allowed to update account")
}
oldSettings := account.Settings
if oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled {
event := activity.AccountPeerLoginExpirationEnabled
if !newSettings.PeerLoginExpirationEnabled {
event = activity.AccountPeerLoginExpirationDisabled
}
am.storeEvent(userID, accountID, accountID, event, nil)
}
if oldSettings.PeerLoginExpiration != newSettings.PeerLoginExpiration {
am.storeEvent(userID, accountID, accountID, activity.AccountPeerLoginExpirationDurationUpdated, nil)
}
updatedAccount := account.UpdateSettings(newSettings)
err = am.Store.SaveAccount(account)
if err != nil {
return nil, err
}
return updatedAccount, nil
}
// newAccount creates a new Account with a generated ID and generated default setup keys.
// If ID is already in use (due to collision) we try one more time before returning error
func (am *DefaultAccountManager) newAccount(userID, domain string) (*Account, error) {

View File

@ -1283,6 +1283,48 @@ func hasNilField(x interface{}) error {
}
return nil
}
func TestDefaultAccountManager_DefaultAccountSettings(t *testing.T) {
manager, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
account, err := manager.GetAccountByUserOrAccountID(userID, "", "")
require.NoError(t, err, "unable to create an account")
assert.NotNil(t, account.Settings)
assert.Equal(t, account.Settings.PeerLoginExpirationEnabled, true)
assert.Equal(t, account.Settings.PeerLoginExpiration, 24*time.Hour)
}
func TestDefaultAccountManager_UpdateAccountSettings(t *testing.T) {
manager, err := createManager(t)
require.NoError(t, err, "unable to create account manager")
account, err := manager.GetAccountByUserOrAccountID(userID, "", "")
require.NoError(t, err, "unable to create an account")
updated, err := manager.UpdateAccountSettings(account.Id, userID, &Settings{
PeerLoginExpiration: time.Hour,
PeerLoginExpirationEnabled: false})
require.NoError(t, err, "expecting to update account settings successfully but got error")
assert.False(t, updated.Settings.PeerLoginExpirationEnabled)
assert.Equal(t, updated.Settings.PeerLoginExpiration, time.Hour)
account, err = manager.GetAccountByUserOrAccountID("", account.Id, "")
require.NoError(t, err, "unable to get account by ID")
assert.False(t, account.Settings.PeerLoginExpirationEnabled)
assert.Equal(t, account.Settings.PeerLoginExpiration, time.Hour)
_, err = manager.UpdateAccountSettings(account.Id, userID, &Settings{
PeerLoginExpiration: time.Second,
PeerLoginExpirationEnabled: false})
require.Error(t, err, "expecting to fail when providing PeerLoginExpiration less than one hour")
_, err = manager.UpdateAccountSettings(account.Id, userID, &Settings{
PeerLoginExpiration: time.Hour * 24 * 181,
PeerLoginExpirationEnabled: false})
require.Error(t, err, "expecting to fail when providing PeerLoginExpiration more than 180 days")
}
func createManager(t *testing.T) (*DefaultAccountManager, error) {
store, err := createStore(t)

View File

@ -71,6 +71,12 @@ const (
NameserverGroupDeleted
// NameserverGroupUpdated indicates that a user updated a nameservers group
NameserverGroupUpdated
// AccountPeerLoginExpirationEnabled indicates that a user enabled peer login expiration for the account
AccountPeerLoginExpirationEnabled
// AccountPeerLoginExpirationDisabled indicates that a user disabled peer login expiration for the account
AccountPeerLoginExpirationDisabled
// AccountPeerLoginExpirationDurationUpdated indicates that a user updated peer login expiration duration for the account
AccountPeerLoginExpirationDurationUpdated
)
const (
@ -144,6 +150,12 @@ const (
NameserverGroupDeletedMessage string = "Nameserver group deleted"
// NameserverGroupUpdatedMessage is a human-readable text message of the NameserverGroupUpdated activity
NameserverGroupUpdatedMessage string = "Nameserver group updated"
// AccountPeerLoginExpirationEnabledMessage is a human-readable text message of the AccountPeerLoginExpirationEnabled activity
AccountPeerLoginExpirationEnabledMessage string = "Peer login expiration enabled for the account"
// AccountPeerLoginExpirationDisabledMessage is a human-readable text message of the AccountPeerLoginExpirationDisabled activity
AccountPeerLoginExpirationDisabledMessage string = "Peer login expiration disabled for the account"
// AccountPeerLoginExpirationDurationUpdatedMessage is a human-readable text message of the AccountPeerLoginExpirationDurationUpdated activity
AccountPeerLoginExpirationDurationUpdatedMessage string = "Peer login expiration duration updated"
)
// Activity that triggered an Event
@ -222,6 +234,12 @@ func (a Activity) Message() string {
return NameserverGroupDeletedMessage
case NameserverGroupUpdated:
return NameserverGroupUpdatedMessage
case AccountPeerLoginExpirationEnabled:
return AccountPeerLoginExpirationEnabledMessage
case AccountPeerLoginExpirationDisabled:
return AccountPeerLoginExpirationDisabledMessage
case AccountPeerLoginExpirationDurationUpdated:
return AccountPeerLoginExpirationDurationUpdatedMessage
default:
return "UNKNOWN_ACTIVITY"
}
@ -300,6 +318,12 @@ func (a Activity) StringCode() string {
return "nameserver.group.delete"
case NameserverGroupUpdated:
return "nameserver.group.update"
case AccountPeerLoginExpirationDurationUpdated:
return "account.settings.peer.login.expiration.update"
case AccountPeerLoginExpirationEnabled:
return "account.setting.peer.login.expiration.enable"
case AccountPeerLoginExpirationDisabled:
return "account.setting.peer.login.expiration.disable"
default:
return "UNKNOWN_ACTIVITY"
}

View File

@ -0,0 +1,96 @@
package http
import (
"encoding/json"
"github.com/gorilla/mux"
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/http/api"
"github.com/netbirdio/netbird/management/server/http/util"
"github.com/netbirdio/netbird/management/server/jwtclaims"
"github.com/netbirdio/netbird/management/server/status"
"net/http"
"time"
)
// Accounts is a handler that handles the server.Account HTTP endpoints
type Accounts struct {
accountManager server.AccountManager
claimsExtractor *jwtclaims.ClaimsExtractor
}
// NewAccounts creates a new Accounts HTTP handler
func NewAccounts(accountManager server.AccountManager, authCfg AuthCfg) *Accounts {
return &Accounts{
accountManager: accountManager,
claimsExtractor: jwtclaims.NewClaimsExtractor(
jwtclaims.WithAudience(authCfg.Audience),
jwtclaims.WithUserIDClaim(authCfg.UserIDClaim),
),
}
}
// GetAccountsHandler is HTTP GET handler that returns a list of accounts. Effectively returns just a single account.
func (h *Accounts) GetAccountsHandler(w http.ResponseWriter, r *http.Request) {
claims := h.claimsExtractor.FromRequestContext(r)
account, user, err := h.accountManager.GetAccountFromToken(claims)
if err != nil {
util.WriteError(err, w)
return
}
if !user.IsAdmin() {
util.WriteError(status.Errorf(status.PermissionDenied, "the user has no permission to access account data"), w)
return
}
resp := toAccountResponse(account)
util.WriteJSONObject(w, []*api.Account{resp})
}
// UpdateAccountHandler is HTTP PUT handler that updates the provided account. Updates only account settings (server.Settings)
func (h *Accounts) UpdateAccountHandler(w http.ResponseWriter, r *http.Request) {
claims := h.claimsExtractor.FromRequestContext(r)
_, user, err := h.accountManager.GetAccountFromToken(claims)
if err != nil {
util.WriteError(err, w)
return
}
vars := mux.Vars(r)
accountID := vars["id"]
if len(accountID) == 0 {
util.WriteError(status.Errorf(status.InvalidArgument, "invalid accountID ID"), w)
return
}
var req api.PutApiAccountsIdJSONBody
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}
updatedAccount, err := h.accountManager.UpdateAccountSettings(accountID, user.Id, &server.Settings{
PeerLoginExpirationEnabled: req.Settings.PeerLoginExpirationEnabled,
PeerLoginExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerLoginExpiration)),
})
if err != nil {
util.WriteError(err, w)
return
}
resp := toAccountResponse(updatedAccount)
util.WriteJSONObject(w, &resp)
}
func toAccountResponse(account *server.Account) *api.Account {
return &api.Account{
Id: account.Id,
Settings: api.AccountSettings{
PeerLoginExpiration: int(account.Settings.PeerLoginExpiration.Seconds()),
PeerLoginExpirationEnabled: account.Settings.PeerLoginExpirationEnabled,
},
}
}

View File

@ -0,0 +1,181 @@
package http
import (
"bytes"
"encoding/json"
"github.com/gorilla/mux"
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/http/api"
"github.com/netbirdio/netbird/management/server/jwtclaims"
"github.com/netbirdio/netbird/management/server/mock_server"
"github.com/netbirdio/netbird/management/server/status"
"github.com/stretchr/testify/assert"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func initAccountsTestData(account *server.Account, admin *server.User) *Accounts {
return &Accounts{
accountManager: &mock_server.MockAccountManager{
GetAccountFromTokenFunc: func(claims jwtclaims.AuthorizationClaims) (*server.Account, *server.User, error) {
return account, admin, nil
},
UpdateAccountSettingsFunc: func(accountID, userID string, newSettings *server.Settings) (*server.Account, error) {
halfYearLimit := 180 * 24 * time.Hour
if newSettings.PeerLoginExpiration > halfYearLimit {
return nil, status.Errorf(status.InvalidArgument, "peer login expiration can't be larger than 180 days")
}
if newSettings.PeerLoginExpiration < time.Hour {
return nil, status.Errorf(status.InvalidArgument, "peer login expiration can't be smaller than one hour")
}
accCopy := account.Copy()
accCopy.UpdateSettings(newSettings)
return accCopy, nil
},
},
claimsExtractor: jwtclaims.NewClaimsExtractor(
jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims {
return jwtclaims.AuthorizationClaims{
UserId: "test_user",
Domain: "hotmail.com",
AccountId: "test_account",
}
}),
),
}
}
func TestAccounts_AccountsHandler(t *testing.T) {
accountID := "test_account"
adminUser := server.NewAdminUser("test_user")
handler := initAccountsTestData(&server.Account{
Id: accountID,
Domain: "hotmail.com",
Network: server.NewNetwork(),
Users: map[string]*server.User{
adminUser.Id: adminUser,
},
Settings: &server.Settings{
PeerLoginExpirationEnabled: false,
PeerLoginExpiration: time.Hour,
},
}, adminUser)
tt := []struct {
name string
expectedStatus int
expectedBody bool
expectedID string
expectedArray bool
expectedSettings api.AccountSettings
requestType string
requestPath string
requestBody io.Reader
}{
{
name: "GetAccounts OK",
expectedBody: true,
requestType: http.MethodGet,
requestPath: "/api/accounts",
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: int(time.Hour.Seconds()),
PeerLoginExpirationEnabled: false,
},
expectedArray: true,
expectedID: accountID,
},
{
name: "PutAccount OK",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true}}"),
expectedStatus: http.StatusOK,
expectedSettings: api.AccountSettings{
PeerLoginExpiration: 15552000,
PeerLoginExpirationEnabled: true,
},
expectedArray: false,
expectedID: accountID,
},
{
name: "Update account failure with high peer_login_expiration more than 180 days",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552001,\"peer_login_expiration_enabled\": true}}"),
expectedStatus: http.StatusUnprocessableEntity,
expectedArray: false,
},
{
name: "Update account failure with peer_login_expiration less than an hour",
expectedBody: true,
requestType: http.MethodPut,
requestPath: "/api/accounts/" + accountID,
requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 3599,\"peer_login_expiration_enabled\": true}}"),
expectedStatus: http.StatusUnprocessableEntity,
expectedArray: false,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody)
router := mux.NewRouter()
router.HandleFunc("/api/accounts", handler.GetAccountsHandler).Methods("GET")
router.HandleFunc("/api/accounts/{id}", handler.UpdateAccountHandler).Methods("PUT")
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
if status := recorder.Code; status != tc.expectedStatus {
t.Errorf("handler returned wrong status code: got %v want %v",
status, tc.expectedStatus)
return
}
if tc.expectedStatus != http.StatusOK {
return
}
if !tc.expectedBody {
return
}
content, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("I don't know what I expected; %v", err)
}
var actual *api.Account
if tc.expectedArray {
var got []*api.Account
if err = json.Unmarshal(content, &got); err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
assert.Len(t, got, 1)
actual = got[0]
} else {
if err = json.Unmarshal(content, &actual); err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
}
assert.Equal(t, tc.expectedID, actual.Id)
assert.Equal(t, tc.expectedSettings, actual.Settings)
})
}
}

View File

@ -20,8 +20,31 @@ tags:
description: Interact with and view information about DNS configuration.
- name: Events
description: View information about the account and network events.
- name: Accounts
description: View information about the accounts.
components:
schemas:
Account:
properties:
id:
description: Account ID
type: string
settings:
$ref: '#/components/schemas/AccountSettings'
required:
- id
- settings
AccountSettings:
properties:
peer_login_expiration_enabled:
description: Enables or disables peer login expiration globally. After peer's login has expired the user has to log in (authenticate). Applies only to peers that were added by a user (interactive SSO login).
type: boolean
peer_login_expiration:
description: Period of time after which peer login expires (seconds).
type: integer
required:
- peer_login_expiration_enabled
- peer_login_expiration
User:
type: object
properties:
@ -606,6 +629,68 @@ components:
security:
- BearerAuth: [ ]
paths:
/api/accounts:
get:
summary: Returns a list of accounts of a user. Always returns a list of one account. Only available for admin users.
tags: [ Accounts ]
security:
- BearerAuth: [ ]
responses:
'200':
description: A JSON array of accounts
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Account'
'400':
"$ref": "#/components/responses/bad_request"
'401':
"$ref": "#/components/responses/requires_authentication"
'403':
"$ref": "#/components/responses/forbidden"
'500':
"$ref": "#/components/responses/internal_error"
/api/accounts/{id}:
put:
summary: Update information about an account
tags: [ Accounts ]
security:
- BearerAuth: [ ]
parameters:
- in: path
name: id
required: true
schema:
type: string
description: The Account ID
requestBody:
description: update an account
content:
'application/json':
schema:
type: object
properties:
settings:
$ref: '#/components/schemas/AccountSettings'
required:
- settings
responses:
'200':
description: An Account object
content:
application/json:
schema:
$ref: '#/components/schemas/Account'
'400':
"$ref": "#/components/responses/bad_request"
'401':
"$ref": "#/components/responses/requires_authentication"
'403':
"$ref": "#/components/responses/forbidden"
'500':
"$ref": "#/components/responses/internal_error"
/api/users:
get:
summary: Returns a list of all users

View File

@ -134,6 +134,22 @@ const (
UserStatusInvited UserStatus = "invited"
)
// Account defines model for Account.
type Account struct {
// Id Account ID
Id string `json:"id"`
Settings AccountSettings `json:"settings"`
}
// AccountSettings defines model for AccountSettings.
type AccountSettings struct {
// PeerLoginExpiration Period of time after which peer login expires (seconds).
PeerLoginExpiration int `json:"peer_login_expiration"`
// PeerLoginExpirationEnabled Enables or disables peer login expiration globally. After peer's login has expired the user has to log in (authenticate). Applies only to peers that were added by a user (interactive SSO login).
PeerLoginExpirationEnabled bool `json:"peer_login_expiration_enabled"`
}
// DNSSettings defines model for DNSSettings.
type DNSSettings struct {
// DisabledManagementGroups Groups whose DNS management is disabled
@ -617,6 +633,11 @@ type UserRequest struct {
Role string `json:"role"`
}
// PutApiAccountsIdJSONBody defines parameters for PutApiAccountsId.
type PutApiAccountsIdJSONBody struct {
Settings AccountSettings `json:"settings"`
}
// PatchApiDnsNameserversIdJSONBody defines parameters for PatchApiDnsNameserversId.
type PatchApiDnsNameserversIdJSONBody = []NameserverGroupPatchOperation
@ -682,6 +703,9 @@ type PutApiRulesIdJSONBody struct {
Sources *[]string `json:"sources,omitempty"`
}
// PutApiAccountsIdJSONRequestBody defines body for PutApiAccountsId for application/json ContentType.
type PutApiAccountsIdJSONRequestBody PutApiAccountsIdJSONBody
// PostApiDnsNameserversJSONRequestBody defines body for PostApiDnsNameservers for application/json ContentType.
type PostApiDnsNameserversJSONRequestBody = NameserverGroupRequest

View File

@ -50,6 +50,10 @@ func APIHandler(accountManager s.AccountManager, appMetrics telemetry.AppMetrics
nameserversHandler := NewNameservers(accountManager, authCfg)
eventsHandler := NewEvents(accountManager, authCfg)
dnsSettingsHandler := NewDNSSettings(accountManager, authCfg)
accountsHandler := NewAccounts(accountManager, authCfg)
apiHandler.HandleFunc("/accounts/{id}", accountsHandler.UpdateAccountHandler).Methods("PUT", "OPTIONS")
apiHandler.HandleFunc("/accounts", accountsHandler.GetAccountsHandler).Methods("GET", "OPTIONS")
apiHandler.HandleFunc("/peers", peersHandler.GetPeers).Methods("GET", "OPTIONS")
apiHandler.HandleFunc("/peers/{id}", peersHandler.HandlePeer).

View File

@ -70,6 +70,7 @@ type MockAccountManager struct {
GetPeerFunc func(accountID, peerID, userID string) (*server.Peer, error)
GetAccountByPeerIDFunc func(peerID string) (*server.Account, error)
UpdatePeerLastLoginFunc func(peerID string) error
UpdateAccountSettingsFunc func(accountID, userID string, newSettings *server.Settings) (*server.Account, error)
}
// GetUsersFromAccount mock implementation of GetUsersFromAccount from server.AccountManager interface
@ -553,3 +554,11 @@ func (am *MockAccountManager) UpdatePeerLastLogin(peerID string) error {
}
return status.Errorf(codes.Unimplemented, "method UpdatePeerLastLogin is not implemented")
}
// UpdateAccountSettings mocks UpdateAccountSettings of the AccountManager interface
func (am *MockAccountManager) UpdateAccountSettings(accountID, userID string, newSettings *server.Settings) (*server.Account, error) {
if am.UpdateAccountSettingsFunc != nil {
return am.UpdateAccountSettingsFunc(accountID, userID, newSettings)
}
return nil, status.Errorf(codes.Unimplemented, "method UpdateAccountSettings is not implemented")
}