Files
netbird/management/server/user.go
T
Eduard Gert 314d88252d [management] Name the account owner in the pending approval error (#7533)
* Name the account owner in the pending approval error

A user refused because their account is pending approval had no way to
learn who could approve them. The refusal now carries the account
owner's address, masked, so a caller can name someone to contact without
being handed the address itself.

Resolving the owner is best effort: a lookup failure, or an account
predating the stored email, falls back to the refusal as it was.

* Name only the caller's own owner in the pending approval error

The refusal is raised before ValidateAccountAccess has established that
the caller belongs to the account the request asked about, and the user
is loaded by ID alone. Resolving the owner of the requested account
therefore disclosed that owner's address to a pending user with no claim
to it, reachable through any handler that takes an account ID from the
caller — DELETE /api/accounts/{accountId} passes one straight through.

The owner who can approve a pending user is the owner of their own
account, so resolve that one. The requested account is never read.

* Mask short local parts whole in MaskedEmail

Keeping the first two characters and the last hides nothing until the
local part is four long: at three or fewer they are the whole of it, so
"abc@example.com" masked to "ab****c@example.com" and a pending user
could recover the owner's address in full from what is meant to conceal
it. Short local parts are now replaced entirely.

* Name the owner from GetCurrentUserInfo instead of the permission gate

The gate could only read the stored user row, which carries no address
when an external IdP owns the identities — the usual case — so it named
no one in practice. It also had no way to reach the IdP without being
handed the account manager, which meant restoring bootstrap wiring that
a refactor had dropped.

GetCurrentUserInfo already holds that account manager, so it answers for
a pending user itself and reuses GetOwnerInfo, the same lookup /msp uses
to resolve an owner's address. The gate returns to exactly what it was,
and with it goes the risk of naming the owner of an account the caller
only asked about.

MaskedEmail becomes MaskEmail: with a UserInfo in hand there is no stored
row to hang it off.

* [management] Cover the pending approval refusal in GetCurrentUserInfo

The branch that names the owner had no coverage at the manager level, so
neither the named refusal nor the fallback for an owner without a resolvable
address was pinned down.

* [management] Cover the failed owner lookup in the pending approval refusal

The generic fallback has two ways in: no address on the resolved owner, and no
owner to resolve at all. Only the first was pinned down.

* [management] Pin the owner lookup to the caller's own account

A mismatched account claim must not steer which owner the refusal names, and
a blocked user is still answered before the claim is validated. Both are load
bearing and neither was covered.
2026-09-21 10:34:04 +02:00

2022 lines
70 KiB
Go

package server
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/auth"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"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/affectedpeers"
"github.com/netbirdio/netbird/management/server/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// createServiceUser creates a new service user under the given account.
func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountID string, initiatorUserID string, role types.UserRole, serviceUserName string, nonDeletable bool, autoGroups []string) (*types.UserInfo, error) {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
if role == types.UserRoleOwner {
return nil, status.NewServiceUserRoleInvalidError()
}
newUserID := uuid.New().String()
newUser := types.NewUser(newUserID, role, true, nonDeletable, serviceUserName, autoGroups, types.UserIssuedAPI, "", "")
newUser.AccountID = accountID
log.WithContext(ctx).Debugf("New User: %v", newUser)
if err = am.Store.SaveUser(ctx, newUser); err != nil {
return nil, err
}
meta := map[string]any{"name": newUser.ServiceUserName}
am.StoreEvent(ctx, initiatorUserID, newUser.Id, accountID, activity.ServiceUserCreated, meta)
return &types.UserInfo{
ID: newUser.Id,
Email: "",
Name: newUser.ServiceUserName,
Role: string(newUser.Role),
AutoGroups: newUser.AutoGroups,
Status: string(types.UserStatusActive),
IsServiceUser: true,
LastLogin: time.Time{},
Issued: types.UserIssuedAPI,
}, nil
}
// CreateUser creates a new user under the given account. Effectively this is a user invite.
func (am *DefaultAccountManager) CreateUser(ctx context.Context, accountID, userID string, user *types.UserInfo) (*types.UserInfo, error) {
if user.IsServiceUser {
return am.createServiceUser(ctx, accountID, userID, types.StrRoleToUserRole(user.Role), user.Name, user.NonDeletable, user.AutoGroups)
}
return am.inviteNewUser(ctx, accountID, userID, user)
}
// inviteNewUser Invites a USer to a given account and creates reference in datastore
func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, userID string, invite *types.UserInfo) (*types.UserInfo, error) {
if am.idpManager == nil {
return nil, status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites")
}
if err := validateUserInvite(invite); err != nil {
return nil, err
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, err
}
inviterID := userID
if initiatorUser.IsServiceUser {
createdBy, err := am.Store.GetAccountCreatedBy(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
inviterID = createdBy
}
var idpUser *idp.UserData
if IsEmbeddedIdp(am.idpManager) {
idpUser, err = am.createEmbeddedIdpUser(ctx, accountID, inviterID, invite)
} else {
idpUser, err = am.createNewIdpUser(ctx, accountID, inviterID, invite)
}
if err != nil {
return nil, err
}
newUser := &types.User{
Id: idpUser.ID,
AccountID: accountID,
Role: types.StrRoleToUserRole(invite.Role),
AutoGroups: invite.AutoGroups,
Issued: invite.Issued,
IntegrationReference: invite.IntegrationReference,
CreatedAt: time.Now().UTC(),
Email: invite.Email,
Name: invite.Name,
}
if err = am.Store.SaveUser(ctx, newUser); err != nil {
return nil, err
}
if !IsEmbeddedIdp(am.idpManager) {
_, err = am.refreshCache(ctx, accountID)
if err != nil {
return nil, err
}
}
eventType := activity.UserInvited
if IsEmbeddedIdp(am.idpManager) {
eventType = activity.UserCreated
}
am.StoreEvent(ctx, userID, newUser.Id, accountID, eventType, nil)
return newUser.ToUserInfo(idpUser)
}
// createNewIdpUser validates the invite and creates a new user in the IdP
func (am *DefaultAccountManager) createNewIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) {
inviter, err := am.GetUserByID(ctx, inviterID)
if err != nil {
return nil, fmt.Errorf("failed to get inviter user: %w", err)
}
// inviterUser is the one who is inviting the new user
inviterUser, err := am.lookupUserInCache(ctx, inviterID, inviter.AccountID)
if err != nil {
return nil, status.Errorf(status.NotFound, "inviter user with ID %s doesn't exist in IdP", inviterID)
}
if inviterUser == nil {
return nil, status.Errorf(status.NotFound, "inviter user with ID %s is empty", inviterID)
}
// check if the user is already registered with this email => reject
user, err := am.lookupUserInCacheByEmail(ctx, invite.Email, accountID)
if err != nil {
return nil, err
}
if user != nil {
return nil, status.Errorf(status.UserAlreadyExists, "can't invite a user with an existing NetBird account")
}
users, err := am.idpManager.GetUserByEmail(ctx, invite.Email)
if err != nil {
return nil, err
}
if len(users) > 0 {
return nil, status.Errorf(status.UserAlreadyExists, "can't invite a user with an existing NetBird account")
}
return am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviterUser.Email)
}
// createEmbeddedIdpUser validates the invite and creates a new user in the embedded IdP.
// Unlike createNewIdpUser, this method fetches user data directly from the database
// since the embedded IdP usage ensures the username and email are stored locally in the User table.
func (am *DefaultAccountManager) createEmbeddedIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) {
if IsLocalAuthDisabled(ctx, am.idpManager) {
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
}
inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, inviterID)
if err != nil {
return nil, fmt.Errorf("failed to get inviter user: %w", err)
}
if inviter == nil {
return nil, status.Errorf(status.NotFound, "inviter user with ID %s doesn't exist", inviterID)
}
// check if the user is already registered with this email => reject
existingUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
for _, user := range existingUsers {
if strings.EqualFold(user.Email, invite.Email) {
return nil, status.Errorf(status.UserAlreadyExists, "can't invite a user with an existing NetBird account")
}
}
return am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviter.Email)
}
func (am *DefaultAccountManager) GetUserByID(ctx context.Context, id string) (*types.User, error) {
return am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, id)
}
// GetUser looks up a user by provided auth.UserAuths.
// Expects account to have been created already.
func (am *DefaultAccountManager) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) {
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId)
if err != nil {
return nil, err
}
// this code should be outside of the am.GetAccountIDFromToken(claims) because this method is called also by the gRPC
// server when user authenticates a device. And we need to separate the Dashboard login event from the Device login event.
newLogin := user.LastDashboardLoginChanged(userAuth.LastLogin)
err = am.Store.SaveUserLastLogin(ctx, userAuth.AccountId, userAuth.UserId, userAuth.LastLogin)
if err != nil {
log.WithContext(ctx).Debugf("failed to update user last login: %v", err)
}
if newLogin {
meta := map[string]any{"timestamp": userAuth.LastLogin}
am.StoreEvent(ctx, userAuth.UserId, userAuth.UserId, userAuth.AccountId, activity.DashboardLogin, meta)
}
return user, nil
}
// ListUsers returns lists of all users under the account.
// It doesn't populate user information such as email or name.
func (am *DefaultAccountManager) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) {
return am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
}
// UpdateUserPassword updates the password for a user in the embedded IdP.
// This is only available when the embedded IdP is enabled.
// Users can only change their own password.
func (am *DefaultAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error {
if !IsEmbeddedIdp(am.idpManager) {
return status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider")
}
if oldPassword == "" {
return status.Errorf(status.InvalidArgument, "old password is required")
}
if newPassword == "" {
return status.Errorf(status.InvalidArgument, "new password is required")
}
embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return status.Errorf(status.Internal, "failed to get embedded IdP manager")
}
err := embeddedIdp.UpdateUserPassword(ctx, currentUserID, targetUserID, oldPassword, newPassword)
if err != nil {
return status.Errorf(status.InvalidArgument, "failed to update password: %v", err)
}
am.StoreEvent(ctx, currentUserID, targetUserID, accountID, activity.UserPasswordChanged, nil)
return nil
}
func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, accountID string, initiatorUserID string, targetUser *types.User) error {
if err := am.Store.DeleteUser(ctx, accountID, targetUser.Id); err != nil {
return err
}
meta := map[string]any{"name": targetUser.ServiceUserName, "created_at": targetUser.CreatedAt}
am.StoreEvent(ctx, initiatorUserID, targetUser.Id, accountID, activity.ServiceUserDeleted, meta)
return nil
}
// DeleteUser deletes a user from the given account.
func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error {
if initiatorUserID == targetUserID {
return status.Errorf(status.InvalidArgument, "self deletion is not allowed")
}
initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return err
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
return err
}
if targetUser.AccountID != accountID {
return status.NewUserNotFoundError(targetUserID)
}
if targetUser.Role == types.UserRoleOwner {
return status.NewOwnerDeletePermissionError()
}
// disable deleting integration user if the initiator is not admin service user
if targetUser.Issued == types.UserIssuedIntegration && !initiatorUser.IsServiceUser {
return status.Errorf(status.PermissionDenied, "only integration service user can delete this user")
}
// handle service user first and exit, no need to fetch extra data from IDP, etc
if targetUser.IsServiceUser {
if targetUser.NonDeletable {
return status.Errorf(status.PermissionDenied, "service user is marked as non-deletable")
}
return am.deleteServiceUser(ctx, accountID, initiatorUserID, targetUser)
}
userInfo, err := am.getUserInfo(ctx, targetUser, accountID)
if err != nil {
return err
}
_, err = am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo)
if err != nil {
return err
}
return nil
}
// InviteUser resend invitations to users who haven't activated their accounts prior to the expiration period.
func (am *DefaultAccountManager) InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error {
if am.idpManager == nil {
return status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites")
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
// check if the user is already registered with this ID
user, err := am.lookupUserInCache(ctx, targetUserID, accountID)
if err != nil {
return err
}
if user == nil {
return status.Errorf(status.NotFound, "user account %s doesn't exist", targetUserID)
}
// check if user account is already invited and account is not activated
pendingInvite := user.AppMetadata.WTPendingInvite
if pendingInvite == nil || !*pendingInvite {
return status.Errorf(status.PreconditionFailed, "can't invite a user with an activated NetBird account")
}
err = am.idpManager.InviteUserByID(ctx, user.ID)
if err != nil {
return err
}
am.StoreEvent(ctx, initiatorUserID, user.ID, accountID, activity.UserInvited, nil)
return nil
}
// CreatePAT creates a new PAT for the given user
func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) {
if tokenName == "" {
return nil, status.Errorf(status.InvalidArgument, "token name can't be empty")
}
if expiresIn < account.PATMinExpireDays || expiresIn > account.PATMaxExpireDays {
return nil, status.Errorf(status.InvalidArgument, "expiration has to be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays)
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return nil, err
}
targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
return nil, err
}
if targetUser.AccountID != accountID {
return nil, status.NewPermissionDeniedError()
}
// @note this is essential to prevent non admin users with Pats create permission frpm creating one for a service user
if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) {
return nil, status.NewAdminPermissionError()
}
pat, err := types.CreateNewPAT(tokenName, expiresIn, targetUserID, initiatorUser.Id)
if err != nil {
return nil, status.Errorf(status.Internal, "failed to create PAT: %v", err)
}
if err = am.Store.SavePAT(ctx, &pat.PersonalAccessToken); err != nil {
return nil, err
}
meta := map[string]any{"name": pat.Name, "is_service_user": targetUser.IsServiceUser, "user_name": targetUser.ServiceUserName}
am.StoreEvent(ctx, initiatorUserID, targetUserID, accountID, activity.PersonalAccessTokenCreated, meta)
return pat, nil
}
// DeletePAT deletes a specific PAT from a user
func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return err
}
targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
return err
}
if targetUser.AccountID != accountID {
return status.NewPermissionDeniedError()
}
if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) {
return status.NewAdminPermissionError()
}
pat, err := am.Store.GetPATByID(ctx, store.LockingStrengthNone, targetUserID, tokenID)
if err != nil {
return err
}
if err = am.Store.DeletePAT(ctx, targetUserID, tokenID); err != nil {
return err
}
meta := map[string]any{"name": pat.Name, "is_service_user": targetUser.IsServiceUser, "user_name": targetUser.ServiceUserName}
am.StoreEvent(ctx, initiatorUserID, targetUserID, accountID, activity.PersonalAccessTokenDeleted, meta)
return nil
}
// GetPAT returns a specific PAT from a user
func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return nil, err
}
targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
return nil, err
}
if targetUser.AccountID != accountID {
return nil, status.NewPermissionDeniedError()
}
if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) {
return nil, status.NewAdminPermissionError()
}
return am.Store.GetPATByID(ctx, store.LockingStrengthNone, targetUserID, tokenID)
}
// GetAllPATs returns all PATs for a user
func (am *DefaultAccountManager) GetAllPATs(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) ([]*types.PersonalAccessToken, error) {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return nil, err
}
targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
return nil, err
}
if targetUser.AccountID != accountID {
return nil, status.NewPermissionDeniedError()
}
if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) {
return nil, status.NewAdminPermissionError()
}
return am.Store.GetUserPATs(ctx, store.LockingStrengthNone, targetUserID)
}
// SaveUser saves updates to the given user. If the user doesn't exist, it will throw status.NotFound error.
func (am *DefaultAccountManager) SaveUser(ctx context.Context, accountID, initiatorUserID string, update *types.User) (*types.UserInfo, error) {
return am.SaveOrAddUser(ctx, accountID, initiatorUserID, update, false) // false means do not create user and throw status.NotFound
}
// SaveOrAddUser updates the given user. If addIfNotExists is set to true it will add user when no exist
// Only User.AutoGroups, User.Role, and User.Blocked fields are allowed to be updated for now.
func (am *DefaultAccountManager) SaveOrAddUser(ctx context.Context, accountID, initiatorUserID string, update *types.User, addIfNotExists bool) (*types.UserInfo, error) {
updatedUsers, err := am.SaveOrAddUsers(ctx, accountID, initiatorUserID, []*types.User{update}, addIfNotExists)
if err != nil {
return nil, err
}
if len(updatedUsers) == 0 {
return nil, status.Errorf(status.Internal, "user was not updated")
}
return updatedUsers[0], nil
}
// SaveOrAddUsers updates existing users or adds new users to the account.
// Note: This function does not acquire the global lock.
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, initiatorUserID string, updates []*types.User, addIfNotExists bool) ([]*types.UserInfo, error) {
if len(updates) == 0 {
return nil, nil //nolint:nilnil
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
var snaps []*affectedpeers.Snapshot
var changes []affectedpeers.Change
var peersToExpire []*nbpeer.Peer
var addUserEvents []func()
var usersToSave = make([]*types.User, 0, len(updates))
groups, err := am.Store.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, fmt.Errorf("error getting account groups: %w", err)
}
groupsMap := make(map[string]*types.Group, len(groups))
for _, group := range groups {
groupsMap[group.ID] = group
}
var initiatorUser *types.User
if initiatorUserID != activity.SystemInitiator {
result, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return nil, err
}
initiatorUser = result
role, ok := nbcontext.RoleFromContext(ctx)
if !ok {
return nil, status.Errorf(status.Internal, "failed to get user role from context")
}
initiatorUser.Role = types.UserRole(role)
}
var globalErr error
for _, update := range updates {
if update == nil {
return nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
change, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate(
ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings,
)
if err != nil {
return fmt.Errorf("failed to process update for user %s: %w", update.Id, err)
}
err = transaction.SaveUser(ctx, updatedUser)
if err != nil {
return fmt.Errorf("failed to save updated user %s: %w", update.Id, err)
}
snap, err := affectedpeers.Load(ctx, transaction, accountID, change)
if err != nil {
return err
}
snaps = append(snaps, snap)
changes = append(changes, change)
usersToSave = append(usersToSave, updatedUser)
addUserEvents = append(addUserEvents, userEvents...)
peersToExpire = append(peersToExpire, userPeersToExpire...)
return nil
})
if err != nil {
log.WithContext(ctx).Errorf("failed to save user %s: %s", update.Id, err)
if len(updates) == 1 {
return nil, err
}
globalErr = errors.Join(globalErr, err)
// continue when updating multiple users
}
}
var updatedUsersInfo = make([]*types.UserInfo, 0, len(usersToSave))
userInfos, err := am.GetUsersFromAccount(ctx, accountID, initiatorUserID)
if err != nil {
return nil, err
}
for _, updatedUser := range usersToSave {
updatedUserInfo, ok := userInfos[updatedUser.Id]
if !ok || updatedUserInfo == nil {
return nil, fmt.Errorf("failed to get user: %s updated user info", updatedUser.Id)
}
updatedUsersInfo = append(updatedUsersInfo, updatedUserInfo)
}
for _, addUserEvent := range addUserEvents {
addUserEvent()
}
if len(peersToExpire) > 0 {
if err := am.expireAndUpdatePeers(ctx, accountID, peersToExpire, peerExpirationUserBlocked); err != nil {
log.WithContext(ctx).Errorf("failed update expired peers: %s", err)
return nil, err
}
} else if len(usersToSave) > 0 {
if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil {
return nil, fmt.Errorf("failed to increment network serial: %w", err)
}
go am.dispatchAffected(ctx, accountID, snaps, changes)
}
return updatedUsersInfo, globalErr
}
// prepareUserUpdateEvents prepares a list user update events based on the changes between the old and new user data.
func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, accountID string, initiatorUserID string, oldUser, newUser *types.User, transferredOwnerRole bool, isNewUser bool, removedGroupIDs, addedGroupIDs []string, tx store.Store) []func() {
var eventsToStore []func()
if isNewUser {
eventsToStore = append(eventsToStore, func() {
am.StoreEvent(ctx, initiatorUserID, newUser.Id, accountID, activity.UserCreated, nil)
})
}
if oldUser.IsBlocked() != newUser.IsBlocked() {
if newUser.IsBlocked() {
eventsToStore = append(eventsToStore, func() {
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.UserBlocked, nil)
})
} else {
eventsToStore = append(eventsToStore, func() {
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.UserUnblocked, nil)
})
}
}
switch {
case transferredOwnerRole:
eventsToStore = append(eventsToStore, func() {
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.TransferredOwnerRole, nil)
})
case oldUser.Role != newUser.Role:
eventsToStore = append(eventsToStore, func() {
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.UserRoleUpdated, map[string]any{"role": newUser.Role})
})
}
addedGroups, err := tx.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, addedGroupIDs)
if err != nil {
log.WithContext(ctx).Errorf("failed to get added groups for user %s update event: %v", oldUser.Id, err)
}
for _, group := range addedGroups {
meta := map[string]any{
"group": group.Name, "group_id": group.ID,
"is_service_user": oldUser.IsServiceUser, "user_name": oldUser.ServiceUserName,
}
eventsToStore = append(eventsToStore, func() {
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.GroupAddedToUser, meta)
})
}
removedGroups, err := tx.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, removedGroupIDs)
if err != nil {
log.WithContext(ctx).Errorf("failed to get removed groups for user %s update event: %v", oldUser.Id, err)
}
for _, group := range removedGroups {
meta := map[string]any{
"group": group.Name, "group_id": group.ID,
"is_service_user": oldUser.IsServiceUser, "user_name": oldUser.ServiceUserName,
}
eventsToStore = append(eventsToStore, func() {
am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.GroupRemovedFromUser, meta)
})
}
return eventsToStore
}
func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transaction store.Store, groupsMap map[string]*types.Group,
accountID, initiatorUserId string, initiatorUser, update *types.User, addIfNotExists bool, settings *types.Settings) (affectedpeers.Change, *types.User, []*nbpeer.Peer, []func(), error) {
var change affectedpeers.Change
if update == nil {
return change, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
}
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
if err != nil {
return change, nil, nil, nil, err
}
if err := validateUserUpdate(groupsMap, initiatorUser, oldUser, update); err != nil {
return change, nil, nil, nil, err
}
// only auto groups, revoked status, and integration reference can be updated for now
updatedUser := oldUser.Copy()
updatedUser.Role = update.Role
updatedUser.Blocked = update.Blocked
updatedUser.AutoGroups = update.AutoGroups
// these fields can't be set via API, only via direct call to the method
updatedUser.Issued = update.Issued
updatedUser.IntegrationReference = update.IntegrationReference
if update.Name != "" {
updatedUser.Name = update.Name
}
if update.Email != "" {
updatedUser.Email = update.Email
}
var transferredOwnerRole bool
result, err := handleOwnerRoleTransfer(ctx, transaction, initiatorUser, update)
if err != nil {
return change, nil, nil, nil, err
}
transferredOwnerRole = result
userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, updatedUser.AccountID, update.Id)
if err != nil {
return change, nil, nil, nil, err
}
var peersToExpire []*nbpeer.Peer
if !oldUser.IsBlocked() && update.IsBlocked() {
peersToExpire = userPeers
}
// A user reaches a peer's network map only through the SSH rules: as part of a
// group -> user mapping, and as part of the account's allowed-user set. Creating,
// blocking or unblocking a user adds it to or removes it from both, so every group
// it maps into changes — including the All group that holds every active user.
// Otherwise only the auto-groups it joined or left do.
if isNewUser || oldUser.IsBlocked() != updatedUser.IsBlocked() {
change.AllowedUsersChanged = true
change.UserGroupIDs = slices.Concat(oldUser.AutoGroups, updatedUser.AutoGroups, allGroupIDs(groupsMap))
} else {
change.UserGroupIDs = slices.Concat(
util.Difference(oldUser.AutoGroups, updatedUser.AutoGroups),
util.Difference(updatedUser.AutoGroups, oldUser.AutoGroups),
)
}
// The user's peers are the changed entity in every scenario the update can
// produce — group membership, IPv6 assignment, SSH mappings — so they refresh
// together with every peer they can connect to, like on a regular peer update.
// An update that changes neither the auto-groups nor the active-user set has no
// peer-visible effect and refreshes nobody.
if len(change.UserGroupIDs) > 0 || change.AllowedUsersChanged {
for _, peer := range userPeers {
change.ChangedPeerIDs = append(change.ChangedPeerIDs, peer.ID)
}
}
var removedGroups, addedGroups []string
if update.AutoGroups != nil && settings.GroupsPropagationEnabled {
removedGroups = util.Difference(oldUser.AutoGroups, update.AutoGroups)
addedGroups = util.Difference(update.AutoGroups, oldUser.AutoGroups)
for _, peer := range userPeers {
for _, groupID := range removedGroups {
if err := transaction.RemovePeerFromGroup(ctx, peer.ID, groupID); err != nil {
return change, nil, nil, nil, fmt.Errorf("failed to remove peer %s from group %s: %w", peer.ID, groupID, err)
}
}
for _, groupID := range addedGroups {
if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil {
return change, nil, nil, nil, fmt.Errorf("failed to add peer %s to group %s: %w", peer.ID, groupID, err)
}
}
}
allGroupChanges := slices.Concat(removedGroups, addedGroups)
change.LinkGroups = allGroupChanges
if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil {
return change, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err)
}
}
userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction)
return change, updatedUser, peersToExpire, userEventsToAdd, nil
}
// allGroupIDs returns the ID of the account's All group, which every active user maps
// into, as a slice so callers can concatenate it.
func allGroupIDs(groupsMap map[string]*types.Group) []string {
for _, group := range groupsMap {
if group.IsGroupAll() {
return []string{group.ID}
}
}
return nil
}
// getUserOrCreateIfNotExists retrieves the existing user or creates a new one if it doesn't exist.
func getUserOrCreateIfNotExists(ctx context.Context, transaction store.Store, accountID string, update *types.User, addIfNotExists bool) (*types.User, bool, error) {
existingUser, err := transaction.GetUserByUserID(ctx, store.LockingStrengthNone, update.Id)
if err != nil {
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound {
if !addIfNotExists {
return nil, false, status.Errorf(status.NotFound, "user to update doesn't exist: %s", update.Id)
}
update.AccountID = accountID
return update, true, nil // use all fields from update if addIfNotExists is true
}
return nil, false, err
}
if existingUser.AccountID != accountID {
return nil, false, status.Errorf(status.InvalidArgument, "user account ID mismatch")
}
return existingUser, false, nil
}
func handleOwnerRoleTransfer(ctx context.Context, transaction store.Store, initiatorUser, update *types.User) (bool, error) {
if initiatorUser != nil && initiatorUser.Role == types.UserRoleOwner && initiatorUser.Id != update.Id && update.Role == types.UserRoleOwner {
newInitiatorUser := initiatorUser.Copy()
newInitiatorUser.Role = types.UserRoleAdmin
if err := transaction.SaveUser(ctx, newInitiatorUser); err != nil {
return false, err
}
return true, nil
}
return false, nil
}
// getUserInfo retrieves the UserInfo for a given User and Account.
// If the AccountManager has a non-nil idpManager and the User is not a service user,
// it will attempt to look up the UserData from the cache.
func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.User, accountID string) (*types.UserInfo, error) {
if !isNil(am.idpManager) && !user.IsServiceUser && !IsEmbeddedIdp(am.idpManager) {
userData, err := am.lookupUserInCache(ctx, user.Id, accountID)
if err != nil {
return nil, err
}
return user.ToUserInfo(userData)
}
userInfo, err := user.ToUserInfo(nil)
if err != nil {
return nil, err
}
// For embedded IDP users, extract the IdPID (connector ID) from the encoded user ID
if IsEmbeddedIdp(am.idpManager) && !user.IsServiceUser {
if _, connectorID, decodeErr := dex.DecodeDexUserID(user.Id); decodeErr == nil && connectorID != "" {
userInfo.IdPID = connectorID
}
}
return userInfo, nil
}
// validateUserUpdate validates the update operation for a user.
func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUser, update *types.User) error {
if initiatorUser == nil {
return nil
}
if !initiatorUser.HasAdminPower() {
return status.Errorf(status.PermissionDenied, "only admins and owners can update users")
}
if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && oldUser.Blocked != update.Blocked {
return status.Errorf(status.PermissionDenied, "admins can't block or unblock themselves")
}
if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && update.Role != initiatorUser.Role {
return status.Errorf(status.PermissionDenied, "admins can't change their role")
}
if initiatorUser.Role != types.UserRoleOwner && oldUser.Role == types.UserRoleOwner && update.Role != oldUser.Role {
return status.Errorf(status.PermissionDenied, "only owners can remove owner role from their user")
}
if oldUser.Role == types.UserRoleOwner && update.IsBlocked() && !oldUser.IsBlocked() {
return status.Errorf(status.PermissionDenied, "unable to block owner user")
}
if initiatorUser.Role != types.UserRoleOwner && update.Role == types.UserRoleOwner && update.Role != oldUser.Role {
return status.Errorf(status.PermissionDenied, "only owners can add owner role to other users")
}
if oldUser.IsServiceUser && update.Role == types.UserRoleOwner {
return status.Errorf(status.PermissionDenied, "can't update a service user with owner role")
}
for _, newGroupID := range update.AutoGroups {
group, ok := groupsMap[newGroupID]
if !ok {
return status.Errorf(status.InvalidArgument, "provided group ID %s in the user %s update doesn't exist",
newGroupID, update.Id)
}
if group.IsGroupAll() {
return status.Errorf(status.InvalidArgument, "can't add All group to the user")
}
}
return nil
}
// GetOrCreateAccountByUser returns an existing account for a given user id or creates a new one if doesn't exist
func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error) {
userID := userAuth.UserId
domain := userAuth.Domain
start := time.Now()
unlock := am.Store.AcquireGlobalLock(ctx)
defer unlock()
log.WithContext(ctx).Debugf("Acquired global lock in %s for user %s", time.Since(start), userID)
lowerDomain := strings.ToLower(domain)
account, err := am.Store.GetAccountByUser(ctx, userID)
if err != nil {
if s, ok := status.FromError(err); ok && s.Type() == status.NotFound {
account, err = am.newAccount(ctx, userID, lowerDomain, userAuth.Email, userAuth.Name)
if err != nil {
return nil, err
}
err = am.Store.SaveAccount(ctx, account)
if err != nil {
return nil, err
}
} else {
// other error
return nil, err
}
}
userObj := account.Users[userID]
if lowerDomain != "" && account.Domain != lowerDomain && userObj.Role == types.UserRoleOwner {
account.Domain = lowerDomain
err = am.Store.SaveAccount(ctx, account)
if err != nil {
return nil, status.Errorf(status.Internal, "failed updating account with domain")
}
}
return account, nil
}
// GetUsersFromAccount performs a batched request for users from IDP by account ID apply filter on what data to return
// based on provided user role.
func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accountID, initiatorUserID string) (map[string]*types.UserInfo, error) {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
var user *types.User
if initiatorUserID != activity.SystemInitiator {
result, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
user = result
}
accountUsers := []*types.User{}
switch {
case allowed:
start := time.Now()
accountUsers, err = am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
log.WithContext(ctx).Tracef("Got %d users from account %s after %s", len(accountUsers), accountID, time.Since(start))
case user != nil && user.AccountID == accountID:
accountUsers = append(accountUsers, user)
default:
return map[string]*types.UserInfo{}, nil
}
return am.BuildUserInfosForAccount(ctx, accountID, initiatorUserID, accountUsers)
}
// BuildUserInfosForAccount builds user info for the given account.
func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, accountID, _ string, accountUsers []*types.User) (map[string]*types.UserInfo, error) {
var queriedUsers []*idp.UserData
var err error
// embedded IdP ensures that we have user data (email and name) stored in the database.
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
users := make(map[string]userLoggedInOnce, len(accountUsers))
usersFromIntegration := make([]*idp.UserData, 0)
filtered := make(map[string]*idp.UserData, len(accountUsers))
log.WithContext(ctx).Tracef("Querying users from IDP for account %s", accountID)
start := time.Now()
integrationKeys := make(map[string]struct{})
for _, user := range accountUsers {
if user.Issued == types.UserIssuedIntegration {
integrationKeys[user.IntegrationReference.CacheKey(accountID)] = struct{}{}
continue
}
if !user.IsServiceUser {
users[user.Id] = userLoggedInOnce(!user.GetLastLogin().IsZero())
}
}
for key := range integrationKeys {
usersData, err := am.externalCacheManager.GetUsers(am.ctx, key)
if err != nil {
log.WithContext(ctx).Debugf("GetUsers from ExternalCache for key: %s, error: %s", key, err)
continue
}
for _, ud := range usersData {
filtered[ud.ID] = ud
}
}
for _, ud := range filtered {
usersFromIntegration = append(usersFromIntegration, ud)
}
log.WithContext(ctx).Tracef("Got user info from external cache after %s", time.Since(start))
start = time.Now()
queriedUsers, err = am.lookupCache(ctx, users, accountID)
log.WithContext(ctx).Tracef("Got user info from cache for %d users after %s", len(queriedUsers), time.Since(start))
if err != nil {
return nil, err
}
log.WithContext(ctx).Tracef("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID)
log.WithContext(ctx).Tracef("Got %d users from InternalCache for account %s", len(queriedUsers), accountID)
queriedUsers = append(queriedUsers, usersFromIntegration...)
}
userInfosMap := make(map[string]*types.UserInfo)
// in case of self-hosted, or IDP doesn't return anything, we will return the locally stored userInfo
if len(queriedUsers) == 0 {
for _, accountUser := range accountUsers {
info, err := accountUser.ToUserInfo(nil)
if err != nil {
return nil, err
}
// Try to decode Dex user ID to extract the IdP ID (connector ID)
if _, connectorID, decodeErr := dex.DecodeDexUserID(accountUser.Id); decodeErr == nil && connectorID != "" {
info.IdPID = connectorID
}
userInfosMap[accountUser.Id] = info
}
return userInfosMap, nil
}
for _, localUser := range accountUsers {
var info *types.UserInfo
if queriedUser, contains := findUserInIDPUserdata(localUser.Id, queriedUsers); contains {
info, err = localUser.ToUserInfo(queriedUser)
if err != nil {
return nil, err
}
} else {
name := ""
if localUser.IsServiceUser {
name = localUser.ServiceUserName
}
info = &types.UserInfo{
ID: localUser.Id,
Email: localUser.Email,
Name: name,
Role: string(localUser.Role),
AutoGroups: localUser.AutoGroups,
Status: string(types.UserStatusActive),
IsServiceUser: localUser.IsServiceUser,
NonDeletable: localUser.NonDeletable,
}
}
// Try to decode Dex user ID to extract the IdP ID (connector ID)
if _, connectorID, decodeErr := dex.DecodeDexUserID(localUser.Id); decodeErr == nil && connectorID != "" {
info.IdPID = connectorID
}
userInfosMap[info.ID] = info
}
return userInfosMap, nil
}
// expireAndUpdatePeers expires all peers of the given user and updates them in the account
func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accountID string, peers []*nbpeer.Peer, reason peerExpirationReason) error {
log.WithContext(ctx).Debugf("Expiring %d peers for account %s", len(peers), accountID)
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return err
}
dnsDomain := am.networkMapController.GetDNSDomain(settings)
var peerIDs []string
defer func() {
if len(peerIDs) == 0 {
return
}
// this will trigger peer disconnect from the management service
log.Debugf("Expiring %d peers for account %s", len(peerIDs), accountID)
am.networkMapController.DisconnectPeers(ctx, accountID, peerIDs)
}()
for _, candidate := range peers {
// nolint:staticcheck
peerCtx := context.WithValue(ctx, nbcontext.PeerIDKey, candidate.Key)
if candidate.UserID == "" {
// we do not want to expire peers that are added via setup key
continue
}
peer, err := am.expirePeerIfStillDue(peerCtx, accountID, candidate.ID, settings, reason)
if err != nil {
return err
}
if peer == nil {
continue
}
peerIDs = append(peerIDs, peer.ID)
meta := peer.EventMeta(dnsDomain)
meta["reason"] = string(reason)
am.StoreEvent(
peerCtx,
peer.UserID, peer.ID, accountID,
activity.PeerLoginExpired, meta,
)
}
if len(peerIDs) != 0 {
if err := am.Store.IncrementNetworkSerial(ctx, accountID); err != nil {
return err
}
}
affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, peerIDs)
err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs, affectedPeerIDs)
if err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
}
return nil
}
// expirePeerIfStillDue flags the peer as login-expired and returns its fresh copy, or nil
// when it no longer qualifies. The candidate list is read without a lock, so a login that
// landed in between would otherwise be overwritten with a stale expired status.
func (am *DefaultAccountManager) expirePeerIfStillDue(ctx context.Context, accountID, peerID string, settings *types.Settings, reason peerExpirationReason) (*nbpeer.Peer, error) {
var expired *nbpeer.Peer
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID)
if err != nil {
if s, ok := status.FromError(err); ok && s.Type() == status.NotFound {
return nil
}
return err
}
if peer.Status.LoginExpired || !peerExpirationDue(peer, settings, reason) {
return nil
}
peer.MarkLoginExpired(true)
if err := transaction.SavePeerStatus(ctx, accountID, peer.ID, *peer.Status); err != nil {
return err
}
expired = peer
return nil
})
if err != nil {
return nil, err
}
return expired, nil
}
// peerExpirationDue re-evaluates a time-based expiry against the peer's current state.
// Administrative reasons expire the peer unconditionally.
func peerExpirationDue(peer *nbpeer.Peer, settings *types.Settings, reason peerExpirationReason) bool {
switch reason {
case peerExpirationSessionExpired:
expired, _ := peer.LoginExpired(settings.PeerLoginExpiration)
return settings.PeerLoginExpirationEnabled && expired
case peerExpirationInactivity:
expired, _ := peer.SessionExpired(settings.PeerInactivityExpiration)
return settings.PeerInactivityExpirationEnabled && expired
default:
return true
}
}
func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUserID, accountID string) error {
if am.userDeleteFromIDPEnabled {
log.WithContext(ctx).Debugf("user %s deleted from IdP", targetUserID)
err := am.idpManager.DeleteUser(ctx, targetUserID)
if err != nil {
return fmt.Errorf("failed to delete user %s from IdP: %s", targetUserID, err)
}
} else {
err := am.idpManager.UpdateUserAppMetadata(ctx, targetUserID, idp.AppMetadata{})
if err != nil {
return fmt.Errorf("failed to remove user %s app metadata in IdP: %s", targetUserID, err)
}
}
err := am.removeUserFromCache(ctx, accountID, targetUserID)
if err != nil {
log.WithContext(ctx).Errorf("remove user from account (%q) cache failed with error: %v", accountID, err)
}
return nil
}
// DeleteRegularUsers deletes regular users from an account.
// Note: This function does not acquire the global lock.
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
//
// If an error occurs while deleting the user, the function skips it and continues deleting other users.
// Errors are collected and returned at the end.
func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
initiatorUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, initiatorUserID)
if err != nil {
return err
}
var allErrors error
for _, targetUserID := range targetUserIDs {
if initiatorUserID == targetUserID {
allErrors = errors.Join(allErrors, errors.New("self deletion is not allowed"))
continue
}
targetUser, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
allErrors = errors.Join(allErrors, err)
continue
}
if targetUser.Role == types.UserRoleOwner {
allErrors = errors.Join(allErrors, fmt.Errorf("unable to delete a user: %s with owner role", targetUserID))
continue
}
// disable deleting integration user if the initiator is not admin service user
if targetUser.Issued == types.UserIssuedIntegration && !initiatorUser.IsServiceUser {
allErrors = errors.Join(allErrors, errors.New("only integration service user can delete this user"))
continue
}
userInfo, ok := userInfos[targetUserID]
if !ok || userInfo == nil {
allErrors = errors.Join(allErrors, fmt.Errorf("user info not found for user: %s", targetUserID))
continue
}
_, err = am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo)
if err != nil {
allErrors = errors.Join(allErrors, err)
continue
}
}
return allErrors
}
// deleteRegularUser deletes a specified user and their related peers from the account.
func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountID, initiatorUserID string, targetUserInfo *types.UserInfo) (bool, error) {
if !isNil(am.idpManager) {
// Delete if the user already exists in the IdP. Necessary in cases where a user account
// was created where a user account was provisioned but the user did not sign in
_, err := am.idpManager.GetUserDataByID(ctx, targetUserInfo.ID, idp.AppMetadata{WTAccountID: accountID})
if err == nil {
err = am.deleteUserFromIDP(ctx, targetUserInfo.ID, accountID)
if err != nil {
log.WithContext(ctx).Debugf("failed to delete user from IDP: %s", targetUserInfo.ID)
return false, err
}
} else {
log.WithContext(ctx).Debugf("skipped deleting user %s from IDP, error: %v", targetUserInfo.ID, err)
}
}
var addPeerRemovedEvents []func()
var updateAccountPeers bool
var userPeers []*nbpeer.Peer
var targetUser *types.User
var settings *types.Settings
var snap *affectedpeers.Snapshot
var change affectedpeers.Change
var err error
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
targetUser, err = transaction.GetUserByUserID(ctx, store.LockingStrengthUpdate, targetUserInfo.ID)
if err != nil {
return fmt.Errorf("failed to get user to delete: %w", err)
}
if targetUser.Role == types.UserRoleOwner && targetUser.Id != initiatorUserID {
return status.NewOwnerDeletePermissionError()
}
settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return fmt.Errorf("failed to get account settings: %w", err)
}
userPeers, err = transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, targetUserInfo.ID)
if err != nil {
return fmt.Errorf("failed to get user peers: %w", err)
}
if len(userPeers) > 0 {
updateAccountPeers = true
var peerIDs []string
for _, peer := range userPeers {
peerIDs = append(peerIDs, peer.ID)
}
// Load before delete so the snapshot still has the peers' group
// memberships; the resolver derives them from the peer IDs during the walk.
change = affectedpeers.Change{ChangedPeerIDs: peerIDs}
if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers, settings)
if err != nil {
return fmt.Errorf("failed to delete user peers: %w", err)
}
}
if err = transaction.DeleteUser(ctx, accountID, targetUserInfo.ID); err != nil {
return fmt.Errorf("failed to delete user: %s %w", targetUserInfo.ID, err)
}
return nil
})
if err != nil {
return false, err
}
var peerIDs []string
for _, peer := range userPeers {
peerIDs = append(peerIDs, peer.ID)
if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peer.ID, settings.Extra); err != nil {
log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peer.ID, err)
}
}
affectedPeerIDs := snap.Expand(ctx, accountID, change)
if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Errorf("failed to delete peers %s from network map: %v", peerIDs, err)
}
for _, addPeerRemovedEvent := range addPeerRemovedEvents {
addPeerRemovedEvent()
}
meta := map[string]any{"name": targetUserInfo.Name, "email": targetUserInfo.Email, "created_at": targetUser.CreatedAt, "issued": targetUser.Issued}
am.StoreEvent(ctx, initiatorUserID, targetUser.Id, accountID, activity.UserDeleted, meta)
return updateAccountPeers, nil
}
// pendingApprovalError refuses a user awaiting approval, naming the owner who
// can approve them when their address resolves. Failing to resolve one is not a
// reason to withhold the refusal, so the lookup is best effort.
func (am *DefaultAccountManager) pendingApprovalError(ctx context.Context, accountID string) error {
owner, err := am.GetOwnerInfo(ctx, accountID)
if err != nil {
log.WithContext(ctx).Debugf("pending approval refusal: owner of account %s did not resolve: %v", accountID, err)
return status.NewUserPendingApprovalError()
}
masked := types.MaskEmail(owner.Email)
if masked == "" {
log.WithContext(ctx).Debugf("pending approval refusal: no address found for the owner of account %s", accountID)
return status.NewUserPendingApprovalError()
}
return status.NewUserPendingApprovalByOwnerError(masked)
}
// GetOwnerInfo retrieves the owner information for a given account ID.
func (am *DefaultAccountManager) GetOwnerInfo(ctx context.Context, accountID string) (*types.UserInfo, error) {
owner, err := am.Store.GetAccountOwner(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
if owner == nil {
return nil, status.Errorf(status.NotFound, "owner not found")
}
userInfo, err := am.getUserInfo(ctx, owner, accountID)
if err != nil {
return nil, err
}
return userInfo, nil
}
func findUserInIDPUserdata(userID string, userData []*idp.UserData) (*idp.UserData, bool) {
for _, user := range userData {
if user.ID == userID {
return user, true
}
}
return nil, false
}
func validateUserInvite(invite *types.UserInfo) error {
if invite == nil {
return fmt.Errorf("provided user update is nil")
}
invitedRole := types.StrRoleToUserRole(invite.Role)
switch {
case invite.Name == "":
return status.Errorf(status.InvalidArgument, "name can't be empty")
case invite.Email == "":
return status.Errorf(status.InvalidArgument, "email can't be empty")
case invitedRole == types.UserRoleOwner:
return status.Errorf(status.InvalidArgument, "can't invite a user with owner role")
default:
}
return nil
}
// GetCurrentUserInfo retrieves the account's current user info and permissions
func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) {
accountID, userID := userAuth.AccountId, userAuth.UserId
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, err
}
// A user pending approval is blocked too, and the dashboard needs to tell
// the two apart: one is a dead end, the other resolves by itself once the
// owner acts. Naming that owner needs the address the IdP holds, which is
// why this is answered here rather than in the permission gate.
if user.IsBlocked() && user.PendingApproval {
return nil, am.pendingApprovalError(ctx, user.AccountID)
}
if user.IsBlocked() {
return nil, status.NewUserBlockedError()
}
if user.IsServiceUser {
return nil, status.NewPermissionDeniedError()
}
ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
if err != nil {
return nil, err
}
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
userInfo, err := am.getUserInfo(ctx, user, accountID)
if err != nil {
return nil, err
}
userWithPermissions := &users.UserInfoWithPermissions{
UserInfo: userInfo,
Restricted: !userAuth.IsChild && user.IsRestrictable() && settings.RegularUsersViewBlocked,
}
permissions, err := am.permissionsManager.GetPermissionsByRole(ctx, user.Role)
if err == nil {
userWithPermissions.Permissions = permissions
}
return userWithPermissions, nil
}
// ApproveUser approves a user that is pending approval
func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
return nil, err
}
if user.AccountID != accountID {
return nil, status.NewUserNotFoundError(targetUserID)
}
if !user.PendingApproval {
return nil, status.Errorf(status.InvalidArgument, "user %s is not pending approval", targetUserID)
}
user.Blocked = false
user.PendingApproval = false
err = am.Store.SaveUser(ctx, user)
if err != nil {
return nil, err
}
am.StoreEvent(ctx, initiatorUserID, targetUserID, accountID, activity.UserApproved, nil)
userInfo, err := am.getUserInfo(ctx, user, accountID)
if err != nil {
return nil, err
}
return userInfo, nil
}
// RejectUser rejects a user that is pending approval by deleting them
func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error {
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID)
if err != nil {
return err
}
if user.AccountID != accountID {
return status.NewUserNotFoundError(targetUserID)
}
if !user.PendingApproval {
return status.Errorf(status.InvalidArgument, "user %s is not pending approval", targetUserID)
}
err = am.DeleteUser(ctx, accountID, initiatorUserID, targetUserID)
if err != nil {
return err
}
am.StoreEvent(ctx, initiatorUserID, targetUserID, accountID, activity.UserRejected, nil)
return nil
}
// CreateUserInvite creates an invite link for a new user in the embedded IdP.
// The user is NOT created until the invite is accepted.
func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
if !IsEmbeddedIdp(am.idpManager) {
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
if IsLocalAuthDisabled(ctx, am.idpManager) {
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
}
if err := validateUserInvite(invite); err != nil {
return nil, err
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
// Check if user already exists in NetBird DB
existingUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
for _, user := range existingUsers {
if strings.EqualFold(user.Email, invite.Email) {
return nil, status.Errorf(status.UserAlreadyExists, "user with this email already exists")
}
}
// Check if invite already exists for this email
existingInvite, err := am.Store.GetUserInviteByEmail(ctx, store.LockingStrengthNone, accountID, invite.Email)
if err != nil {
if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
return nil, fmt.Errorf("failed to check existing invites: %w", err)
}
}
if existingInvite != nil {
return nil, status.Errorf(status.AlreadyExists, "invite already exists for this email")
}
// Calculate expiration time
if expiresIn <= 0 {
expiresIn = types.DefaultInviteExpirationSeconds
}
if expiresIn < types.MinInviteExpirationSeconds {
return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour")
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second)
// Generate invite token
inviteID := types.NewInviteID()
hashedToken, plainToken, err := types.GenerateInviteToken()
if err != nil {
return nil, fmt.Errorf("failed to generate invite token: %w", err)
}
// Create the invite record (no user created yet)
userInvite := &types.UserInviteRecord{
ID: inviteID,
AccountID: accountID,
Email: invite.Email,
Name: invite.Name,
Role: invite.Role,
AutoGroups: invite.AutoGroups,
HashedToken: hashedToken,
ExpiresAt: expiresAt,
CreatedAt: time.Now().UTC(),
CreatedBy: initiatorUserID,
}
if err := am.Store.SaveUserInvite(ctx, userInvite); err != nil {
return nil, err
}
am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkCreated, map[string]any{"email": invite.Email})
return &types.UserInvite{
UserInfo: &types.UserInfo{
ID: inviteID,
Email: invite.Email,
Name: invite.Name,
Role: invite.Role,
AutoGroups: invite.AutoGroups,
Status: string(types.UserStatusInvited),
Issued: types.UserIssuedAPI,
},
InviteToken: plainToken,
InviteExpiresAt: expiresAt,
}, nil
}
// GetUserInviteInfo retrieves invite information from a token (public endpoint).
func (am *DefaultAccountManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) {
if err := types.ValidateInviteToken(token); err != nil {
return nil, status.Errorf(status.InvalidArgument, "invalid invite token: %v", err)
}
hashedToken := types.HashInviteToken(token)
invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthNone, hashedToken)
if err != nil {
return nil, err
}
// Get the inviter's name
invitedBy := ""
if invite.CreatedBy != "" {
inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, invite.CreatedBy)
if err == nil && inviter != nil {
invitedBy = inviter.Name
}
}
return &types.UserInviteInfo{
Email: invite.Email,
Name: invite.Name,
ExpiresAt: invite.ExpiresAt,
Valid: !invite.IsExpired(),
InvitedBy: invitedBy,
}, nil
}
// ListUserInvites returns all invites for an account.
func (am *DefaultAccountManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
if !IsEmbeddedIdp(am.idpManager) {
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
records, err := am.Store.GetAccountUserInvites(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, err
}
invites := make([]*types.UserInvite, 0, len(records))
for _, record := range records {
invites = append(invites, &types.UserInvite{
UserInfo: &types.UserInfo{
ID: record.ID,
Email: record.Email,
Name: record.Name,
Role: record.Role,
AutoGroups: record.AutoGroups,
},
InviteExpiresAt: record.ExpiresAt,
InviteCreatedAt: record.CreatedAt,
})
}
return invites, nil
}
// AcceptUserInvite accepts an invite and creates the user in both IdP and NetBird DB.
func (am *DefaultAccountManager) AcceptUserInvite(ctx context.Context, token, password string) error {
if !IsEmbeddedIdp(am.idpManager) {
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
if IsLocalAuthDisabled(ctx, am.idpManager) {
return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
}
if password == "" {
return status.Errorf(status.InvalidArgument, "password is required")
}
if err := validatePassword(password); err != nil {
return status.Errorf(status.InvalidArgument, "invalid password: %v", err)
}
if err := types.ValidateInviteToken(token); err != nil {
return status.Errorf(status.InvalidArgument, "invalid invite token: %v", err)
}
hashedToken := types.HashInviteToken(token)
invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthUpdate, hashedToken)
if err != nil {
return err
}
if invite.IsExpired() {
return status.Errorf(status.InvalidArgument, "invite has expired")
}
// Create user in Dex with the provided password
embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return status.Errorf(status.Internal, "failed to get embedded IdP manager")
}
idpUser, err := embeddedIdp.CreateUserWithPassword(ctx, invite.Email, password, invite.Name)
if err != nil {
return fmt.Errorf("failed to create user in IdP: %w", err)
}
// Create user in NetBird DB
newUser := &types.User{
Id: idpUser.ID,
AccountID: invite.AccountID,
Role: types.StrRoleToUserRole(invite.Role),
AutoGroups: invite.AutoGroups,
Issued: types.UserIssuedAPI,
CreatedAt: time.Now().UTC(),
Email: invite.Email,
Name: invite.Name,
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err := transaction.SaveUser(ctx, newUser); err != nil {
return fmt.Errorf("failed to save user: %w", err)
}
if err := transaction.DeleteUserInvite(ctx, invite.ID); err != nil {
return fmt.Errorf("failed to delete invite: %w", err)
}
return nil
})
if err != nil {
// Best-effort rollback: delete the IdP user to avoid orphaned records
if deleteErr := embeddedIdp.DeleteUser(ctx, idpUser.ID); deleteErr != nil {
log.WithContext(ctx).WithError(deleteErr).Errorf("failed to rollback IdP user %s after transaction failure", idpUser.ID)
}
return err
}
am.StoreEvent(ctx, newUser.Id, newUser.Id, invite.AccountID, activity.UserInviteLinkAccepted, map[string]any{"email": invite.Email})
return nil
}
// RegenerateUserInvite creates a new invite token for an existing invite, invalidating the previous one.
func (am *DefaultAccountManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
if !IsEmbeddedIdp(am.idpManager) {
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
if !allowed {
return nil, status.NewPermissionDeniedError()
}
// Get existing invite
existingInvite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID)
if err != nil {
return nil, err
}
// Calculate expiration time
if expiresIn <= 0 {
expiresIn = types.DefaultInviteExpirationSeconds
}
if expiresIn < types.MinInviteExpirationSeconds {
return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour")
}
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second)
// Generate new invite token
hashedToken, plainToken, err := types.GenerateInviteToken()
if err != nil {
return nil, fmt.Errorf("failed to generate invite token: %w", err)
}
// Update existing invite with new token and expiration
existingInvite.HashedToken = hashedToken
existingInvite.ExpiresAt = expiresAt
existingInvite.CreatedBy = initiatorUserID
err = am.Store.SaveUserInvite(ctx, existingInvite)
if err != nil {
return nil, err
}
am.StoreEvent(ctx, initiatorUserID, existingInvite.ID, accountID, activity.UserInviteLinkRegenerated, map[string]any{"email": existingInvite.Email})
return &types.UserInvite{
UserInfo: &types.UserInfo{
ID: existingInvite.ID,
Email: existingInvite.Email,
Name: existingInvite.Name,
Role: existingInvite.Role,
AutoGroups: existingInvite.AutoGroups,
Status: string(types.UserStatusInvited),
Issued: types.UserIssuedAPI,
},
InviteToken: plainToken,
InviteExpiresAt: expiresAt,
}, nil
}
// DeleteUserInvite deletes an existing invite by ID.
func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
if !IsEmbeddedIdp(am.idpManager) {
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
if !allowed {
return status.NewPermissionDeniedError()
}
invite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID)
if err != nil {
return err
}
if err := am.Store.DeleteUserInvite(ctx, inviteID); err != nil {
return err
}
am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkDeleted, map[string]any{"email": invite.Email})
return nil
}
const minPasswordLength = 8
// validatePassword checks password strength requirements.
func validatePassword(password string) error {
return ValidatePassword(password)
}
// ValidatePassword checks password strength requirements:
// - Minimum 8 characters
// - At least 1 digit
// - At least 1 uppercase letter
// - At least 1 special character
func ValidatePassword(password string) error {
if len(password) < minPasswordLength {
return errors.New("password must be at least 8 characters long")
}
var hasDigit, hasUpper, hasSpecial bool
for _, c := range password {
switch {
case unicode.IsDigit(c):
hasDigit = true
case unicode.IsUpper(c):
hasUpper = true
case !unicode.IsLetter(c) && !unicode.IsDigit(c):
hasSpecial = true
}
}
var missing []string
if !hasDigit {
missing = append(missing, "one digit")
}
if !hasUpper {
missing = append(missing, "one uppercase letter")
}
if !hasSpecial {
missing = append(missing, "one special character")
}
if len(missing) > 0 {
return errors.New("password must contain at least " + strings.Join(missing, ", "))
}
return nil
}