Files
netmaker/pro/auth/sync.go
T
Abhishek Kondur edda2868fc NM-163: Users, Groups, Roles, Networks and Hosts Table Migration (#3910)
* feat(go): add user schema;

* feat(go): migrate to user schema;

* feat(go): add audit fields;

* feat(go): remove unused fields from the network model;

* feat(go): add network schema;

* feat(go): migrate to network schema;

* refactor(go): add comment to clarify migration logic;

* fix(go): test failures;

* fix(go): test failures;

* feat(go): change membership table to store memberships at all scopes;

* feat(go): add schema for access grants;

* feat(go): remove nameservers from new networks table; ensure db passed for schema functions;

* feat(go): set max conns for sqlite to 1;

* fix(go): issues updating user account status;

* refactor(go): remove converters and access grants;

* refactor(go): add json tags in schema models;

* refactor(go): rename file to migrate_v1_6_0.go;

* refactor(go): add user groups and user roles tables; use schema tables;

* refactor(go): inline get and list from schema package;

* refactor(go): inline get network and list users from schema package;

* fix(go): staticcheck issues;

* fix(go): remove test not in use; fix test case;

* fix(go): validate network;

* fix(go): resolve static checks;

* fix(go): new models errors;

* fix(go): test errors;

* fix(go): handle no records;

* fix(go): add validations for user object;

* fix(go): set correct extclient status;

* fix(go): test error;

* feat(go): make schema the base package;

* feat(go): add host schema;

* feat(go): use schema host everywhere;

* feat(go): inline get host, list hosts and delete host;

* feat(go): use non-ptr value;

* feat(go): use save to upsert all fields;

* feat(go): use save to upsert all fields;

* feat(go): save turn endpoint as string;

* feat(go): check for gorm error record not found;

* fix(go): test failures;

* fix(go): update all network fields;

* fix(go): update all network fields;

* feat(go): add paginated list networks api;

* feat(go): add paginated list users api;

* feat(go): add paginated list hosts api;

* feat(go): add pagination to list groups api;

* fix(go): comment;

* fix(go): implement marshal and unmarshal text for custom types;

* fix(go): implement marshal and unmarshal json for custom types;

* fix(go): just use the old model for unmarshalling;

* fix(go): implement marshal and unmarshal json for custom types;

* feat(go): remove paginated list networks api;

* feat(go): use custom paginated response object;

* fix(go): ensure default values for page and per_page are used when not passed;

* fix(go): rename v1.6.0 to v1.5.1;

* fix(go): check for gorm.ErrRecordNotFound instead of database.IsEmptyRecord;

* fix(go): use host id, not pending host id;

* feat(go): add filters to paginated apis;

* feat(go): add filters to paginated apis;

* feat(go): remove check for max username length;

* feat(go): add filters to count as well;

* feat(go): use library to check email address validity;

* feat(go): ignore pagination if params not passed;

* fix(go): pagination issues;

* fix(go): check exists before using;

* fix(go): remove debug log;

* fix(go): use gorm err record not found;

* fix(go): use gorm err record not found;

* fix(go): use user principal name when creating pending user;

* fix(go): use schema package for consts;

* fix(go): prevent disabling superadmin user;

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* fix(go): swap is admin and is superadmin;

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* fix(go): remove dead code block;

https://github.com/gravitl/netmaker/pull/3910#discussion_r2928837937

* fix(go): incorrect message when trying to disable self;

https://github.com/gravitl/netmaker/pull/3910#discussion_r2928837934

* fix(go): use correct header;

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* fix(go): return after error response;

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* fix(go): use correct order of params;

https://github.com/gravitl/netmaker/pull/3910#discussion_r2929593036

* fix(go): set default values for page and page size; use v2 instead of /list;

* Update logic/auth.go

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* Update schema/user_roles.go

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* fix(go): syntax error;

* fix(go): set default values when page and per_page are not passed or 0;

* fix(go): use uuid.parse instead of uuid.must parse;

* fix(go): review errors;

* fix(go): review errors;

* Update controllers/user.go

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* Update controllers/user.go

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* NM-163: fix errors:

* Update db/types/options.go

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* fix(go): persist return user in event;

* Update db/types/options.go

Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>

* NM-163: duplicate lines of code

* NM-163: fix(go): fix missing return and filter parsing in user controller

- Add missing return after error response in updateUserAccountStatus
  to prevent double-response and spurious ext-client side-effects
- Use switch statements in listUsers to skip unrecognized
  account_status and mfa_status filter values

* fix(go): check for both min and max page size;

* fix(go): enclose transfer superadmin in transaction;

* fix(go): review errors;

* fix(go): remove free tier checks;

* fix(go): review fixes;

---------

Co-authored-by: VishalDalwadi <dalwadivishal26@gmail.com>
Co-authored-by: Vishal Dalwadi <51291657+VishalDalwadi@users.noreply.github.com>
Co-authored-by: tenki-reviewer[bot] <262613592+tenki-reviewer[bot]@users.noreply.github.com>
2026-03-17 19:36:52 +05:30

496 lines
12 KiB
Go

package auth
import (
"context"
"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/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/servercfg"
"gorm.io/datatypes"
)
var (
cancelSyncHook context.CancelFunc
hookStopWg sync.WaitGroup
idpSyncMtx sync.Mutex
idpSyncErr error
)
func ResetIDPSyncHook() {
if cancelSyncHook != nil {
cancelSyncHook()
hookStopWg.Wait()
cancelSyncHook = nil
}
if logic.IsSyncEnabled() {
ctx, cancel := context.WithCancel(context.Background())
cancelSyncHook = cancel
hookStopWg.Add(1)
go runIDPSyncHook(ctx)
}
}
func runIDPSyncHook(ctx context.Context) {
defer hookStopWg.Done()
ticker := time.NewTicker(logic.GetIDPSyncInterval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Log(0, "idp sync hook stopped")
return
case <-ticker.C:
if err := SyncFromIDP(); err != nil {
logger.Log(0, "failed to sync from idp: ", err.Error())
} else {
logger.Log(0, "sync from idp complete")
}
}
}
}
func SyncFromIDP() error {
idpSyncMtx.Lock()
defer idpSyncMtx.Unlock()
settings := logic.GetServerSettings()
var idpClient idp.Client
var idpUsers []idp.User
var idpGroups []idp.Group
var err error
defer func() {
idpSyncErr = err
}()
switch settings.AuthProvider {
case "google":
idpClient, err = google.NewGoogleWorkspaceClientFromSettings()
if err != nil {
return err
}
case "azure-ad":
idpClient = azure.NewAzureEntraIDClientFromSettings()
case "okta":
idpClient, err = okta.NewOktaClientFromSettings()
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(idpUsers)
if err != nil {
return err
}
err = syncGroups(idpGroups)
return err
}
func syncUsers(idpUsers []idp.User) error {
dbUsers, err := (&schema.User{}).ListAll(db.WithContext(context.TODO()))
if err != nil {
return err
}
password, err := logic.FetchPassValue("")
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
}
filters := logic.GetServerSettings().UserFilters
for _, user := range idpUsers {
if user.AccountArchived {
// delete the user if it has been archived.
user, ok := dbUsersMap[user.Username]
if ok {
_ = deleteAndCleanUpUser(user)
}
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 {
// create the user only if it doesn't exist.
err = logic.CreateUser(&schema.User{
Username: user.Username,
ExternalIdentityProviderID: user.ID,
DisplayName: user.DisplayName,
AccountDisabled: user.AccountDisabled,
Password: password,
AuthType: schema.OAuth,
PlatformRoleID: schema.ServiceUser,
})
if err != nil {
return err
}
// 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.
_ = logic.DeletePendingUser(user.Username)
} else if dbUser.AuthType == schema.OAuth {
if 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
}
}
} 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 {
// delete the user if it has been deleted on idp
// or is filtered out.
err = deleteAndCleanUpUser(user)
if err != nil {
return err
}
}
}
}
return nil
}
func syncGroups(idpGroups []idp.Group) error {
dbGroups, err := (&schema.UserGroup{}).ListAll(db.WithContext(context.TODO()))
if err != nil {
return err
}
dbUsers, err := (&schema.User{}).ListAll(db.WithContext(context.TODO()))
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{})
filters := logic.GetServerSettings().GroupFilters
networks, err := (&schema.Network{}).ListAll(db.WithContext(context.TODO()))
if err != nil {
return err
}
var aclsUpdated bool
var acls []models.Acl
for _, network := range networks {
aclID := fmt.Sprintf("%s.%s-grp", network.Name, schema.NetworkUser)
acl, err := logic.GetAcl(aclID)
if err == nil {
acls = append(acls, acl)
}
}
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(&dbGroup)
if err != nil {
return err
}
for i := range acls {
acls[i].Src = append(acls[i].Src, models.AclPolicyTag{
ID: models.UserGroupAclID,
Value: dbGroup.ID.String(),
})
aclsUpdated = true
}
} else {
dbGroup.Name = group.Name
err = proLogic.UpdateUserGroup(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 {
err = logic.UpsertUser(*user)
if err != nil {
return err
}
}
}
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
}
}
}
}
if aclsUpdated {
for _, acl := range acls {
err = logic.UpsertAcl(acl)
if err != nil {
return err
}
}
}
return nil
}
func GetIDPSyncStatus() models.IDPSyncStatus {
if idpSyncMtx.TryLock() {
defer idpSyncMtx.Unlock()
if idpSyncErr == nil {
return models.IDPSyncStatus{
Status: "completed",
}
} else {
return models.IDPSyncStatus{
Status: "failed",
Description: idpSyncErr.Error(),
}
}
} else {
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
}
// TODO: deduplicate
// The cyclic import between the package logic and mq requires this
// function to be duplicated in multiple places.
func deleteAndCleanUpUser(user *schema.User) error {
err := logic.DeleteUser(user.Username)
if err != nil {
return err
}
// check and delete extclient with this ownerID
go func() {
extclients, err := logic.GetAllExtClients()
if err != nil {
return
}
for _, extclient := range extclients {
if extclient.OwnerID == user.Username {
err = logic.DeleteExtClientAndCleanup(extclient)
if err == nil {
_ = mq.PublishDeletedClientPeerUpdate(&extclient)
}
}
}
go logic.DeleteUserInvite(user.Username)
go mq.PublishPeerUpdate(false)
if servercfg.IsDNSMode() {
go logic.SetDNS()
}
}()
return nil
}