mirror of
https://github.com/gravitl/netmaker.git
synced 2026-04-23 00:17:10 +08:00
edda2868fc
* 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>
496 lines
12 KiB
Go
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
|
|
}
|