mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-28 13:16:39 +00:00
Compare commits
6 Commits
drop-netma
...
feat/pat-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb5cf75e6 | ||
|
|
cab078d800 | ||
|
|
e5a70007eb | ||
|
|
59569c5147 | ||
|
|
0c71aca86d | ||
|
|
be9f1b46e6 |
8
management/server/account/pat.go
Normal file
8
management/server/account/pat.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PATMinExpireDays is the minimum allowed Personal Access Token expiration in days.
|
||||||
|
PATMinExpireDays = 1
|
||||||
|
// PATMaxExpireDays is the maximum allowed Personal Access Token expiration in days.
|
||||||
|
PATMaxExpireDays = 365
|
||||||
|
)
|
||||||
@@ -62,9 +62,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const apiPrefix = "/api"
|
||||||
apiPrefix = "/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
|
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
|
||||||
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter) (http.Handler, error) {
|
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter) (http.Handler, error) {
|
||||||
@@ -141,7 +139,7 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
|||||||
zonesManager.RegisterEndpoints(router, zManager)
|
zonesManager.RegisterEndpoints(router, zManager)
|
||||||
recordsManager.RegisterEndpoints(router, rManager)
|
recordsManager.RegisterEndpoints(router, rManager)
|
||||||
idp.AddEndpoints(accountManager, router)
|
idp.AddEndpoints(accountManager, router)
|
||||||
instance.AddEndpoints(instanceManager, router)
|
instance.AddEndpoints(instanceManager, accountManager, router)
|
||||||
instance.AddVersionEndpoint(instanceManager, router)
|
instance.AddVersionEndpoint(instanceManager, router)
|
||||||
if serviceManager != nil && reverseProxyDomainManager != nil {
|
if serviceManager != nil && reverseProxyDomainManager != nil {
|
||||||
reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router)
|
reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
nbinstance "github.com/netbirdio/netbird/management/server/instance"
|
nbinstance "github.com/netbirdio/netbird/management/server/instance"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||||
@@ -15,13 +16,15 @@ import (
|
|||||||
// handler handles the instance setup HTTP endpoints
|
// handler handles the instance setup HTTP endpoints
|
||||||
type handler struct {
|
type handler struct {
|
||||||
instanceManager nbinstance.Manager
|
instanceManager nbinstance.Manager
|
||||||
|
setupManager *nbinstance.SetupService
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddEndpoints registers the instance setup endpoints.
|
// AddEndpoints registers the instance setup endpoints.
|
||||||
// These endpoints bypass authentication for initial setup.
|
// These endpoints bypass authentication for initial setup.
|
||||||
func AddEndpoints(instanceManager nbinstance.Manager, router *mux.Router) {
|
func AddEndpoints(instanceManager nbinstance.Manager, accountManager account.Manager, router *mux.Router) {
|
||||||
h := &handler{
|
h := &handler{
|
||||||
instanceManager: instanceManager,
|
instanceManager: instanceManager,
|
||||||
|
setupManager: nbinstance.NewSetupService(instanceManager, accountManager),
|
||||||
}
|
}
|
||||||
|
|
||||||
router.HandleFunc("/instance", h.getInstanceStatus).Methods("GET", "OPTIONS")
|
router.HandleFunc("/instance", h.getInstanceStatus).Methods("GET", "OPTIONS")
|
||||||
@@ -55,24 +58,36 @@ func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
// setup creates the initial admin user for the instance.
|
// setup creates the initial admin user for the instance.
|
||||||
// This endpoint is unauthenticated but only works when setup is required.
|
// This endpoint is unauthenticated but only works when setup is required.
|
||||||
func (h *handler) setup(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) setup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
var req api.SetupRequest
|
var req api.SetupRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w)
|
util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userData, err := h.instanceManager.CreateOwnerUser(r.Context(), req.Email, req.Password, req.Name)
|
result, err := h.setupManager.SetupOwner(ctx, req.Email, req.Password, req.Name, nbinstance.SetupOptions{
|
||||||
|
CreatePAT: req.CreatePat != nil && *req.CreatePat,
|
||||||
|
PATExpireInDays: req.PatExpireIn,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.WriteError(r.Context(), err, w)
|
util.WriteError(ctx, err, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithContext(r.Context()).Infof("instance setup completed: created user %s", req.Email)
|
log.WithContext(ctx).Infof("instance setup completed: created user %s", req.Email)
|
||||||
|
|
||||||
util.WriteJSONObject(r.Context(), w, api.SetupResponse{
|
resp := api.SetupResponse{
|
||||||
UserId: userData.ID,
|
UserId: result.User.ID,
|
||||||
Email: userData.Email,
|
Email: result.User.Email,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if result.PATPlainToken != "" {
|
||||||
|
resp.PersonalAccessToken = &result.PATPlainToken
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
util.WriteJSONObject(ctx, w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVersionInfo returns version information for NetBird components.
|
// getVersionInfo returns version information for NetBird components.
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
"github.com/netbirdio/netbird/management/server/idp"
|
"github.com/netbirdio/netbird/management/server/idp"
|
||||||
nbinstance "github.com/netbirdio/netbird/management/server/instance"
|
nbinstance "github.com/netbirdio/netbird/management/server/instance"
|
||||||
|
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||||
|
"github.com/netbirdio/netbird/management/server/types"
|
||||||
|
"github.com/netbirdio/netbird/shared/auth"
|
||||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||||
"github.com/netbirdio/netbird/shared/management/status"
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
)
|
)
|
||||||
@@ -25,6 +29,7 @@ type mockInstanceManager struct {
|
|||||||
isSetupRequired bool
|
isSetupRequired bool
|
||||||
isSetupRequiredFn func(ctx context.Context) (bool, error)
|
isSetupRequiredFn func(ctx context.Context) (bool, error)
|
||||||
createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||||
|
rollbackSetupFn func(ctx context.Context, userID string) error
|
||||||
getVersionInfoFn func(ctx context.Context) (*nbinstance.VersionInfo, error)
|
getVersionInfoFn func(ctx context.Context) (*nbinstance.VersionInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +72,13 @@ func (m *mockInstanceManager) CreateOwnerUser(ctx context.Context, email, passwo
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockInstanceManager) RollbackSetup(ctx context.Context, userID string) error {
|
||||||
|
if m.rollbackSetupFn != nil {
|
||||||
|
return m.rollbackSetupFn(ctx, userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockInstanceManager) GetVersionInfo(ctx context.Context) (*nbinstance.VersionInfo, error) {
|
func (m *mockInstanceManager) GetVersionInfo(ctx context.Context) (*nbinstance.VersionInfo, error) {
|
||||||
if m.getVersionInfoFn != nil {
|
if m.getVersionInfoFn != nil {
|
||||||
return m.getVersionInfoFn(ctx)
|
return m.getVersionInfoFn(ctx)
|
||||||
@@ -82,8 +94,12 @@ func (m *mockInstanceManager) GetVersionInfo(ctx context.Context) (*nbinstance.V
|
|||||||
var _ nbinstance.Manager = (*mockInstanceManager)(nil)
|
var _ nbinstance.Manager = (*mockInstanceManager)(nil)
|
||||||
|
|
||||||
func setupTestRouter(manager nbinstance.Manager) *mux.Router {
|
func setupTestRouter(manager nbinstance.Manager) *mux.Router {
|
||||||
|
return setupTestRouterWithPAT(manager, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestRouterWithPAT(manager nbinstance.Manager, accountManager account.Manager) *mux.Router {
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
AddEndpoints(manager, router)
|
AddEndpoints(manager, accountManager, router)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +177,7 @@ func TestSetup_Success(t *testing.T) {
|
|||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
|
||||||
|
|
||||||
var response api.SetupResponse
|
var response api.SetupResponse
|
||||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||||
@@ -293,6 +310,221 @@ func TestSetup_ManagerError(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetup_PAT_FeatureDisabled_IgnoresCreatePAT(t *testing.T) {
|
||||||
|
t.Setenv(nbinstance.SetupPATEnabledEnvKey, "false")
|
||||||
|
|
||||||
|
manager := &mockInstanceManager{isSetupRequired: true}
|
||||||
|
// NB_SETUP_PAT_ENABLED=false: request fields must be silently ignored
|
||||||
|
router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{})
|
||||||
|
|
||||||
|
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var response api.SetupResponse
|
||||||
|
require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||||
|
assert.Nil(t, response.PersonalAccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_PAT_FlagOmitted_NoPAT(t *testing.T) {
|
||||||
|
t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
manager := &mockInstanceManager{isSetupRequired: true}
|
||||||
|
router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{})
|
||||||
|
|
||||||
|
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var response api.SetupResponse
|
||||||
|
require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||||
|
assert.Nil(t, response.PersonalAccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_PAT_MissingExpireIn_DefaultsToOneDay(t *testing.T) {
|
||||||
|
t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
createCalled := false
|
||||||
|
manager := &mockInstanceManager{
|
||||||
|
isSetupRequired: true,
|
||||||
|
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||||
|
createCalled = true
|
||||||
|
return &idp.UserData{ID: "u1", Email: email, Name: name}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
accountMgr := &mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) {
|
||||||
|
assert.Equal(t, "u1", userAuth.UserId)
|
||||||
|
return "acc-1", nil
|
||||||
|
},
|
||||||
|
CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) {
|
||||||
|
assert.Equal(t, "acc-1", accountID)
|
||||||
|
assert.Equal(t, "u1", initiator)
|
||||||
|
assert.Equal(t, "u1", target)
|
||||||
|
assert.Equal(t, "setup-token", name)
|
||||||
|
assert.Equal(t, 1, expiresIn)
|
||||||
|
return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := setupTestRouterWithPAT(manager, accountMgr)
|
||||||
|
|
||||||
|
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.True(t, createCalled)
|
||||||
|
var response api.SetupResponse
|
||||||
|
require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||||
|
require.NotNil(t, response.PersonalAccessToken)
|
||||||
|
assert.Equal(t, "nbp_plain", *response.PersonalAccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_PAT_ExpireOutOfRange(t *testing.T) {
|
||||||
|
t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
manager := &mockInstanceManager{isSetupRequired: true}
|
||||||
|
router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{})
|
||||||
|
|
||||||
|
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 0}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_PAT_Success(t *testing.T) {
|
||||||
|
t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
manager := &mockInstanceManager{
|
||||||
|
isSetupRequired: true,
|
||||||
|
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||||
|
return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gotAccountArgs := struct {
|
||||||
|
userID string
|
||||||
|
email string
|
||||||
|
}{}
|
||||||
|
accountMgr := &mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) {
|
||||||
|
gotAccountArgs.userID = userAuth.UserId
|
||||||
|
gotAccountArgs.email = userAuth.Email
|
||||||
|
return "acc-1", nil
|
||||||
|
},
|
||||||
|
CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) {
|
||||||
|
assert.Equal(t, "acc-1", accountID)
|
||||||
|
assert.Equal(t, "owner-id", initiator)
|
||||||
|
assert.Equal(t, "owner-id", target)
|
||||||
|
assert.Equal(t, "setup-token", name)
|
||||||
|
assert.Equal(t, 30, expiresIn)
|
||||||
|
return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := setupTestRouterWithPAT(manager, accountMgr)
|
||||||
|
|
||||||
|
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
|
||||||
|
var response api.SetupResponse
|
||||||
|
require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||||
|
assert.Equal(t, "owner-id", response.UserId)
|
||||||
|
require.NotNil(t, response.PersonalAccessToken)
|
||||||
|
assert.Equal(t, "nbp_plain", *response.PersonalAccessToken)
|
||||||
|
assert.Equal(t, "owner-id", gotAccountArgs.userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_PAT_AccountCreationFails_Rollback(t *testing.T) {
|
||||||
|
t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
rolledBackFor := ""
|
||||||
|
manager := &mockInstanceManager{
|
||||||
|
isSetupRequired: true,
|
||||||
|
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||||
|
return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil
|
||||||
|
},
|
||||||
|
rollbackSetupFn: func(_ context.Context, userID string) error {
|
||||||
|
rolledBackFor = userID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
accountMgr := &mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) {
|
||||||
|
return "", errors.New("db down")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := setupTestRouterWithPAT(manager, accountMgr)
|
||||||
|
|
||||||
|
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||||
|
assert.Equal(t, "owner-id", rolledBackFor, "RollbackSetup must be called with the created user id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup_PAT_CreatePATFails_Rollback(t *testing.T) {
|
||||||
|
t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
rolledBackFor := ""
|
||||||
|
manager := &mockInstanceManager{
|
||||||
|
isSetupRequired: true,
|
||||||
|
createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||||
|
return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil
|
||||||
|
},
|
||||||
|
rollbackSetupFn: func(_ context.Context, userID string) error {
|
||||||
|
rolledBackFor = userID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
accountMgr := &mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) {
|
||||||
|
return "acc-1", nil
|
||||||
|
},
|
||||||
|
CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) {
|
||||||
|
return nil, status.Errorf(status.Internal, "token store unavailable")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := setupTestRouterWithPAT(manager, accountMgr)
|
||||||
|
|
||||||
|
body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||||
|
assert.Equal(t, "owner-id", rolledBackFor, "RollbackSetup must be called when CreatePAT fails")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetVersionInfo_Success(t *testing.T) {
|
func TestGetVersionInfo_Success(t *testing.T) {
|
||||||
manager := &mockInstanceManager{}
|
manager := &mockInstanceManager{}
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dexidp/dex/storage"
|
||||||
goversion "github.com/hashicorp/go-version"
|
goversion "github.com/hashicorp/go-version"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@@ -60,6 +61,13 @@ type Manager interface {
|
|||||||
// This should only be called when IsSetupRequired returns true.
|
// This should only be called when IsSetupRequired returns true.
|
||||||
CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||||
|
|
||||||
|
// RollbackSetup reverses a successful CreateOwnerUser by deleting the user
|
||||||
|
// from the embedded IDP and reloading setupRequired from persistent state, so
|
||||||
|
// /api/setup can be retried only when no accounts or local users remain. Used
|
||||||
|
// when post-user steps (account or PAT creation) fail and the caller wants a
|
||||||
|
// clean slate.
|
||||||
|
RollbackSetup(ctx context.Context, userID string) error
|
||||||
|
|
||||||
// GetVersionInfo returns version information for NetBird components.
|
// GetVersionInfo returns version information for NetBird components.
|
||||||
GetVersionInfo(ctx context.Context) (*VersionInfo, error)
|
GetVersionInfo(ctx context.Context) (*VersionInfo, error)
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,7 @@ type instanceStore interface {
|
|||||||
|
|
||||||
type embeddedIdP interface {
|
type embeddedIdP interface {
|
||||||
CreateUserWithPassword(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
CreateUserWithPassword(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||||
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
GetAllAccounts(ctx context.Context) (map[string][]*idp.UserData, error)
|
GetAllAccounts(ctx context.Context) (map[string][]*idp.UserData, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +196,51 @@ func (m *DefaultManager) CreateOwnerUser(ctx context.Context, email, password, n
|
|||||||
return userData, nil
|
return userData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RollbackSetup undoes a successful CreateOwnerUser: deletes the user from the
|
||||||
|
// embedded IDP and reloads setupRequired from persistent state.
|
||||||
|
func (m *DefaultManager) RollbackSetup(ctx context.Context, userID string) error {
|
||||||
|
if m.embeddedIdpManager == nil {
|
||||||
|
return errors.New("embedded IDP is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteErr error
|
||||||
|
if err := m.embeddedIdpManager.DeleteUser(ctx, userID); err != nil {
|
||||||
|
if isNotFoundError(err) {
|
||||||
|
log.WithContext(ctx).Debugf("setup rollback user %s already deleted", userID)
|
||||||
|
} else {
|
||||||
|
deleteErr = fmt.Errorf("failed to delete user from embedded IdP: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.loadSetupRequired(ctx); err != nil {
|
||||||
|
reloadErr := fmt.Errorf("failed to reload setup state after rollback: %w", err)
|
||||||
|
if deleteErr != nil {
|
||||||
|
return errors.Join(deleteErr, reloadErr)
|
||||||
|
}
|
||||||
|
return reloadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteErr != nil {
|
||||||
|
return deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithContext(ctx).Infof("rolled back setup for user %s", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNotFoundError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s, ok := status.FromError(err); ok {
|
||||||
|
return s.Type() == status.NotFound
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (m *DefaultManager) checkSetupRequiredFromDB(ctx context.Context) error {
|
func (m *DefaultManager) checkSetupRequiredFromDB(ctx context.Context) error {
|
||||||
numAccounts, err := m.store.GetAccountsCounter(ctx)
|
numAccounts, err := m.store.GetAccountsCounter(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dexidp/dex/storage"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/management/server/idp"
|
"github.com/netbirdio/netbird/management/server/idp"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockIdP struct {
|
type mockIdP struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
createUserFunc func(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
createUserFunc func(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||||
|
deleteUserFunc func(ctx context.Context, userID string) error
|
||||||
users map[string][]*idp.UserData
|
users map[string][]*idp.UserData
|
||||||
getAllAccountsErr error
|
getAllAccountsErr error
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,13 @@ func (m *mockIdP) CreateUserWithPassword(ctx context.Context, email, password, n
|
|||||||
return &idp.UserData{ID: "test-user-id", Email: email, Name: name}, nil
|
return &idp.UserData{ID: "test-user-id", Email: email, Name: name}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockIdP) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
if m.deleteUserFunc != nil {
|
||||||
|
return m.deleteUserFunc(ctx, userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockIdP) GetAllAccounts(_ context.Context) (map[string][]*idp.UserData, error) {
|
func (m *mockIdP) GetAllAccounts(_ context.Context) (map[string][]*idp.UserData, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -223,6 +233,77 @@ func TestIsSetupRequired_ReturnsFlag(t *testing.T) {
|
|||||||
assert.False(t, required)
|
assert.False(t, required)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRollbackSetup_UserAlreadyDeletedIsSuccess(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "management status not found",
|
||||||
|
err: status.NewUserNotFoundError("owner-id"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dex storage not found",
|
||||||
|
err: fmt.Errorf("failed to get user for deletion: %w", storage.ErrNotFound),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
idpMock := &mockIdP{
|
||||||
|
deleteUserFunc: func(_ context.Context, userID string) error {
|
||||||
|
assert.Equal(t, "owner-id", userID)
|
||||||
|
return tt.err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mgr := newTestManager(idpMock, &mockStore{})
|
||||||
|
mgr.setupRequired = false
|
||||||
|
|
||||||
|
err := mgr.RollbackSetup(context.Background(), "owner-id")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
required, err := mgr.IsSetupRequired(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, required, "setup should be required when no accounts or local users remain")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollbackSetup_RecomputesSetupStateWhenAccountStillExists(t *testing.T) {
|
||||||
|
idpMock := &mockIdP{
|
||||||
|
deleteUserFunc: func(_ context.Context, _ string) error {
|
||||||
|
return status.NewUserNotFoundError("owner-id")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mgr := newTestManager(idpMock, &mockStore{accountsCount: 1})
|
||||||
|
mgr.setupRequired = true
|
||||||
|
|
||||||
|
err := mgr.RollbackSetup(context.Background(), "owner-id")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
required, err := mgr.IsSetupRequired(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, required, "setup should not be required while an account still exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollbackSetup_ReturnsDeleteErrorButReloadsSetupState(t *testing.T) {
|
||||||
|
idpMock := &mockIdP{
|
||||||
|
deleteUserFunc: func(_ context.Context, _ string) error {
|
||||||
|
return errors.New("idp unavailable")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mgr := newTestManager(idpMock, &mockStore{})
|
||||||
|
mgr.setupRequired = false
|
||||||
|
|
||||||
|
err := mgr.RollbackSetup(context.Background(), "owner-id")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "idp unavailable")
|
||||||
|
|
||||||
|
required, err := mgr.IsSetupRequired(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, required, "setup state should be reloaded even when user deletion fails")
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultManager_ValidateSetupRequest(t *testing.T) {
|
func TestDefaultManager_ValidateSetupRequest(t *testing.T) {
|
||||||
manager := &DefaultManager{setupRequired: true}
|
manager := &DefaultManager{setupRequired: true}
|
||||||
|
|
||||||
|
|||||||
206
management/server/instance/setup_service.go
Normal file
206
management/server/instance/setup_service.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package instance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
|
"github.com/netbirdio/netbird/management/server/idp"
|
||||||
|
"github.com/netbirdio/netbird/management/server/store"
|
||||||
|
"github.com/netbirdio/netbird/shared/auth"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
setupPATTokenName = "setup-token"
|
||||||
|
|
||||||
|
// SetupPATEnabledEnvKey enables setup-time Personal Access Token creation.
|
||||||
|
SetupPATEnabledEnvKey = "NB_SETUP_PAT_ENABLED"
|
||||||
|
|
||||||
|
setupPATDefaultExpireDays = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupOptions controls optional work performed during initial instance setup.
|
||||||
|
type SetupOptions struct {
|
||||||
|
// CreatePAT requests creation of a setup Personal Access Token. It is honored
|
||||||
|
// only when SetupPATEnabledEnvKey is set to "true".
|
||||||
|
CreatePAT bool
|
||||||
|
// PATExpireInDays defaults to 1 day when CreatePAT is requested and setup PAT
|
||||||
|
// creation is enabled.
|
||||||
|
PATExpireInDays *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupResult contains resources created during initial instance setup.
|
||||||
|
type SetupResult struct {
|
||||||
|
User *idp.UserData
|
||||||
|
PATPlainToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupService orchestrates the initial setup use case across the instance and
|
||||||
|
// account bounded contexts and owns the compensation logic when a later step
|
||||||
|
// fails.
|
||||||
|
type SetupService struct {
|
||||||
|
instanceManager Manager
|
||||||
|
accountManager account.Manager
|
||||||
|
setupPATEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSetupService creates a setup use-case service.
|
||||||
|
func NewSetupService(instanceManager Manager, accountManager account.Manager) *SetupService {
|
||||||
|
return &SetupService{
|
||||||
|
instanceManager: instanceManager,
|
||||||
|
accountManager: accountManager,
|
||||||
|
setupPATEnabled: os.Getenv(SetupPATEnabledEnvKey) == "true",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSetupOptions(opts SetupOptions, setupPATEnabled bool) (SetupOptions, error) {
|
||||||
|
if !opts.CreatePAT {
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !setupPATEnabled {
|
||||||
|
opts.CreatePAT = false
|
||||||
|
opts.PATExpireInDays = nil
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.PATExpireInDays == nil {
|
||||||
|
defaultExpireInDays := setupPATDefaultExpireDays
|
||||||
|
opts.PATExpireInDays = &defaultExpireInDays
|
||||||
|
}
|
||||||
|
|
||||||
|
if *opts.PATExpireInDays < account.PATMinExpireDays || *opts.PATExpireInDays > account.PATMaxExpireDays {
|
||||||
|
return opts, status.Errorf(status.InvalidArgument, "pat_expire_in must be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupOwner creates the initial owner user and, when requested and enabled by
|
||||||
|
// SetupPATEnabledEnvKey, provisions the account and a setup Personal Access
|
||||||
|
// Token. If account or PAT provisioning fails, created resources are rolled
|
||||||
|
// back so setup can be retried.
|
||||||
|
func (m *SetupService) SetupOwner(ctx context.Context, email, password, name string, opts SetupOptions) (*SetupResult, error) {
|
||||||
|
opts, err := normalizeSetupOptions(opts, m.setupPATEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.CreatePAT && m.accountManager == nil {
|
||||||
|
return nil, fmt.Errorf("account manager is required to create setup PAT")
|
||||||
|
}
|
||||||
|
|
||||||
|
userData, err := m.instanceManager.CreateOwnerUser(ctx, email, password, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &SetupResult{User: userData}
|
||||||
|
if !opts.CreatePAT {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userAuth := auth.UserAuth{
|
||||||
|
UserId: userData.ID,
|
||||||
|
Email: userData.Email,
|
||||||
|
Name: userData.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := m.accountManager.GetAccountIDByUserID(ctx, userAuth)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("create account for setup user: %w", err)
|
||||||
|
m.rollbackSetup(ctx, userData.ID, "account provisioning failed", err, "")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pat, err := m.accountManager.CreatePAT(ctx, accountID, userData.ID, userData.ID, setupPATTokenName, *opts.PATExpireInDays)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("create setup PAT: %w", err)
|
||||||
|
m.rollbackSetup(ctx, userData.ID, "setup PAT provisioning failed", err, accountID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result.PATPlainToken = pat.PlainToken
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SetupService) rollbackSetup(ctx context.Context, userID, reason string, origErr error, accountID string) {
|
||||||
|
if accountID == "" {
|
||||||
|
resolvedAccountID, err := m.lookupSetupAccountIDForRollback(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("failed to resolve setup account for user %s after %s: original error: %v, rollback error: %v", userID, reason, origErr, err)
|
||||||
|
}
|
||||||
|
accountID = resolvedAccountID
|
||||||
|
}
|
||||||
|
|
||||||
|
if accountID != "" {
|
||||||
|
if err := m.rollbackSetupAccount(ctx, accountID); err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("failed to roll back setup account %s for user %s after %s: original error: %v, rollback error: %v", accountID, userID, reason, origErr, err)
|
||||||
|
} else {
|
||||||
|
log.WithContext(ctx).Warnf("rolled back setup account %s for user %s after %s: %v", accountID, userID, reason, origErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.instanceManager.RollbackSetup(ctx, userID); err != nil {
|
||||||
|
log.WithContext(ctx).Errorf("failed to roll back setup user %s after %s: original error: %v, rollback error: %v", userID, reason, origErr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.WithContext(ctx).Warnf("rolled back setup user %s after %s: %v", userID, reason, origErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SetupService) lookupSetupAccountIDForRollback(ctx context.Context, userID string) (string, error) {
|
||||||
|
if m.accountManager == nil {
|
||||||
|
return "", fmt.Errorf("account manager is required to resolve setup account")
|
||||||
|
}
|
||||||
|
|
||||||
|
accountStore := m.accountManager.GetStore()
|
||||||
|
if accountStore == nil {
|
||||||
|
return "", fmt.Errorf("account store is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID, err := accountStore.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFoundError(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("get setup account ID for rollback: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackSetupAccount removes only the setup-created account data from the
|
||||||
|
// store. It intentionally avoids accountManager.DeleteAccount because the normal
|
||||||
|
// account deletion path also deletes users from the IdP; embedded IdP cleanup is
|
||||||
|
// owned by instanceManager.RollbackSetup.
|
||||||
|
func (m *SetupService) rollbackSetupAccount(ctx context.Context, accountID string) error {
|
||||||
|
if m.accountManager == nil {
|
||||||
|
return fmt.Errorf("account manager is required to roll back setup account")
|
||||||
|
}
|
||||||
|
|
||||||
|
accountStore := m.accountManager.GetStore()
|
||||||
|
if accountStore == nil {
|
||||||
|
return fmt.Errorf("account store is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := accountStore.GetAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFoundError(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("get setup account for rollback: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := accountStore.DeleteAccount(ctx, account); err != nil {
|
||||||
|
if isNotFoundError(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delete setup account for rollback: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
311
management/server/instance/setup_service_test.go
Normal file
311
management/server/instance/setup_service_test.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
package instance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/server/idp"
|
||||||
|
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||||
|
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||||
|
"github.com/netbirdio/netbird/management/server/types"
|
||||||
|
"github.com/netbirdio/netbird/shared/auth"
|
||||||
|
"github.com/netbirdio/netbird/shared/management/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
type setupInstanceManagerMock struct {
|
||||||
|
createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error)
|
||||||
|
rollbackSetupFn func(ctx context.Context, userID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *setupInstanceManagerMock) IsSetupRequired(context.Context) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *setupInstanceManagerMock) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) {
|
||||||
|
if m.createOwnerUserFn != nil {
|
||||||
|
return m.createOwnerUserFn(ctx, email, password, name)
|
||||||
|
}
|
||||||
|
return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *setupInstanceManagerMock) RollbackSetup(ctx context.Context, userID string) error {
|
||||||
|
if m.rollbackSetupFn != nil {
|
||||||
|
return m.rollbackSetupFn(ctx, userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *setupInstanceManagerMock) GetVersionInfo(context.Context) (*VersionInfo, error) {
|
||||||
|
return &VersionInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Manager = (*setupInstanceManagerMock)(nil)
|
||||||
|
|
||||||
|
func intPtr(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupOwner_PATFeatureDisabled_IgnoresCreatePAT(t *testing.T) {
|
||||||
|
t.Setenv(SetupPATEnabledEnvKey, "false")
|
||||||
|
|
||||||
|
createCalls := 0
|
||||||
|
setupManager := NewSetupService(
|
||||||
|
&setupInstanceManagerMock{
|
||||||
|
createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) {
|
||||||
|
createCalls++
|
||||||
|
return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&mock_server.MockAccountManager{},
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{
|
||||||
|
CreatePAT: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Equal(t, "owner-id", result.User.ID)
|
||||||
|
assert.Empty(t, result.PATPlainToken)
|
||||||
|
assert.Equal(t, 1, createCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupOwner_PATFeatureEnabled_MissingExpireDefaultsToOneDay(t *testing.T) {
|
||||||
|
t.Setenv(SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
createCalled := false
|
||||||
|
setupManager := NewSetupService(
|
||||||
|
&setupInstanceManagerMock{
|
||||||
|
createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) {
|
||||||
|
createCalled = true
|
||||||
|
return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) {
|
||||||
|
assert.Equal(t, "owner-id", userAuth.UserId)
|
||||||
|
return "acc-1", nil
|
||||||
|
},
|
||||||
|
CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) {
|
||||||
|
assert.Equal(t, "acc-1", accountID)
|
||||||
|
assert.Equal(t, "owner-id", initiatorUserID)
|
||||||
|
assert.Equal(t, "owner-id", targetUserID)
|
||||||
|
assert.Equal(t, setupPATTokenName, tokenName)
|
||||||
|
assert.Equal(t, setupPATDefaultExpireDays, expiresIn)
|
||||||
|
return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{
|
||||||
|
CreatePAT: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.True(t, createCalled)
|
||||||
|
assert.Equal(t, "nbp_plain", result.PATPlainToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupOwner_PATFeatureEnabled_MissingAccountManagerFailsBeforeCreateUser(t *testing.T) {
|
||||||
|
t.Setenv(SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
createCalled := false
|
||||||
|
rollbackCalled := false
|
||||||
|
setupManager := NewSetupService(
|
||||||
|
&setupInstanceManagerMock{
|
||||||
|
createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) {
|
||||||
|
createCalled = true
|
||||||
|
return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil
|
||||||
|
},
|
||||||
|
rollbackSetupFn: func(_ context.Context, _ string) error {
|
||||||
|
rollbackCalled = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{
|
||||||
|
CreatePAT: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "account manager is required")
|
||||||
|
assert.False(t, createCalled)
|
||||||
|
assert.False(t, rollbackCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupOwner_AccountProvisioningFails_RollsBackSideEffectAccountAndUser(t *testing.T) {
|
||||||
|
t.Setenv(SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
accountStore := nbstore.NewMockStore(ctrl)
|
||||||
|
account := &types.Account{Id: "acc-1"}
|
||||||
|
accountStore.EXPECT().GetAccountIDByUserID(gomock.Any(), nbstore.LockingStrengthNone, "owner-id").Return("acc-1", nil)
|
||||||
|
accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil)
|
||||||
|
accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil)
|
||||||
|
|
||||||
|
rolledBackFor := ""
|
||||||
|
setupManager := NewSetupService(
|
||||||
|
&setupInstanceManagerMock{
|
||||||
|
rollbackSetupFn: func(_ context.Context, userID string) error {
|
||||||
|
rolledBackFor = userID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) {
|
||||||
|
assert.Equal(t, "owner-id", userAuth.UserId)
|
||||||
|
return "", errors.New("metadata update failed")
|
||||||
|
},
|
||||||
|
GetStoreFunc: func() nbstore.Store {
|
||||||
|
return accountStore
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{
|
||||||
|
CreatePAT: true,
|
||||||
|
PATExpireInDays: intPtr(30),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "create account for setup user")
|
||||||
|
assert.Equal(t, "owner-id", rolledBackFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupOwner_CreatePATFails_RollsBackSetupAccountAndUser(t *testing.T) {
|
||||||
|
t.Setenv(SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
accountStore := nbstore.NewMockStore(ctrl)
|
||||||
|
account := &types.Account{Id: "acc-1"}
|
||||||
|
accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil)
|
||||||
|
accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil)
|
||||||
|
|
||||||
|
rollbackCalls := 0
|
||||||
|
setupManager := NewSetupService(
|
||||||
|
&setupInstanceManagerMock{
|
||||||
|
rollbackSetupFn: func(_ context.Context, userID string) error {
|
||||||
|
rollbackCalls++
|
||||||
|
assert.Equal(t, "owner-id", userID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) {
|
||||||
|
assert.Equal(t, "owner-id", userAuth.UserId)
|
||||||
|
return "acc-1", nil
|
||||||
|
},
|
||||||
|
CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) {
|
||||||
|
assert.Equal(t, "acc-1", accountID)
|
||||||
|
assert.Equal(t, "owner-id", initiatorUserID)
|
||||||
|
assert.Equal(t, "owner-id", targetUserID)
|
||||||
|
assert.Equal(t, setupPATTokenName, tokenName)
|
||||||
|
assert.Equal(t, 30, expiresIn)
|
||||||
|
return nil, status.Errorf(status.Internal, "token store unavailable")
|
||||||
|
},
|
||||||
|
GetStoreFunc: func() nbstore.Store {
|
||||||
|
return accountStore
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{
|
||||||
|
CreatePAT: true,
|
||||||
|
PATExpireInDays: intPtr(30),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "create setup PAT")
|
||||||
|
assert.Equal(t, 1, rollbackCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupOwner_CreatePATFails_AccountAlreadyGoneStillRollsBackUser(t *testing.T) {
|
||||||
|
t.Setenv(SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
accountStore := nbstore.NewMockStore(ctrl)
|
||||||
|
accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(nil, status.NewAccountNotFoundError("acc-1"))
|
||||||
|
|
||||||
|
rolledBackFor := ""
|
||||||
|
setupManager := NewSetupService(
|
||||||
|
&setupInstanceManagerMock{
|
||||||
|
rollbackSetupFn: func(_ context.Context, userID string) error {
|
||||||
|
rolledBackFor = userID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) {
|
||||||
|
return "acc-1", nil
|
||||||
|
},
|
||||||
|
CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) {
|
||||||
|
return nil, errors.New("token failure")
|
||||||
|
},
|
||||||
|
GetStoreFunc: func() nbstore.Store {
|
||||||
|
return accountStore
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{
|
||||||
|
CreatePAT: true,
|
||||||
|
PATExpireInDays: intPtr(30),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "create setup PAT")
|
||||||
|
assert.Equal(t, "owner-id", rolledBackFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupOwner_CreatePATFails_AccountRollbackFailureStillRollsBackUser(t *testing.T) {
|
||||||
|
t.Setenv(SetupPATEnabledEnvKey, "true")
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
accountStore := nbstore.NewMockStore(ctrl)
|
||||||
|
account := &types.Account{Id: "acc-1"}
|
||||||
|
accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil)
|
||||||
|
accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(errors.New("delete failed"))
|
||||||
|
|
||||||
|
rolledBackFor := ""
|
||||||
|
setupManager := NewSetupService(
|
||||||
|
&setupInstanceManagerMock{
|
||||||
|
rollbackSetupFn: func(_ context.Context, userID string) error {
|
||||||
|
rolledBackFor = userID
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&mock_server.MockAccountManager{
|
||||||
|
GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) {
|
||||||
|
return "acc-1", nil
|
||||||
|
},
|
||||||
|
CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) {
|
||||||
|
return nil, errors.New("token failure")
|
||||||
|
},
|
||||||
|
GetStoreFunc: func() nbstore.Store {
|
||||||
|
return accountStore
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{
|
||||||
|
CreatePAT: true,
|
||||||
|
PATExpireInDays: intPtr(30),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "create setup PAT")
|
||||||
|
assert.Equal(t, "owner-id", rolledBackFor)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/idp/dex"
|
"github.com/netbirdio/netbird/idp/dex"
|
||||||
|
"github.com/netbirdio/netbird/management/server/account"
|
||||||
"github.com/netbirdio/netbird/management/server/activity"
|
"github.com/netbirdio/netbird/management/server/activity"
|
||||||
"github.com/netbirdio/netbird/management/server/idp"
|
"github.com/netbirdio/netbird/management/server/idp"
|
||||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||||
@@ -395,8 +396,8 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string
|
|||||||
return nil, status.Errorf(status.InvalidArgument, "token name can't be empty")
|
return nil, status.Errorf(status.InvalidArgument, "token name can't be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
if expiresIn < 1 || expiresIn > 365 {
|
if expiresIn < account.PATMinExpireDays || expiresIn > account.PATMaxExpireDays {
|
||||||
return nil, status.Errorf(status.InvalidArgument, "expiration has to be between 1 and 365")
|
return nil, status.Errorf(status.InvalidArgument, "expiration has to be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
|
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
|
||||||
|
|||||||
@@ -3425,6 +3425,17 @@ components:
|
|||||||
description: Display name for the admin user (defaults to email if not provided)
|
description: Display name for the admin user (defaults to email if not provided)
|
||||||
type: string
|
type: string
|
||||||
example: Admin User
|
example: Admin User
|
||||||
|
create_pat:
|
||||||
|
description: If true and the server has setup-time PAT issuance enabled (NB_SETUP_PAT_ENABLED=true), create a Personal Access Token for the new owner user and return it in the response. Ignored when the server feature is disabled.
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
pat_expire_in:
|
||||||
|
description: Expiration of the Personal Access Token in days. Applies only when create_pat is true and the server feature is enabled. Defaults to 1 day when omitted.
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 365
|
||||||
|
default: 1
|
||||||
|
example: 30
|
||||||
required:
|
required:
|
||||||
- email
|
- email
|
||||||
- password
|
- password
|
||||||
@@ -3441,6 +3452,10 @@ components:
|
|||||||
description: Email address of the created user
|
description: Email address of the created user
|
||||||
type: string
|
type: string
|
||||||
example: admin@example.com
|
example: admin@example.com
|
||||||
|
personal_access_token:
|
||||||
|
description: Plain text Personal Access Token created during setup. Present only when create_pat was requested and the NB_SETUP_PAT_ENABLED feature was enabled on the server.
|
||||||
|
type: string
|
||||||
|
example: nbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
required:
|
required:
|
||||||
- user_id
|
- user_id
|
||||||
- email
|
- email
|
||||||
@@ -4979,7 +4994,10 @@ paths:
|
|||||||
/api/setup:
|
/api/setup:
|
||||||
post:
|
post:
|
||||||
summary: Setup Instance
|
summary: Setup Instance
|
||||||
description: Creates the initial admin user for the instance. This endpoint does not require authentication but only works when setup is required (no accounts exist and embedded IDP is enabled).
|
description: |
|
||||||
|
Creates the initial admin user for the instance. This endpoint does not require authentication but only works when setup is required (no accounts exist and embedded IDP is enabled).
|
||||||
|
|
||||||
|
When the management server is started with `NB_SETUP_PAT_ENABLED=true` and the request includes `create_pat: true`, the endpoint also provisions the NetBird account for the new owner user and returns the plain text Personal Access Token in `personal_access_token`. The optional `pat_expire_in` value applies only when `create_pat` is true and defaults to 1 day when omitted. If any post-user step fails, created setup resources are rolled back and setup remains retryable.
|
||||||
tags: [ Instance ]
|
tags: [ Instance ]
|
||||||
security: [ ]
|
security: [ ]
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -4992,6 +5010,12 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Setup completed successfully
|
description: Setup completed successfully
|
||||||
|
headers:
|
||||||
|
Cache-Control:
|
||||||
|
description: Always set to no-store because the response may contain a one-time plain text Personal Access Token.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: no-store
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -4294,6 +4294,9 @@ type SetupKeyRequest struct {
|
|||||||
|
|
||||||
// SetupRequest Request to set up the initial admin user
|
// SetupRequest Request to set up the initial admin user
|
||||||
type SetupRequest struct {
|
type SetupRequest struct {
|
||||||
|
// CreatePat If true and the server has setup-time PAT issuance enabled (NB_SETUP_PAT_ENABLED=true), create a Personal Access Token for the new owner user and return it in the response. Ignored when the server feature is disabled.
|
||||||
|
CreatePat *bool `json:"create_pat,omitempty"`
|
||||||
|
|
||||||
// Email Email address for the admin user
|
// Email Email address for the admin user
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
||||||
@@ -4302,6 +4305,9 @@ type SetupRequest struct {
|
|||||||
|
|
||||||
// Password Password for the admin user (minimum 8 characters)
|
// Password Password for the admin user (minimum 8 characters)
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
|
||||||
|
// PatExpireIn Expiration of the Personal Access Token in days. Applies only when create_pat is true and the server feature is enabled. Defaults to 1 day when omitted.
|
||||||
|
PatExpireIn *int `json:"pat_expire_in,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupResponse Response after successful instance setup
|
// SetupResponse Response after successful instance setup
|
||||||
@@ -4309,6 +4315,9 @@ type SetupResponse struct {
|
|||||||
// Email Email address of the created user
|
// Email Email address of the created user
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
||||||
|
// PersonalAccessToken Plain text Personal Access Token created during setup. Present only when create_pat was requested and the NB_SETUP_PAT_ENABLED feature was enabled on the server.
|
||||||
|
PersonalAccessToken *string `json:"personal_access_token,omitempty"`
|
||||||
|
|
||||||
// UserId The ID of the created user
|
// UserId The ID of the created user
|
||||||
UserId string `json:"user_id"`
|
UserId string `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user