From 2b8a7e148f0a1e2d3ef82cd4830eba8a9a3f4495 Mon Sep 17 00:00:00 2001 From: Dion Gionet Mallet Date: Wed, 2 Sep 2026 16:23:38 -0400 Subject: [PATCH 1/2] [DEVOPS-4655] feat(users): add user and user group listing --- README.md | 2 + authentication.go | 8 ++- dvlstypes.go | 13 ++++ mock_test.go | 6 ++ user_groups.go | 53 ++++++++++++++++ user_groups_unit_test.go | 72 ++++++++++++++++++++++ userauthenticationtype_string.go | 11 ++-- usergrouptype_string.go | 28 +++++++++ users.go | 94 ++++++++++++++++++++++++++++ users_test.go | 65 ++++++++++++++++++++ users_unit_test.go | 101 +++++++++++++++++++++++++++++++ utils.go | 58 ++++++++++++++++++ 12 files changed, 505 insertions(+), 6 deletions(-) create mode 100644 user_groups.go create mode 100644 user_groups_unit_test.go create mode 100644 usergrouptype_string.go create mode 100644 users.go create mode 100644 users_test.go create mode 100644 users_unit_test.go diff --git a/README.md b/README.md index c9379d0..f3ae1ac 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Heavily based on the information found on the [Devolutions.Server](https://github.com/Devolutions/devolutions-server/tree/main/Powershell%20Module/Devolutions.Server) powershell module. +Users, applications and user groups (`client.Users`, `client.UserGroups`) expose the principal ids used as assignees in role assignments and as roles in entry permissions. + ## Usage - Run go get `go get github.com/Devolutions/go-dvls` - Add the import `import "github.com/Devolutions/go-dvls"` diff --git a/authentication.go b/authentication.go index 1163688..b1b00ab 100644 --- a/authentication.go +++ b/authentication.go @@ -18,8 +18,10 @@ type Client struct { common service - Entries *Entries - Vaults *Vaults + Entries *Entries + Vaults *Vaults + Users *Users + UserGroups *UserGroups } type service struct { @@ -96,6 +98,8 @@ func (c *Client) initServices() { Website: (*EntryWebsiteService)(&c.common), } c.Vaults = (*Vaults)(&c.common) + c.Users = (*Users)(&c.common) + c.UserGroups = (*UserGroups)(&c.common) } func (c *Client) login() error { diff --git a/dvlstypes.go b/dvlstypes.go index 8dad657..81a8c35 100644 --- a/dvlstypes.go +++ b/dvlstypes.go @@ -33,6 +33,19 @@ const ( UserAuthenticationAzureAD UserAuthenticationApplication UserAuthenticationOkta + UserAuthenticationPingOne + UserAuthenticationContractor +) + +//go:generate stringer -type=UserGroupType -trimprefix UserGroupType +type UserGroupType uint8 + +const ( + UserGroupTypeActiveDirectory UserGroupType = iota + UserGroupTypeCustom + UserGroupTypeOffice365 + UserGroupTypeOkta + UserGroupTypePingOne ) //go:generate stringer -type=ServerLoginResult -trimprefix ServerLogin diff --git a/mock_test.go b/mock_test.go index 98dd5f6..cc927f0 100644 --- a/mock_test.go +++ b/mock_test.go @@ -8,6 +8,12 @@ import ( const testVaultID = "test-vault-id" +const ( + testRoleID = "11111111-2222-3333-4444-555555555555" + testAssigneeID = "66666666-7777-8888-9999-000000000000" + testUserID = "12121212-3434-5656-7878-909090909090" +) + func newTestClient(t *testing.T, mux *http.ServeMux) *Client { t.Helper() diff --git a/user_groups.go b/user_groups.go new file mode 100644 index 0000000..178b9b3 --- /dev/null +++ b/user_groups.go @@ -0,0 +1,53 @@ +package dvls + +import ( + "context" + "fmt" +) + +const userGroupListEndpoint = "/api/security/roles/basic" + +var ErrUserGroupNotFound = fmt.Errorf("user group not found") +var ErrMultipleUserGroupsFound = fmt.Errorf("multiple user groups found") + +type UserGroups service + +// UserGroup represents a DVLS user group. +type UserGroup struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsAdministrator bool `json:"isAdministrator"` + Type UserGroupType `json:"roleType"` +} + +// List returns all user groups. +func (c *UserGroups) List() ([]UserGroup, error) { + return c.ListWithContext(context.Background()) +} + +// ListWithContext returns all user groups. +// The provided context can be used to cancel the request. +func (c *UserGroups) ListWithContext(ctx context.Context) ([]UserGroup, error) { + return fetchDataList[UserGroup](ctx, c.client, userGroupListEndpoint, "user groups") +} + +// GetByName returns a single user group based on name. +// Returns ErrUserGroupNotFound if no user group is found. +// Returns ErrMultipleUserGroupsFound if more than one user group matches the name. +func (c *UserGroups) GetByName(name string) (UserGroup, error) { + return c.GetByNameWithContext(context.Background(), name) +} + +// GetByNameWithContext returns a single user group based on name. +// Returns ErrUserGroupNotFound if no user group is found. +// Returns ErrMultipleUserGroupsFound if more than one user group matches the name. +// The provided context can be used to cancel the request. +func (c *UserGroups) GetByNameWithContext(ctx context.Context, name string) (UserGroup, error) { + groups, err := c.ListWithContext(ctx) + if err != nil { + return UserGroup{}, err + } + + return singleByName(groups, name, func(g UserGroup) string { return g.Name }, ErrUserGroupNotFound, ErrMultipleUserGroupsFound) +} diff --git a/user_groups_unit_test.go b/user_groups_unit_test.go new file mode 100644 index 0000000..4b34df9 --- /dev/null +++ b/user_groups_unit_test.go @@ -0,0 +1,72 @@ +package dvls + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserGroupsList(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/security/roles/basic", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":1,"data":[ + {"id":"` + testRoleID + `","name":"Ops","description":"Operations","isAdministrator":true,"roleType":1}, + {"id":"` + testAssigneeID + `","name":"Domain Admins","description":"","roleType":0} + ]}`)) + }) + + client := newTestClient(t, mux) + + groups, err := client.UserGroups.List() + require.NoError(t, err) + require.Len(t, groups, 2) + assert.Equal(t, testRoleID, groups[0].Id) + assert.Equal(t, "Ops", groups[0].Name) + assert.True(t, groups[0].IsAdministrator) + assert.Equal(t, UserGroupTypeCustom, groups[0].Type) + assert.Equal(t, UserGroupTypeActiveDirectory, groups[1].Type) +} + +func TestUserGroupsList_ResultError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/security/roles/basic", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":2,"message":"AccessDenied"}`)) + }) + + client := newTestClient(t, mux) + + _, err := client.UserGroups.List() + require.Error(t, err) + assert.Contains(t, err.Error(), "AccessDenied") +} + +func TestUserGroupsGetByName(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/security/roles/basic", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":1,"data":[ + {"id":"` + testRoleID + `","name":"Ops"}, + {"id":"` + testAssigneeID + `","name":"Dup"}, + {"id":"` + testUserID + `","name":"Dup"} + ]}`)) + }) + + client := newTestClient(t, mux) + + group, err := client.UserGroups.GetByName("Ops") + require.NoError(t, err) + assert.Equal(t, testRoleID, group.Id) + + _, err = client.UserGroups.GetByName("Dup") + assert.ErrorIs(t, err, ErrMultipleUserGroupsFound) + + _, err = client.UserGroups.GetByName("nope") + assert.ErrorIs(t, err, ErrUserGroupNotFound) +} diff --git a/userauthenticationtype_string.go b/userauthenticationtype_string.go index 91e9285..8d4208e 100644 --- a/userauthenticationtype_string.go +++ b/userauthenticationtype_string.go @@ -19,15 +19,18 @@ func _() { _ = x[UserAuthenticationAzureAD-8] _ = x[UserAuthenticationApplication-9] _ = x[UserAuthenticationOkta-10] + _ = x[UserAuthenticationPingOne-11] + _ = x[UserAuthenticationContractor-12] } -const _UserAuthenticationType_name = "BuiltinLocalWindowsSqlServerDomainOffice365NoneCloudLegacyAzureADApplicationOkta" +const _UserAuthenticationType_name = "BuiltinLocalWindowsSqlServerDomainOffice365NoneCloudLegacyAzureADApplicationOktaPingOneContractor" -var _UserAuthenticationType_index = [...]uint8{0, 7, 19, 28, 34, 43, 47, 52, 58, 65, 76, 80} +var _UserAuthenticationType_index = [...]uint8{0, 7, 19, 28, 34, 43, 47, 52, 58, 65, 76, 80, 87, 97} func (i UserAuthenticationType) String() string { - if i >= UserAuthenticationType(len(_UserAuthenticationType_index)-1) { + idx := int(i) - 0 + if i < 0 || idx >= len(_UserAuthenticationType_index)-1 { return "UserAuthenticationType(" + strconv.FormatInt(int64(i), 10) + ")" } - return _UserAuthenticationType_name[_UserAuthenticationType_index[i]:_UserAuthenticationType_index[i+1]] + return _UserAuthenticationType_name[_UserAuthenticationType_index[idx]:_UserAuthenticationType_index[idx+1]] } diff --git a/usergrouptype_string.go b/usergrouptype_string.go new file mode 100644 index 0000000..812c6ee --- /dev/null +++ b/usergrouptype_string.go @@ -0,0 +1,28 @@ +// Code generated by "stringer -type=UserGroupType -trimprefix UserGroupType"; DO NOT EDIT. + +package dvls + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[UserGroupTypeActiveDirectory-0] + _ = x[UserGroupTypeCustom-1] + _ = x[UserGroupTypeOffice365-2] + _ = x[UserGroupTypeOkta-3] + _ = x[UserGroupTypePingOne-4] +} + +const _UserGroupType_name = "ActiveDirectoryCustomOffice365OktaPingOne" + +var _UserGroupType_index = [...]uint8{0, 15, 21, 30, 34, 41} + +func (i UserGroupType) String() string { + idx := int(i) - 0 + if i < 0 || idx >= len(_UserGroupType_index)-1 { + return "UserGroupType(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _UserGroupType_name[_UserGroupType_index[idx]:_UserGroupType_index[idx+1]] +} diff --git a/users.go b/users.go new file mode 100644 index 0000000..631afd6 --- /dev/null +++ b/users.go @@ -0,0 +1,94 @@ +package dvls + +import ( + "context" + "fmt" +) + +const ( + userListEndpoint = "/api/security/users/list" + applicationListEndpoint = "/api/security/application/users/list" +) + +var ErrUserNotFound = fmt.Errorf("user not found") +var ErrMultipleUsersFound = fmt.Errorf("multiple users found") + +type Users service + +// User represents a DVLS user or application account. Application accounts +// have AuthenticationType UserAuthenticationApplication and their Name is the +// application key. +type User struct { + Id string `json:"id"` + Name string `json:"name"` + FullName string `json:"fullName"` + Email string `json:"email"` + AuthenticationType UserAuthenticationType `json:"authenticationType"` + IsAdministrator bool `json:"isAdministrator"` + IsEnabled bool `json:"isEnabled"` + UserGroups []string `json:"userGroups"` +} + +func userName(u User) string { return u.Name } + +// List returns all users, excluding application accounts. +func (c *Users) List() ([]User, error) { + return c.ListWithContext(context.Background()) +} + +// ListWithContext returns all users, excluding application accounts. +// The provided context can be used to cancel the request. +func (c *Users) ListWithContext(ctx context.Context) ([]User, error) { + return fetchDataList[User](ctx, c.client, userListEndpoint, "users") +} + +// ListApplications returns all application accounts. +func (c *Users) ListApplications() ([]User, error) { + return c.ListApplicationsWithContext(context.Background()) +} + +// ListApplicationsWithContext returns all application accounts. +// The provided context can be used to cancel the request. +func (c *Users) ListApplicationsWithContext(ctx context.Context) ([]User, error) { + return fetchDataList[User](ctx, c.client, applicationListEndpoint, "applications") +} + +// GetByName returns a single user based on its login name. +// Returns ErrUserNotFound if no user is found. +// Returns ErrMultipleUsersFound if more than one user matches the name. +func (c *Users) GetByName(name string) (User, error) { + return c.GetByNameWithContext(context.Background(), name) +} + +// GetByNameWithContext returns a single user based on its login name. +// Returns ErrUserNotFound if no user is found. +// Returns ErrMultipleUsersFound if more than one user matches the name. +// The provided context can be used to cancel the request. +func (c *Users) GetByNameWithContext(ctx context.Context, name string) (User, error) { + users, err := c.ListWithContext(ctx) + if err != nil { + return User{}, err + } + + return singleByName(users, name, userName, ErrUserNotFound, ErrMultipleUsersFound) +} + +// GetApplicationByName returns a single application account based on its application key. +// Returns ErrUserNotFound if no application is found. +// Returns ErrMultipleUsersFound if more than one application matches the name. +func (c *Users) GetApplicationByName(name string) (User, error) { + return c.GetApplicationByNameWithContext(context.Background(), name) +} + +// GetApplicationByNameWithContext returns a single application account based on its application key. +// Returns ErrUserNotFound if no application is found. +// Returns ErrMultipleUsersFound if more than one application matches the name. +// The provided context can be used to cancel the request. +func (c *Users) GetApplicationByNameWithContext(ctx context.Context, name string) (User, error) { + apps, err := c.ListApplicationsWithContext(ctx) + if err != nil { + return User{}, err + } + + return singleByName(apps, name, userName, ErrUserNotFound, ErrMultipleUsersFound) +} diff --git a/users_test.go b/users_test.go new file mode 100644 index 0000000..693744b --- /dev/null +++ b/users_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package dvls + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Users(t *testing.T) { + users, err := testClient.Users.List() + require.NoError(t, err) + require.NotEmpty(t, users) + + for _, u := range users { + assert.NotEmpty(t, u.Id) + assert.NotEmpty(t, u.Name) + } + + user, err := testClient.Users.GetByName(users[0].Name) + require.NoError(t, err) + assert.Equal(t, users[0].Id, user.Id) + + _, err = testClient.Users.GetByName("go-dvls-nonexistent-user") + assert.ErrorIs(t, err, ErrUserNotFound) +} + +func Test_Applications(t *testing.T) { + apps, err := testClient.Users.ListApplications() + require.NoError(t, err) + + require.NotEmpty(t, apps) + + for _, a := range apps { + assert.NotEmpty(t, a.Id) + assert.Equal(t, UserAuthenticationApplication, a.AuthenticationType) + } + + app, err := testClient.Users.GetApplicationByName(apps[0].Name) + require.NoError(t, err) + assert.Equal(t, apps[0].Id, app.Id) + + _, err = testClient.Users.GetApplicationByName("go-dvls-nonexistent-app") + assert.ErrorIs(t, err, ErrUserNotFound) +} + +func Test_UserGroups(t *testing.T) { + groups, err := testClient.UserGroups.List() + require.NoError(t, err) + require.NotEmpty(t, groups) + + for _, g := range groups { + assert.NotEmpty(t, g.Id) + assert.NotEmpty(t, g.Name) + } + + group, err := testClient.UserGroups.GetByName(groups[0].Name) + require.NoError(t, err) + assert.Equal(t, groups[0].Id, group.Id) + + _, err = testClient.UserGroups.GetByName("go-dvls-nonexistent-group") + assert.ErrorIs(t, err, ErrUserGroupNotFound) +} diff --git a/users_unit_test.go b/users_unit_test.go new file mode 100644 index 0000000..ca687a1 --- /dev/null +++ b/users_unit_test.go @@ -0,0 +1,101 @@ +package dvls + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUsersList(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/security/users/list", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":1,"data":[ + {"id":"` + testUserID + `","name":"admin","fullName":"Admin User","email":"admin@example.com","authenticationType":0,"isAdministrator":true,"isEnabled":true,"userGroups":["Ops"]}, + {"id":"` + testAssigneeID + `","name":"jdoe","fullName":"John Doe","authenticationType":8,"isEnabled":true} + ]}`)) + }) + + client := newTestClient(t, mux) + + users, err := client.Users.List() + require.NoError(t, err) + require.Len(t, users, 2) + assert.Equal(t, testUserID, users[0].Id) + assert.Equal(t, "admin", users[0].Name) + assert.Equal(t, UserAuthenticationBuiltin, users[0].AuthenticationType) + assert.True(t, users[0].IsAdministrator) + assert.Equal(t, []string{"Ops"}, users[0].UserGroups) + assert.Equal(t, UserAuthenticationAzureAD, users[1].AuthenticationType) +} + +func TestUsersList_ResultError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/security/users/list", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":2,"message":"AccessDenied"}`)) + }) + + client := newTestClient(t, mux) + + _, err := client.Users.List() + require.Error(t, err) + assert.Contains(t, err.Error(), "AccessDenied") +} + +func TestUsersGetByName(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/security/users/list", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":1,"data":[ + {"id":"` + testUserID + `","name":"admin"}, + {"id":"` + testAssigneeID + `","name":"dup"}, + {"id":"` + testRoleID + `","name":"dup"} + ]}`)) + }) + + client := newTestClient(t, mux) + + user, err := client.Users.GetByName("admin") + require.NoError(t, err) + assert.Equal(t, testUserID, user.Id) + + _, err = client.Users.GetByName("dup") + assert.ErrorIs(t, err, ErrMultipleUsersFound) + + _, err = client.Users.GetByName("nobody") + assert.ErrorIs(t, err, ErrUserNotFound) +} + +func TestUsersApplications(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/security/application/users/list", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"result":1,"data":[ + {"id":"` + testAssigneeID + `","name":"my-app-key","fullName":"CI","authenticationType":9,"isEnabled":true} + ]}`)) + }) + + client := newTestClient(t, mux) + + apps, err := client.Users.ListApplications() + require.NoError(t, err) + require.Len(t, apps, 1) + assert.Equal(t, testAssigneeID, apps[0].Id) + assert.Equal(t, UserAuthenticationApplication, apps[0].AuthenticationType) + + app, err := client.Users.GetApplicationByName("my-app-key") + require.NoError(t, err) + assert.Equal(t, testAssigneeID, app.Id) + + _, err = client.Users.GetApplicationByName("missing") + assert.ErrorIs(t, err, ErrUserNotFound) +} diff --git a/utils.go b/utils.go index 0efc665..faa51b0 100644 --- a/utils.go +++ b/utils.go @@ -1,6 +1,11 @@ package dvls import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" "strconv" "strings" ) @@ -37,3 +42,56 @@ func sliceToKeywords(kw []string) string { return kString } + +type dataListResponse[T any] struct { + Result SaveResult `json:"result"` + Message string `json:"message"` + Data []T `json:"data"` +} + +// fetchDataList performs a GET on an unpaged endpoint that wraps its items in +// a {result, data: [...]} envelope and returns the items. +func fetchDataList[T any](ctx context.Context, c *Client, endpoint string, resource string) ([]T, error) { + reqUrl, err := url.JoinPath(c.baseUri, endpoint) + if err != nil { + return nil, fmt.Errorf("failed to build %s url: %w", resource, err) + } + + resp, err := c.RequestWithContext(ctx, reqUrl, http.MethodGet, nil, RequestOptions{RawBody: true}) + if err != nil { + return nil, fmt.Errorf("error while fetching %s: %w", resource, err) + } + + var listResp dataListResponse[T] + if err := json.Unmarshal(resp.Response, &listResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response body: %w", err) + } + if listResp.Result != SaveResultSuccess { + return nil, fmt.Errorf("unexpected result code %d (%s) %s", listResp.Result, listResp.Result, listResp.Message) + } + + return listResp.Data, nil +} + +// singleByName returns the only item whose name equals name, or notFound / +// multiple when zero or several items match. +func singleByName[T any](items []T, name string, nameOf func(T) string, notFound error, multiple error) (T, error) { + var match T + count := 0 + for _, item := range items { + if nameOf(item) == name { + match = item + count++ + } + } + + switch count { + case 0: + return match, notFound + case 1: + return match, nil + default: + var zero T + return zero, multiple + } +} From fe7944d455016e10708609b42d2b8ac2c19298c6 Mon Sep 17 00:00:00 2001 From: Dion Gionet Mallet Date: Thu, 3 Sep 2026 08:35:51 -0400 Subject: [PATCH 2/2] Fix capitalization of 'principal IDs' in README Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f3ae1ac..cd86c0f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Heavily based on the information found on the [Devolutions.Server](https://github.com/Devolutions/devolutions-server/tree/main/Powershell%20Module/Devolutions.Server) powershell module. -Users, applications and user groups (`client.Users`, `client.UserGroups`) expose the principal ids used as assignees in role assignments and as roles in entry permissions. +Users, applications and user groups (`client.Users`, `client.UserGroups`) expose the principal IDs used as assignees in role assignments and as roles in entry permissions. ## Usage - Run go get `go get github.com/Devolutions/go-dvls`