mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 14:19:08 +02:00
* 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.
2022 lines
70 KiB
Go
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
|
|
}
|