mirror of
https://github.com/gravitl/netmaker.git
synced 2026-10-12 02:15:06 +00:00
536 lines
14 KiB
Go
536 lines
14 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gravitl/netmaker/db"
|
|
"github.com/gravitl/netmaker/logger"
|
|
"github.com/gravitl/netmaker/logic"
|
|
"github.com/gravitl/netmaker/models"
|
|
"github.com/gravitl/netmaker/mq"
|
|
"github.com/gravitl/netmaker/orchestrator"
|
|
"github.com/gravitl/netmaker/pro/idp"
|
|
"github.com/gravitl/netmaker/pro/idp/azure"
|
|
"github.com/gravitl/netmaker/pro/idp/google"
|
|
"github.com/gravitl/netmaker/pro/idp/okta"
|
|
proLogic "github.com/gravitl/netmaker/pro/logic"
|
|
"github.com/gravitl/netmaker/schema"
|
|
"github.com/gravitl/netmaker/scope"
|
|
"github.com/gravitl/netmaker/servercfg"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// idpSyncStateMtx guards idpSyncLocks and idpSyncErrs below, which track
|
|
// per-tenant sync state since each tenant's hook runs independently and
|
|
// concurrently.
|
|
var (
|
|
idpSyncStateMtx sync.Mutex
|
|
idpSyncLocks = make(map[string]*sync.Mutex)
|
|
idpSyncErrs = make(map[string]error)
|
|
)
|
|
|
|
func idpSyncHookID(ctx context.Context) string {
|
|
return fmt.Sprintf("idp-sync-%s", scope.ID(ctx))
|
|
}
|
|
|
|
func tenantSyncLock(tenantID string) *sync.Mutex {
|
|
idpSyncStateMtx.Lock()
|
|
defer idpSyncStateMtx.Unlock()
|
|
mtx, ok := idpSyncLocks[tenantID]
|
|
if !ok {
|
|
mtx = &sync.Mutex{}
|
|
idpSyncLocks[tenantID] = mtx
|
|
}
|
|
return mtx
|
|
}
|
|
|
|
func idpSyncInterval(settings schema.TenantSettings) time.Duration {
|
|
interval, err := time.ParseDuration(settings.IDPSyncInterval)
|
|
if err != nil || interval == 0 {
|
|
return 24 * time.Hour
|
|
}
|
|
return interval
|
|
}
|
|
|
|
// ResetIDPSyncHook re-reads the settings for the tenant carried in ctx and
|
|
// either (re)registers or stops that tenant's idp sync hook accordingly.
|
|
func ResetIDPSyncHook(ctx context.Context) {
|
|
if !servercfg.IsMasterPod() {
|
|
if servercfg.IsHA() && logic.PublishServerSync != nil {
|
|
logic.PublishServerSync(ctx, logic.SyncTypeIDPSync)
|
|
}
|
|
return
|
|
}
|
|
|
|
StartIDPSyncHookForTenant(ctx)
|
|
}
|
|
|
|
func StartIDPSyncHookForTenant(ctx context.Context) {
|
|
hookID := idpSyncHookID(ctx)
|
|
|
|
// Embed scope/db into a fresh context so the hook goroutine carries its
|
|
// own tenant identity independently of any caller's request lifetime.
|
|
tenantCtx := scope.WithContext(db.WithContext(context.Background()), scope.Level(ctx), scope.ID(ctx))
|
|
|
|
settingsRecord := &schema.TenantSettingsRecord{Key: scope.ID(ctx)}
|
|
if err := settingsRecord.Get(tenantCtx); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return
|
|
}
|
|
logger.Log(0, "failed to load settings for tenant ", scope.ID(ctx), ": ", err.Error())
|
|
return
|
|
}
|
|
settings := settingsRecord.Value.Data()
|
|
|
|
if !settings.SyncEnabled {
|
|
logic.StopHook(hookID)
|
|
return
|
|
}
|
|
|
|
logic.HookManagerCh <- models.HookDetails{
|
|
ID: hookID,
|
|
Hook: logic.WrapHook(func() error {
|
|
return SyncFromIDP(tenantCtx)
|
|
}),
|
|
Interval: idpSyncInterval(settings),
|
|
}
|
|
}
|
|
|
|
func SyncFromIDP(ctx context.Context) error {
|
|
tenantID := scope.ID(ctx)
|
|
mtx := tenantSyncLock(tenantID)
|
|
mtx.Lock()
|
|
defer mtx.Unlock()
|
|
|
|
settingsRecord := &schema.TenantSettingsRecord{
|
|
Key: tenantID,
|
|
}
|
|
var err error
|
|
defer func() {
|
|
idpSyncStateMtx.Lock()
|
|
idpSyncErrs[tenantID] = err
|
|
idpSyncStateMtx.Unlock()
|
|
}()
|
|
|
|
err = settingsRecord.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
settings := settingsRecord.Value.Data()
|
|
|
|
var idpClient idp.Client
|
|
var idpUsers []idp.User
|
|
var idpGroups []idp.Group
|
|
|
|
switch settings.AuthProvider {
|
|
case "google":
|
|
idpClient, err = google.NewGoogleWorkspaceClient(settings.GoogleAdminEmail, settings.GoogleSACredsJson)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case "azure-ad":
|
|
idpClient = azure.NewAzureEntraIDClient(settings.ClientID, settings.ClientSecret, settings.AzureTenant)
|
|
case "okta":
|
|
idpClient, err = okta.NewOktaClient(settings.OktaOrgURL, settings.OktaAPIToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
if settings.AuthProvider != "" {
|
|
err = fmt.Errorf("invalid auth provider: %s", settings.AuthProvider)
|
|
return err
|
|
}
|
|
}
|
|
|
|
if settings.AuthProvider != "" && idpClient != nil {
|
|
idpUsers, err = idpClient.GetUsers(settings.UserFilters)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
idpGroups, err = idpClient.GetGroups(settings.GroupFilters)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(settings.GroupFilters) > 0 {
|
|
idpUsers = filterUsersByGroupMembership(idpUsers, idpGroups)
|
|
}
|
|
|
|
if len(settings.UserFilters) > 0 {
|
|
idpGroups = filterGroupsByMembers(idpGroups, idpUsers)
|
|
}
|
|
}
|
|
|
|
err = syncUsers(ctx, idpUsers, settings.UserFilters, settings.AuthProvider == "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = syncGroups(ctx, idpGroups, settings.GroupFilters)
|
|
return err
|
|
}
|
|
|
|
func syncUsers(ctx context.Context, idpUsers []idp.User, filters []string, removeIntegration bool) error {
|
|
dbUsers, err := (&schema.User{}).ListAllWithMembership(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
password, err := logic.FetchOAuthSecret(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
idpUsersMap := make(map[string]struct{})
|
|
for _, user := range idpUsers {
|
|
idpUsersMap[user.Username] = struct{}{}
|
|
}
|
|
|
|
dbUsersMap := make(map[string]*schema.User)
|
|
for _, user := range dbUsers {
|
|
dbUsersMap[user.Username] = &user
|
|
}
|
|
|
|
for _, user := range idpUsers {
|
|
if user.AccountArchived {
|
|
// delete the user if it has been archived.
|
|
user, ok := dbUsersMap[user.Username]
|
|
if ok {
|
|
_ = logic.DeleteTenantUser(ctx, user, true, cleanupUserRefs)
|
|
}
|
|
continue
|
|
}
|
|
|
|
var found bool
|
|
for _, filter := range filters {
|
|
if strings.HasPrefix(user.Username, filter) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// if there are filters but none of them match, then skip this user.
|
|
if len(filters) > 0 && !found {
|
|
continue
|
|
}
|
|
|
|
dbUser, ok := dbUsersMap[user.Username]
|
|
if !ok {
|
|
createErr := orchestrator.GetRepository().UserOrchestrator().CreateUser(ctx, &schema.User{
|
|
Username: user.Username,
|
|
ExternalIdentityProviderID: user.ID,
|
|
DisplayName: user.DisplayName,
|
|
AccountDisabled: user.AccountDisabled,
|
|
Password: password,
|
|
AuthType: schema.OAuth,
|
|
PlatformRoleID: schema.ServiceUser,
|
|
EmailValidated: true,
|
|
})
|
|
if createErr != nil {
|
|
if errors.Is(createErr, logic.ErrUserLimitExceeded) {
|
|
logger.Log(0, "idp sync: skipping user", user.Username, "user limit reached for tenant", scope.ID(ctx))
|
|
continue
|
|
}
|
|
return createErr
|
|
}
|
|
|
|
// It's possible that a user can attempt to log in to Netmaker
|
|
// after the IDP is configured but before the users are synced.
|
|
// Since the user doesn't exist, a pending user will be
|
|
// created. Now, since the user is created, the pending user
|
|
// can be deleted.
|
|
_ = (&schema.PendingUser{
|
|
Username: user.Username,
|
|
}).Delete(ctx)
|
|
} else if dbUser.AuthType == schema.OAuth {
|
|
if dbUser.PlatformRoleID != schema.SuperAdminRole &&
|
|
(dbUser.AccountDisabled != user.AccountDisabled ||
|
|
dbUser.DisplayName != user.DisplayName ||
|
|
dbUser.ExternalIdentityProviderID != user.ID) {
|
|
|
|
dbUser.AccountDisabled = user.AccountDisabled
|
|
dbUser.DisplayName = user.DisplayName
|
|
dbUser.ExternalIdentityProviderID = user.ID
|
|
|
|
err = logic.UpsertUser(*dbUser)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tm := &schema.TenantMembership{
|
|
TenantID: scope.ID(ctx),
|
|
UserID: dbUser.ID,
|
|
ExternalIdentityProviderID: user.ID,
|
|
}
|
|
err = tm.UpdateExternalIdentityProviderID(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = dbUser.UpdateAccountStatus(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
logger.Log(0, "user with username "+user.Username+" already exists, skipping creation")
|
|
continue
|
|
}
|
|
}
|
|
|
|
for _, user := range dbUsersMap {
|
|
if user.ExternalIdentityProviderID != "" {
|
|
if _, ok := idpUsersMap[user.Username]; !ok {
|
|
if user.PlatformRoleID == schema.SuperAdminRole && !removeIntegration {
|
|
continue
|
|
}
|
|
|
|
// delete the user if it has been deleted on idp
|
|
// or is filtered out.
|
|
err = logic.DeleteTenantUser(ctx, user, true, cleanupUserRefs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func syncGroups(ctx context.Context, idpGroups []idp.Group, filters []string) error {
|
|
dbGroups, err := (&schema.UserGroup{}).ListAll(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dbUsers, err := (&schema.User{}).ListAllWithMembership(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
idpGroupsMap := make(map[string]struct{})
|
|
for _, group := range idpGroups {
|
|
idpGroupsMap[group.ID] = struct{}{}
|
|
}
|
|
|
|
dbGroupsMap := make(map[string]schema.UserGroup)
|
|
for _, group := range dbGroups {
|
|
if group.ExternalIdentityProviderID != "" {
|
|
dbGroupsMap[group.ExternalIdentityProviderID] = group
|
|
}
|
|
}
|
|
|
|
dbUsersMap := make(map[string]*schema.User)
|
|
for _, user := range dbUsers {
|
|
if user.ExternalIdentityProviderID != "" {
|
|
dbUsersMap[user.ExternalIdentityProviderID] = &user
|
|
}
|
|
}
|
|
|
|
modifiedUsers := make(map[string]struct{})
|
|
|
|
for _, group := range idpGroups {
|
|
var found bool
|
|
for _, filter := range filters {
|
|
if strings.HasPrefix(group.Name, filter) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// if there are filters but none of them match, then skip this group.
|
|
if len(filters) > 0 && !found {
|
|
continue
|
|
}
|
|
|
|
dbGroup, ok := dbGroupsMap[group.ID]
|
|
if !ok {
|
|
dbGroup.ExternalIdentityProviderID = group.ID
|
|
dbGroup.Name = group.Name
|
|
dbGroup.Default = false
|
|
dbGroup.NetworkRoles = datatypes.NewJSONType(schema.NetworkRoles{})
|
|
err := proLogic.CreateUserGroup(ctx, &dbGroup)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
dbGroup.Name = group.Name
|
|
err = proLogic.UpdateUserGroup(ctx, dbGroup)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
groupMembersMap := make(map[string]struct{})
|
|
for _, member := range group.Members {
|
|
groupMembersMap[member] = struct{}{}
|
|
}
|
|
|
|
for _, user := range dbUsers {
|
|
// use dbGroup.Name because the group name may have been changed on idp.
|
|
_, inNetmakerGroup := user.UserGroups.Data()[dbGroup.ID]
|
|
_, inIDPGroup := groupMembersMap[user.ExternalIdentityProviderID]
|
|
|
|
if inNetmakerGroup && !inIDPGroup {
|
|
// use dbGroup.Name because the group name may have been changed on idp.
|
|
delete(dbUsersMap[user.ExternalIdentityProviderID].UserGroups.Data(), dbGroup.ID)
|
|
modifiedUsers[user.ExternalIdentityProviderID] = struct{}{}
|
|
}
|
|
|
|
if !inNetmakerGroup && inIDPGroup {
|
|
// use dbGroup.Name because the group name may have been changed on idp.
|
|
dbUsersMap[user.ExternalIdentityProviderID].UserGroups.Data()[dbGroup.ID] = struct{}{}
|
|
modifiedUsers[user.ExternalIdentityProviderID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
for userID := range modifiedUsers {
|
|
user, ok := dbUsersMap[userID]
|
|
if ok {
|
|
tm := &schema.TenantMembership{
|
|
TenantID: scope.ID(ctx),
|
|
UserID: user.ID,
|
|
Groups: user.UserGroups,
|
|
}
|
|
err = tm.UpdateGroups(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if len(modifiedUsers) > 0 {
|
|
postureCtx := scope.WithContext(db.WithContext(context.Background()), scope.Level(ctx), scope.ID(ctx))
|
|
go proLogic.RunPostureChecksForTenant(postureCtx)
|
|
}
|
|
|
|
for _, group := range dbGroups {
|
|
if group.ExternalIdentityProviderID != "" {
|
|
if _, ok := idpGroupsMap[group.ExternalIdentityProviderID]; !ok {
|
|
// delete the group if it has been deleted on idp
|
|
// or is filtered out.
|
|
err = proLogic.DeleteAndCleanUpGroup(&group)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func GetIDPSyncStatus(ctx context.Context) models.IDPSyncStatus {
|
|
tenantID := scope.ID(ctx)
|
|
mtx := tenantSyncLock(tenantID)
|
|
if mtx.TryLock() {
|
|
defer mtx.Unlock()
|
|
idpSyncStateMtx.Lock()
|
|
err := idpSyncErrs[tenantID]
|
|
idpSyncStateMtx.Unlock()
|
|
if err == nil {
|
|
return models.IDPSyncStatus{
|
|
Status: "completed",
|
|
}
|
|
}
|
|
return models.IDPSyncStatus{
|
|
Status: "failed",
|
|
Description: err.Error(),
|
|
}
|
|
}
|
|
return models.IDPSyncStatus{
|
|
Status: "in_progress",
|
|
}
|
|
}
|
|
|
|
func filterUsersByGroupMembership(idpUsers []idp.User, idpGroups []idp.Group) []idp.User {
|
|
usersMap := make(map[string]int)
|
|
for i, user := range idpUsers {
|
|
usersMap[user.ID] = i
|
|
}
|
|
|
|
filteredUsersMap := make(map[string]int)
|
|
for _, group := range idpGroups {
|
|
for _, member := range group.Members {
|
|
if userIdx, ok := usersMap[member]; ok {
|
|
// user at index `userIdx` is a member of at least one of the
|
|
// groups in the `idpGroups` list, so we keep it.
|
|
filteredUsersMap[member] = userIdx
|
|
}
|
|
}
|
|
}
|
|
|
|
i := 0
|
|
filteredUsers := make([]idp.User, len(filteredUsersMap))
|
|
for _, userIdx := range filteredUsersMap {
|
|
filteredUsers[i] = idpUsers[userIdx]
|
|
i++
|
|
}
|
|
|
|
return filteredUsers
|
|
}
|
|
|
|
func filterGroupsByMembers(idpGroups []idp.Group, idpUsers []idp.User) []idp.Group {
|
|
usersMap := make(map[string]int)
|
|
for i, user := range idpUsers {
|
|
usersMap[user.ID] = i
|
|
}
|
|
|
|
filteredGroupsMap := make(map[int]bool)
|
|
for i, group := range idpGroups {
|
|
var members []string
|
|
for _, member := range group.Members {
|
|
if _, ok := usersMap[member]; ok {
|
|
members = append(members, member)
|
|
}
|
|
}
|
|
|
|
if len(members) > 0 {
|
|
// the group at index `i` has members from the `idpUsers` list,
|
|
// so we keep it.
|
|
filteredGroupsMap[i] = true
|
|
// filter out members that were not provided in the `idpUsers` list.
|
|
idpGroups[i].Members = members
|
|
}
|
|
}
|
|
|
|
i := 0
|
|
filteredGroups := make([]idp.Group, len(filteredGroupsMap))
|
|
for groupIdx := range filteredGroupsMap {
|
|
filteredGroups[i] = idpGroups[groupIdx]
|
|
i++
|
|
}
|
|
|
|
return filteredGroups
|
|
}
|
|
|
|
func cleanupUserRefs(ctx context.Context, username string, forceDeleteConfigs bool) {
|
|
extclients, err := logic.GetAllExtClients(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, extclient := range extclients {
|
|
if extclient.OwnerID == username {
|
|
err = logic.DeleteExtClientAndCleanup(ctx, extclient)
|
|
if err == nil {
|
|
_ = mq.PublishDeletedClientPeerUpdate(ctx, &extclient)
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = (&schema.UserInvite{
|
|
Email: username,
|
|
}).DeleteByEmail(ctx)
|
|
|
|
_ = mq.PublishPeerUpdate(ctx, false)
|
|
}
|