From f17ae26506d72726f79de7718e90191987089e67 Mon Sep 17 00:00:00 2001 From: Arthur Lu Date: Thu, 26 Mar 2026 19:35:55 +0000 Subject: [PATCH] implement pool-group and group-user ownership routes, fix issue with config var scoping, fix missing return in early exit conditions --- app/common/config.go | 3 +- app/common/types.go | 19 ------- app/ldap/ldap.go | 38 ++++++------- app/main.go | 84 +++++++++++++++++++++++++++-- app/operations.go | 79 +++++++++++++++++++++++++++ app/pve/pve.go | 126 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 303 insertions(+), 46 deletions(-) diff --git a/app/common/config.go b/app/common/config.go index e9fe41f..3b572cb 100644 --- a/app/common/config.go +++ b/app/common/config.go @@ -12,7 +12,8 @@ type LDAPConfig struct { } type PVEConfig struct { - URL string `json:"url"` + URL string `json:"url"` + PAASClientRole string `json:"paas-client-role"` } type RealmConfig struct { diff --git a/app/common/types.go b/app/common/types.go index ba2d4d2..0e66048 100644 --- a/app/common/types.go +++ b/app/common/types.go @@ -1,24 +1,5 @@ package app -type BackendClient interface { - BindUser(username string, password string) error - - //GetAllUsers() ([]User, int, error) - GetUser(username string) (User, int, error) - AddUser(username string, user User) (int, error) - ModUser(username string, user User) (int, error) - DelUser(username string) (int, error) - - //GetAllGroups() ([]Group, int, error) - GetGroup(groupname string) (Group, int, error) - AddGroup(groupname string, group Group) (int, error) - ModGroup(groupname string, group Group) (int, error) - DelGroup(groupname string) (int, error) - - AddUserToGroup(username string, groupname string) - RemoveUserFromGroup(username string, groupname string) -} - type Pool struct { PoolID string `json:"poolid"` Path string `json:"-"` // typically /pool/poolid from proxmox, only used internally diff --git a/app/ldap/ldap.go b/app/ldap/ldap.go index f60ca89..d10ce10 100644 --- a/app/ldap/ldap.go +++ b/app/ldap/ldap.go @@ -13,10 +13,8 @@ import ( // LDAPClient wrapper struct containing the connection, baseDN, peopleDN, and groupsDN type LDAPClient struct { - client *ldap.Conn - basedn string - peopledn string - groupsdn string + config *common.LDAPConfig + client *ldap.Conn } // returns a new LDAPClient from the config @@ -34,13 +32,11 @@ func NewClientFromCredentials(config common.LDAPConfig, username common.Username } ldap := LDAPClient{ - client: LDAPConn, - basedn: config.BaseDN, - peopledn: "ou=people," + config.BaseDN, - groupsdn: "ou=groups," + config.BaseDN, + config: &config, + client: LDAPConn, } - userdn := fmt.Sprintf("uid=%s,%s", username.UserID, ldap.peopledn) + userdn := fmt.Sprintf("uid=%s,ou=people,%s", username.UserID, ldap.config.BaseDN) err = ldap.client.Bind(userdn, password) if err != nil { @@ -54,7 +50,7 @@ func (l LDAPClient) GetUser(username common.Username) (common.User, int, error) user := common.User{} searchRequest := ldap.NewSearchRequest( // setup search for user by uid - fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn), // The base dn to search + fmt.Sprintf("uid=%s,ou=people,%s", username.UserID, l.config.BaseDN), // The base dn to search ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(&(objectClass=inetOrgPerson))", // The filter to apply []string{"dn", "cn", "sn", "mail", "uid", "memberOf"}, // A list attributes to retrieve @@ -82,7 +78,7 @@ func (l LDAPClient) NewUser(username common.Username, user common.User) (int, er } addRequest := ldap.NewAddRequest( - fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn), // DN + fmt.Sprintf("uid=%s,ou=people,%s", username.UserID, l.config.BaseDN), // DN nil, // controls ) addRequest.Attribute("sn", []string{user.SN}) @@ -108,7 +104,7 @@ func (l LDAPClient) ModUser(username common.Username, user common.User) (int, er } modifyRequest := ldap.NewModifyRequest( - fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn), + fmt.Sprintf("uid=%s,ou=people,%s", username.UserID, l.config.BaseDN), nil, ) if user.CN != "" { @@ -133,7 +129,7 @@ func (l LDAPClient) ModUser(username common.Username, user common.User) (int, er } func (l LDAPClient) DelUser(username common.Username) (int, error) { - userDN := fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn) + userDN := fmt.Sprintf("uid=%s,ou=people,%s", username.UserID, l.config.BaseDN) // assumes that olcMemberOfRefint=true updates member attributes of referenced groups @@ -154,7 +150,7 @@ func (l LDAPClient) GetGroup(groupname common.Groupname) (common.Group, int, err group := common.Group{} searchRequest := ldap.NewSearchRequest( // setup search for user by uid - fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn), // The base dn to search + fmt.Sprintf("cn=%s,ou=groups,%s", groupname.GroupID, l.config.BaseDN), // The base dn to search ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(&(objectClass=groupOfNames))", // The filter to apply []string{"cn", "member"}, // A list attributes to retrieve @@ -174,7 +170,7 @@ func (l LDAPClient) GetGroup(groupname common.Groupname) (common.Group, int, err func (l LDAPClient) NewGroup(groupname common.Groupname) (int, error) { addRequest := ldap.NewAddRequest( - fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn), // DN + fmt.Sprintf("cn=%s,ou=groups,%s", groupname.GroupID, l.config.BaseDN), // DN nil, // controls ) addRequest.Attribute("cn", []string{groupname.GroupID}) @@ -191,7 +187,7 @@ func (l LDAPClient) NewGroup(groupname common.Groupname) (int, error) { func (l LDAPClient) ModGroup(groupname common.Groupname, group common.Group) (int, error) { modifyRequest := ldap.NewModifyRequest( - fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn), + fmt.Sprintf("cn=%s,ou=groups,%s", groupname.GroupID, l.config.BaseDN), nil, ) @@ -206,7 +202,7 @@ func (l LDAPClient) ModGroup(groupname common.Groupname, group common.Group) (in } func (l LDAPClient) DelGroup(groupname common.Groupname) (int, error) { - groupDN := fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn) + groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupname.GroupID, l.config.BaseDN) // assumes that memberOf overlay will automatically update referenced memberOf attributes @@ -224,8 +220,8 @@ func (l LDAPClient) DelGroup(groupname common.Groupname) (int, error) { } func (l LDAPClient) AddUserToGroup(username common.Username, groupname common.Groupname) (int, error) { - userDN := fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn) - groupDN := fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn) + userDN := fmt.Sprintf("uid=%s,ou=people,%s", username.UserID, l.config.BaseDN) + groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupname.GroupID, l.config.BaseDN) modifyRequest := ldap.NewModifyRequest( // modify group member value groupDN, @@ -243,8 +239,8 @@ func (l LDAPClient) AddUserToGroup(username common.Username, groupname common.Gr } func (l LDAPClient) DelUserFromGroup(username common.Username, groupname common.Groupname) (int, error) { - userDN := fmt.Sprintf("uid=%s,%s", username.UserID, l.peopledn) - groupDN := fmt.Sprintf("cn=%s,%s", groupname.GroupID, l.groupsdn) + userDN := fmt.Sprintf("uid=%s,ou=people,%s", username.UserID, l.config.BaseDN) + groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupname.GroupID, l.config.BaseDN) modifyRequest := ldap.NewModifyRequest( // modify group member value groupDN, diff --git a/app/main.go b/app/main.go index b804c83..f71103a 100644 --- a/app/main.go +++ b/app/main.go @@ -9,7 +9,7 @@ import ( common "user-manager-api/app/common" ldap "user-manager-api/app/ldap" - "user-manager-api/app/pve" + pve "user-manager-api/app/pve" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -23,15 +23,16 @@ var UserSessions map[string]*Backends func Run(configPath *string) { // load config values - Config, err := common.GetConfig(*configPath) + var err error + Config, err = common.GetConfig(*configPath) if err != nil { log.Fatalf("Error when reading config file: %s\n", err) } log.Printf("Read in config from %s\n", *configPath) // setup router - router := SetupAPISessionStore(&Config) gin.SetMode(gin.ReleaseMode) + router := SetupAPISessionStore(&Config) // make global session map UserSessions = make(map[string]*Backends) @@ -42,7 +43,8 @@ func Run(configPath *string) { router.POST("/ticket", func(c *gin.Context) { body := common.Login{} - if err := c.ShouldBind(&body); err != nil { // bad request from binding + err := c.ShouldBind(&body) + if err != nil { // bad request from binding c.JSON(http.StatusBadRequest, gin.H{"auth": false, "error": err.Error()}) return } @@ -105,11 +107,13 @@ func Run(configPath *string) { poolid, ok := c.Params.Get("poolid") if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter poolid")}) + return } backends, code, err := GetBackendsFromContext(c) if err != nil { c.JSON(code, gin.H{"error": err.Error()}) + return } code, err = NewPool(backends, poolid) @@ -124,11 +128,13 @@ func Run(configPath *string) { poolid, ok := c.Params.Get("poolid") if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter poolid")}) + return } backends, code, err := GetBackendsFromContext(c) if err != nil { c.JSON(code, gin.H{"error": err.Error()}) + return } code, err = DelPool(backends, poolid) @@ -143,15 +149,18 @@ func Run(configPath *string) { groupid, ok := c.Params.Get("groupid") if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter groupid")}) + return } groupname, err := common.ParseGroupname(groupid) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err}) + return } backends, code, err := GetBackendsFromContext(c) if err != nil { c.JSON(code, gin.H{"error": err.Error()}) + return } code, err = NewGroup(backends, groupname) @@ -166,15 +175,18 @@ func Run(configPath *string) { groupid, ok := c.Params.Get("groupid") if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter groupid")}) + return } groupname, err := common.ParseGroupname(groupid) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err}) + return } backends, code, err := GetBackendsFromContext(c) if err != nil { c.JSON(code, gin.H{"error": err.Error()}) + return } code, err = DelGroup(backends, groupname) @@ -185,6 +197,70 @@ func Run(configPath *string) { } }) + router.POST("/pools/:poolid/groups/:groupid", func(c *gin.Context) { + poolid, ok := c.Params.Get("poolid") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter groupid")}) + return + } + groupid, ok := c.Params.Get("groupid") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter groupid")}) + return + } + + groupname, err := common.ParseGroupname(groupid) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err}) + return + } + + backends, code, err := GetBackendsFromContext(c) + if err != nil { + c.JSON(code, gin.H{"error": err.Error()}) + return + } + + code, err = AddGroupToPool(backends, groupname, poolid) + if err != nil { + c.JSON(code, gin.H{"error": err.Error()}) + } else { + c.Status(200) + } + }) + + router.DELETE("/pools/:poolid/groups/:groupid", func(c *gin.Context) { + poolid, ok := c.Params.Get("poolid") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter groupid")}) + return + } + groupid, ok := c.Params.Get("groupid") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("Missing required path parameter groupid")}) + return + } + + groupname, err := common.ParseGroupname(groupid) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err}) + return + } + + backends, code, err := GetBackendsFromContext(c) + if err != nil { + c.JSON(code, gin.H{"error": err.Error()}) + return + } + + code, err = DelGroupFromPool(backends, groupname, poolid) + if err != nil { + c.JSON(code, gin.H{"error": err.Error()}) + } else { + c.Status(200) + } + }) + log.Printf("Starting User Manager API on port %s\n", strconv.Itoa(Config.ListenPort)) err = router.Run("0.0.0.0:" + strconv.Itoa(Config.ListenPort)) diff --git a/app/operations.go b/app/operations.go index 8f6f7ed..2fa42d5 100644 --- a/app/operations.go +++ b/app/operations.go @@ -17,6 +17,7 @@ func NewGroup(backends *Backends, groupname common.Groupname) (int, error) { case "pve": return backends.pve.NewGroup(groupname) case "ldap": + code, err := backends.ldap.NewGroup(groupname) if err != nil { return code, err @@ -44,3 +45,81 @@ func DelGroup(backends *Backends, groupname common.Groupname) (int, error) { } return 200, nil } + +func AddGroupToPool(backends *Backends, groupname common.Groupname, poolname string) (int, error) { + // only pve backend handles pool-group membership + return backends.pve.AddGroupToPool(groupname, poolname) +} + +func DelGroupFromPool(backends *Backends, groupname common.Groupname, poolname string) (int, error) { + // only pve backend handles pool-group membership + return backends.pve.DelGroupFromPool(groupname, poolname) +} + +func NewUser(backends *Backends, username common.Username, user common.User) (int, error) { + handler := Config.Realms[username.Realm].Handler + switch handler { + case "pve": + return backends.pve.NewUser(username, user) + case "ldap": + code, err := backends.ldap.NewUser(username, user) + if err != nil { + return code, err + } + + //pve sync + return backends.pve.SyncRealms() + } + return 200, nil +} + +func DelUser(backends *Backends, username common.Username) (int, error) { + handler := Config.Realms[username.Realm].Handler + switch handler { + case "pve": + return backends.pve.DelUser(username) + case "ldap": + code, err := backends.ldap.DelUser(username) + if err != nil { + return code, err + } + + //pve sync + return backends.pve.SyncRealms() + } + return 200, nil +} + +func AddUserToGroup(backends *Backends, username common.Username, groupname common.Groupname) (int, error) { + handler := Config.Realms[username.Realm].Handler + switch handler { + case "pve": + return backends.pve.AddUserToGroup(username, groupname) + case "ldap": + code, err := backends.ldap.AddUserToGroup(username, groupname) + if err != nil { + return code, err + } + + //pve sync + return backends.pve.SyncRealms() + } + return 200, nil +} + +func DelUserFromGroup(backends *Backends, username common.Username, groupname common.Groupname) (int, error) { + handler := Config.Realms[username.Realm].Handler + switch handler { + case "pve": + return backends.pve.DelUserFromGroup(username, groupname) + case "ldap": + code, err := backends.ldap.DelUserFromGroup(username, groupname) + if err != nil { + return code, err + } + + //pve sync + return backends.pve.SyncRealms() + } + return 200, nil +} diff --git a/app/pve/pve.go b/app/pve/pve.go index b10baa0..dd9afbe 100644 --- a/app/pve/pve.go +++ b/app/pve/pve.go @@ -3,7 +3,9 @@ package pve import ( "context" "crypto/tls" + "fmt" "net/http" + "slices" common "user-manager-api/app/common" @@ -11,9 +13,11 @@ import ( ) type ProxmoxClient struct { + config *common.PVEConfig client *proxmox.Client } +// creates a new client binding with associated permissions func NewClientFromCredentials(config common.PVEConfig, username common.Username, password string) (*ProxmoxClient, int, error) { HTTPClient := http.Client{ Transport: &http.Transport{ @@ -30,7 +34,7 @@ func NewClientFromCredentials(config common.PVEConfig, username common.Username, // todo this should return an error code if the binding failed (ie fetch version to check if the auth was actually ok) - return &ProxmoxClient{client: client}, http.StatusOK, nil + return &ProxmoxClient{config: &config, client: client}, http.StatusOK, nil } func (pve ProxmoxClient) SyncRealms() (int, error) { @@ -115,3 +119,123 @@ func (pve ProxmoxClient) DelGroup(groupname common.Groupname) (int, error) { return 200, nil } } + +func (pve ProxmoxClient) AddGroupToPool(groupname common.Groupname, poolname string) (int, error) { + // adds the group to the pool with the predetermined PAAS client role + err := pve.client.UpdateACL(context.Background(), proxmox.ACLOptions{ + Path: fmt.Sprintf("/pool/%s", poolname), + Groups: groupname.ToString(), + Roles: pve.config.PAASClientRole, + Propagate: true, + }) + + if proxmox.IsNotAuthorized(err) { + return 401, err + } else if err != nil { + return 500, err + } else { + return 200, nil + } +} + +func (pve ProxmoxClient) DelGroupFromPool(groupname common.Groupname, poolname string) (int, error) { + // removes the group from the pool with the predetermined PAAS client role + err := pve.client.UpdateACL(context.Background(), proxmox.ACLOptions{ + Path: fmt.Sprintf("/pool/%s", poolname), + Groups: groupname.ToString(), + Roles: pve.config.PAASClientRole, + Delete: true, + }) + + if proxmox.IsNotAuthorized(err) { + return 401, err + } else if err != nil { + return 500, err + } else { + return 200, nil + } +} + +func (pve ProxmoxClient) NewUser(username common.Username, user common.User) (int, error) { + err := pve.client.NewUser(context.Background(), &proxmox.NewUser{ + UserID: username.ToString(), + Firstname: user.CN, + Lastname: user.SN, + Email: user.Mail, + Password: user.Password, + }) + if proxmox.IsNotAuthorized(err) { + return 401, err + } else if err != nil { + return 500, err + } else { + return 200, nil + } +} + +func (pve ProxmoxClient) DelUser(username common.Username) (int, error) { + user, err := pve.client.User(context.Background(), username.ToString()) + if proxmox.IsNotAuthorized(err) { + return 401, err // not authorized to read the user = not authorized to delete the user, this may be slightly different from ldap + } else if err != nil { + return 500, err + } + + // assume that user cannot be nil if no error was returned + err = user.Delete(context.Background()) + if proxmox.IsNotAuthorized(err) { + return 401, err // not authorized to delete the user + } else if err != nil { + return 500, err + } else { + return 200, nil + } +} + +func (pve ProxmoxClient) AddUserToGroup(username common.Username, groupname common.Groupname) (int, error) { + user, err := pve.client.User(context.Background(), groupname.ToString()) + if proxmox.IsNotAuthorized(err) { + return 401, err // not authorized to read the user = not authorized to delete the user, this may be slightly different from ldap + } else if err != nil { + return 500, err + } + + newGroups := append(user.Groups, groupname.ToString()) + + err = user.Update(context.Background(), proxmox.UserOptions{ + Groups: newGroups, + }) + if proxmox.IsNotAuthorized(err) { + return 401, err // not authorized to delete the user + } else if err != nil { + return 500, err + } else { + return 200, nil + } +} + +func (pve ProxmoxClient) DelUserFromGroup(username common.Username, groupname common.Groupname) (int, error) { + user, err := pve.client.User(context.Background(), groupname.ToString()) + if proxmox.IsNotAuthorized(err) { + return 401, err // not authorized to read the user = not authorized to delete the user, this may be slightly different from ldap + } else if err != nil { + return 500, err + } + + idx := slices.Index(user.Groups, groupname.ToString()) + if idx < 0 { + return http.StatusBadRequest, fmt.Errorf("Did not find group %s in user groups {%+v}.", groupname.ToString(), user.Groups) + } + newGroups := slices.Delete(user.Groups, idx, idx) + + err = user.Update(context.Background(), proxmox.UserOptions{ + Groups: newGroups, + }) + if proxmox.IsNotAuthorized(err) { + return 401, err // not authorized to delete the user + } else if err != nil { + return 500, err + } else { + return 200, nil + } +}