From 6172407d4f1803b979ab10bb91f15fbb83fc5404 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Thu, 8 Jul 2021 15:56:49 -0400 Subject: [PATCH 01/21] v0.8.0-alpha.2 --- version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version/version.go b/version/version.go index d38de25..b2f3cd0 100644 --- a/version/version.go +++ b/version/version.go @@ -18,5 +18,5 @@ package version const ( // Version is the current version of Gort - Version = "0.8.0-alpha.1" + Version = "0.8.0-alpha.2" ) From 423b6ce071d594d261b2c9db61a217730ce21547 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Thu, 8 Jul 2021 16:19:52 -0400 Subject: [PATCH 02/21] Close transaction --- dataaccess/postgres/bundle-access.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dataaccess/postgres/bundle-access.go b/dataaccess/postgres/bundle-access.go index 2d3bf11..504abbc 100644 --- a/dataaccess/postgres/bundle-access.go +++ b/dataaccess/postgres/bundle-access.go @@ -465,6 +465,12 @@ func (da PostgresDataAccess) BundleUpdate(ctx context.Context, bundle data.Bundl return err } + err = tx.Commit() + if err != nil { + tx.Rollback() + return gerr.Wrap(errs.ErrDataAccess, err) + } + return nil } From 3616bbb098823b1823b4651b432871c330d75fc2 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Fri, 9 Jul 2021 14:21:26 -0400 Subject: [PATCH 03/21] Significant expansion of DAL functions to fill some identified gaps --- data/rest/group-data.go | 1 + data/rest/role-data.go | 17 +- dataaccess/dataaccess.go | 20 +- dataaccess/memory/bundle-access.go | 2 +- dataaccess/memory/bundle-access_test.go | 6 +- dataaccess/memory/group-access.go | 228 ++++++++-------- dataaccess/memory/group-access_test.go | 189 +++++++++---- dataaccess/memory/memory-data-access.go | 18 +- dataaccess/memory/role-access.go | 113 +++++--- dataaccess/memory/role-access_test.go | 197 +++++++++++++- dataaccess/memory/user-access.go | 114 +++++--- dataaccess/memory/user-access_test.go | 17 +- dataaccess/postgres/bundle-access.go | 112 ++++---- dataaccess/postgres/bundle-access_test.go | 6 +- dataaccess/postgres/group-access.go | 314 ++++++++++++---------- dataaccess/postgres/group-access_test.go | 189 +++++++++---- dataaccess/postgres/role-access.go | 291 +++++++++++++------- dataaccess/postgres/role-access_test.go | 197 +++++++++++++- dataaccess/postgres/user-access.go | 304 ++++++++++++--------- dataaccess/postgres/user-access_test.go | 17 +- 20 files changed, 1591 insertions(+), 761 deletions(-) diff --git a/data/rest/group-data.go b/data/rest/group-data.go index 8a7dd29..fa6d928 100644 --- a/data/rest/group-data.go +++ b/data/rest/group-data.go @@ -20,5 +20,6 @@ package rest // Gort controller's REST service. type Group struct { Name string `json:"name,omitempty"` + Roles []Role `json:"roles,omitempty"` Users []User `json:"users,omitempty"` } diff --git a/data/rest/role-data.go b/data/rest/role-data.go index 1ef3e7a..34b54ec 100644 --- a/data/rest/role-data.go +++ b/data/rest/role-data.go @@ -21,6 +21,7 @@ import "fmt" type Role struct { Name string Permissions []RolePermission + Groups []Group } type RolePermission struct { @@ -28,6 +29,18 @@ type RolePermission struct { Permission string } -func (r RolePermission) String() string { - return fmt.Sprintf("%s:%s", r.BundleName, r.Permission) +func (p RolePermission) String() string { + return fmt.Sprintf("%s:%s", p.BundleName, p.Permission) +} + +type RolePermissionList []RolePermission + +func (l RolePermissionList) Strings() []string { + s := make([]string, len(l)) + + for i, p := range l { + s[i] = p.String() + } + + return s } diff --git a/dataaccess/dataaccess.go b/dataaccess/dataaccess.go index 9de554f..bc014ae 100644 --- a/dataaccess/dataaccess.go +++ b/dataaccess/dataaccess.go @@ -45,33 +45,36 @@ type DataAccess interface { BundleExists(ctx context.Context, name string, version string) (bool, error) BundleGet(ctx context.Context, name string, version string) (data.Bundle, error) BundleList(ctx context.Context) ([]data.Bundle, error) - BundleListVersions(ctx context.Context, name string) ([]data.Bundle, error) + BundleVersionList(ctx context.Context, name string) ([]data.Bundle, error) BundleUpdate(ctx context.Context, bundle data.Bundle) error - GroupAddUser(ctx context.Context, groupname string, username string) error GroupCreate(ctx context.Context, group rest.Group) error GroupDelete(ctx context.Context, groupname string) error GroupExists(ctx context.Context, groupname string) (bool, error) GroupGet(ctx context.Context, groupname string) (rest.Group, error) GroupList(ctx context.Context) ([]rest.Group, error) - GroupListRoles(ctx context.Context, groupname string) ([]rest.Role, error) - GroupListUsers(ctx context.Context, groupname string) ([]rest.User, error) - GroupRemoveUser(ctx context.Context, groupname string, username string) error + GroupPermissionList(ctx context.Context, groupname string) (rest.RolePermissionList, error) GroupRoleAdd(ctx context.Context, groupname, rolename string) error GroupRoleDelete(ctx context.Context, groupname, rolename string) error + GroupRoleList(ctx context.Context, groupname string) ([]rest.Role, error) GroupUpdate(ctx context.Context, group rest.Group) error GroupUserAdd(ctx context.Context, groupname string, username string) error GroupUserDelete(ctx context.Context, groupname string, username string) error + GroupUserList(ctx context.Context, groupname string) ([]rest.User, error) RoleCreate(ctx context.Context, rolename string) error RoleDelete(ctx context.Context, rolename string) error RoleGet(ctx context.Context, rolename string) (rest.Role, error) + RoleGroupAdd(ctx context.Context, rolename, groupname string) error + RoleGroupDelete(ctx context.Context, rolename, groupname string) error + RoleGroupExists(ctx context.Context, rolename, groupname string) (bool, error) + RoleGroupList(ctx context.Context, rolename string) ([]rest.Group, error) RoleList(ctx context.Context) ([]rest.Role, error) RoleExists(ctx context.Context, rolename string) (bool, error) - RoleHasPermission(ctx context.Context, rolename, bundlename, permission string) (bool, error) RolePermissionAdd(ctx context.Context, rolename, bundlename, permission string) error RolePermissionDelete(ctx context.Context, rolename, bundlename, permission string) error - RolePermissionList(ctx context.Context, rolename string) ([]rest.RolePermission, error) + RolePermissionExists(ctx context.Context, rolename, bundlename, permission string) (bool, error) + RolePermissionList(ctx context.Context, rolename string) (rest.RolePermissionList, error) TokenEvaluate(ctx context.Context, token string) bool TokenGenerate(ctx context.Context, username string, duration time.Duration) (rest.Token, error) @@ -89,6 +92,7 @@ type DataAccess interface { UserGroupAdd(ctx context.Context, username string, groupname string) error UserGroupDelete(ctx context.Context, username string, groupname string) error UserList(ctx context.Context) ([]rest.User, error) - UserPermissions(ctx context.Context, username string) ([]string, error) + UserPermissionList(ctx context.Context, username string) (rest.RolePermissionList, error) + UserRoleList(ctx context.Context, username string) ([]rest.Role, error) UserUpdate(ctx context.Context, user rest.User) error } diff --git a/dataaccess/memory/bundle-access.go b/dataaccess/memory/bundle-access.go index 9d33500..28b07d1 100644 --- a/dataaccess/memory/bundle-access.go +++ b/dataaccess/memory/bundle-access.go @@ -192,7 +192,7 @@ func (da *InMemoryDataAccess) BundleList(ctx context.Context) ([]data.Bundle, er } // BundleListVersions TBD -func (da *InMemoryDataAccess) BundleListVersions(ctx context.Context, name string) ([]data.Bundle, error) { +func (da *InMemoryDataAccess) BundleVersionList(ctx context.Context, name string) ([]data.Bundle, error) { list := make([]data.Bundle, 0) for _, g := range da.bundles { diff --git a/dataaccess/memory/bundle-access_test.go b/dataaccess/memory/bundle-access_test.go index 0eeb5d7..d145989 100644 --- a/dataaccess/memory/bundle-access_test.go +++ b/dataaccess/memory/bundle-access_test.go @@ -35,7 +35,7 @@ func testBundleAccess(t *testing.T) { t.Run("testBundleDelete", testBundleDelete) t.Run("testBundleGet", testBundleGet) t.Run("testBundleList", testBundleList) - t.Run("testBundleListVersions", testBundleListVersions) + t.Run("testBundleVersionList", testBundleVersionList) t.Run("testFindCommandEntry", testFindCommandEntry) } @@ -340,7 +340,7 @@ func testBundleList(t *testing.T) { } } -func testBundleListVersions(t *testing.T) { +func testBundleVersionList(t *testing.T) { da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) defer da.BundleDelete(ctx, "test-list-0", "0.0") da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) @@ -350,7 +350,7 @@ func testBundleListVersions(t *testing.T) { da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) defer da.BundleDelete(ctx, "test-list-1", "0.1") - bundles, err := da.BundleListVersions(ctx, "test-list-0") + bundles, err := da.BundleVersionList(ctx, "test-list-0") assert.NoError(t, err) if len(bundles) != 2 { diff --git a/dataaccess/memory/group-access.go b/dataaccess/memory/group-access.go index 5415209..b0de32c 100644 --- a/dataaccess/memory/group-access.go +++ b/dataaccess/memory/group-access.go @@ -24,39 +24,6 @@ import ( "github.com/getgort/gort/dataaccess/errs" ) -// GroupAddUser adds a user to a group -func (da *InMemoryDataAccess) GroupAddUser(ctx context.Context, groupname string, username string) error { - if groupname == "" { - return errs.ErrEmptyGroupName - } - - exists, err := da.GroupExists(ctx, groupname) - if err != nil { - return err - } - if !exists { - return errs.ErrNoSuchGroup - } - - if username == "" { - return errs.ErrEmptyUserName - } - - exists, err = da.UserExists(ctx, username) - if err != nil { - return err - } - if !exists { - return errs.ErrNoSuchUser - } - - group := da.groups[groupname] - user := da.users[username] - group.Users = append(group.Users, *user) - - return nil -} - // GroupCreate creates a new user group. func (da *InMemoryDataAccess) GroupCreate(ctx context.Context, group rest.Group) error { if group.Name == "" { @@ -126,32 +93,6 @@ func (da *InMemoryDataAccess) GroupGet(ctx context.Context, groupname string) (r return *group, nil } -// GroupRoleAdd grants one or more roles to a group. -func (da *InMemoryDataAccess) GroupRoleAdd(ctx context.Context, groupname, rolename string) error { - b, err := da.GroupExists(ctx, groupname) - if err != nil { - return err - } else if !b { - return errs.ErrNoSuchGroup - } - - b, err = da.RoleExists(ctx, rolename) - if err != nil { - return err - } else if !b { - return errs.ErrNoSuchRole - } - - m := da.grouproles[groupname] - if m == nil { - m = make(map[string]*rest.Role) - da.grouproles[groupname] = m - } - - m[rolename] = da.roles[rolename] - return nil -} - // GroupList returns a list of all known groups in the datastore. // Passwords are not included. Nice try. func (da *InMemoryDataAccess) GroupList(ctx context.Context) ([]rest.Group, error) { @@ -164,76 +105,91 @@ func (da *InMemoryDataAccess) GroupList(ctx context.Context) ([]rest.Group, erro return list, nil } -func (da *InMemoryDataAccess) GroupListRoles(ctx context.Context, groupname string) ([]rest.Role, error) { - roles := []rest.Role{} - - gr := da.grouproles[groupname] - if gr == nil { - return roles, nil +func (da *InMemoryDataAccess) GroupPermissionList(ctx context.Context, groupname string) (rest.RolePermissionList, error) { + roles, err := da.GroupRoleList(ctx, groupname) + if err != nil { + return rest.RolePermissionList{}, err } - for _, r := range gr { - roles = append(roles, *r) + mp := map[string]rest.RolePermission{} + + for _, r := range roles { + rpl, err := da.RolePermissionList(ctx, r.Name) + if err != nil { + return rest.RolePermissionList{}, err + } + + for _, rp := range rpl { + mp[rp.String()] = rp + } } - sort.Slice(roles, func(i, j int) bool { return roles[i].Name < roles[j].Name }) + pp := []rest.RolePermission{} - return roles, nil -} + for _, p := range mp { + pp = append(pp, p) + } + + sort.Slice(pp, func(i, j int) bool { return pp[i].String() < pp[j].String() }) -func (da *InMemoryDataAccess) GroupListUsers(ctx context.Context, groupname string) ([]rest.User, error) { - return nil, errs.ErrNotImplemented + return pp, nil } -// GroupRemoveUser removes one or more users from a group. -func (da *InMemoryDataAccess) GroupRemoveUser(ctx context.Context, groupname string, username string) error { - if groupname == "" { - return errs.ErrEmptyGroupName +func (da *InMemoryDataAccess) GroupRoleList(ctx context.Context, groupname string) ([]rest.Role, error) { + gr := da.groups[groupname] + if gr == nil { + return []rest.Role{}, nil } - exists, err := da.GroupExists(ctx, groupname) - if err != nil { - return err - } + sort.Slice(gr.Roles, func(i, j int) bool { return gr.Roles[i].Name < gr.Roles[j].Name }) + + return gr.Roles, nil +} + +// GroupRoleAdd grants one or more roles to a group. +func (da *InMemoryDataAccess) GroupRoleAdd(ctx context.Context, groupname, rolename string) error { + group, exists := da.groups[groupname] if !exists { return errs.ErrNoSuchGroup } - group := da.groups[groupname] - - for i, u := range group.Users { - if u.Username == username { - group.Users = append(group.Users[:i], group.Users[i+1:]...) - return nil - } + role, exists := da.roles[rolename] + if !exists { + return errs.ErrNoSuchRole } - return errs.ErrNoSuchUser + group.Roles = append(group.Roles, *role) + role.Groups = append(role.Groups, *group) + + return nil } // GroupRoleDelete revokes one or more roles from a group. func (da *InMemoryDataAccess) GroupRoleDelete(ctx context.Context, groupname, rolename string) error { - b, err := da.GroupExists(ctx, groupname) - if err != nil { - return err - } else if !b { + group, exists := da.groups[groupname] + if !exists { return errs.ErrNoSuchGroup } - b, err = da.RoleExists(ctx, rolename) - if err != nil { - return err - } else if !b { + role, exists := da.roles[rolename] + if !exists { return errs.ErrNoSuchRole } - m := da.grouproles[groupname] - if m == nil { - m = make(map[string]*rest.Role) - da.grouproles[groupname] = m + for i, r := range group.Roles { + if r.Name == rolename { + group.Roles = append(group.Roles[:i], group.Roles[i+1:]...) + break + } + } + + for i, g := range role.Groups { + if g.Name == groupname { + role.Groups = append(role.Groups[:i], role.Groups[i+1:]...) + break + } } - delete(m, rolename) return nil } @@ -258,12 +214,70 @@ func (da *InMemoryDataAccess) GroupUpdate(ctx context.Context, group rest.Group) return nil } -// GroupUserAdd comments TBD -func (da *InMemoryDataAccess) GroupUserAdd(ctx context.Context, group string, user string) error { - return errs.ErrNotImplemented +// GroupUserAdd adds a user to a group +func (da *InMemoryDataAccess) GroupUserAdd(ctx context.Context, groupname string, username string) error { + if groupname == "" { + return errs.ErrEmptyGroupName + } + + exists, err := da.GroupExists(ctx, groupname) + if err != nil { + return err + } + if !exists { + return errs.ErrNoSuchGroup + } + + if username == "" { + return errs.ErrEmptyUserName + } + + exists, err = da.UserExists(ctx, username) + if err != nil { + return err + } + if !exists { + return errs.ErrNoSuchUser + } + + group := da.groups[groupname] + user := da.users[username] + group.Users = append(group.Users, *user) + + return nil +} + +// GroupUserDelete removes one or more users from a group. +func (da *InMemoryDataAccess) GroupUserDelete(ctx context.Context, groupname string, username string) error { + if groupname == "" { + return errs.ErrEmptyGroupName + } + + exists, err := da.GroupExists(ctx, groupname) + if err != nil { + return err + } + if !exists { + return errs.ErrNoSuchGroup + } + + group := da.groups[groupname] + + for i, u := range group.Users { + if u.Username == username { + group.Users = append(group.Users[:i], group.Users[i+1:]...) + return nil + } + } + + return errs.ErrNoSuchUser } -// GroupUserDelete comments TBD -func (da *InMemoryDataAccess) GroupUserDelete(ctx context.Context, group string, user string) error { - return errs.ErrNotImplemented +func (da *InMemoryDataAccess) GroupUserList(ctx context.Context, groupname string) ([]rest.User, error) { + group, exists := da.groups[groupname] + if !exists { + return []rest.User{}, errs.ErrNoSuchGroup + } + + return group.Users, nil } diff --git a/dataaccess/memory/group-access_test.go b/dataaccess/memory/group-access_test.go index a375974..6b6aa42 100644 --- a/dataaccess/memory/group-access_test.go +++ b/dataaccess/memory/group-access_test.go @@ -25,44 +25,85 @@ import ( ) func testGroupAccess(t *testing.T) { - t.Run("testGroupAddUser", testGroupAddUser) + t.Run("testGroupUserAdd", testGroupUserAdd) + t.Run("testGroupUserList", testGroupUserList) t.Run("testGroupCreate", testGroupCreate) t.Run("testGroupDelete", testGroupDelete) t.Run("testGroupExists", testGroupExists) t.Run("testGroupGet", testGroupGet) t.Run("testGroupRoleAdd", testGroupRoleAdd) + t.Run("testGroupPermissionList", testGroupPermissionList) t.Run("testGroupList", testGroupList) - t.Run("testGroupListRoles", testGroupListRoles) - t.Run("testGroupRemoveUser", testGroupRemoveUser) + t.Run("testGroupRoleList", testGroupRoleList) + t.Run("testGroupUserDelete", testGroupUserDelete) } -func testGroupAddUser(t *testing.T) { - err := da.GroupAddUser(ctx, "foo", "bar") +func testGroupUserAdd(t *testing.T) { + var ( + groupname = "group-test-group-user-add" + username = "user-test-group-user-add" + useremail = "user@foo.bar" + ) + + err := da.GroupUserAdd(ctx, groupname, username) assert.Error(t, err, errs.ErrNoSuchGroup) - da.GroupCreate(ctx, rest.Group{Name: "foo"}) - defer da.GroupDelete(ctx, "foo") + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) - err = da.GroupAddUser(ctx, "foo", "bar") + err = da.GroupUserAdd(ctx, groupname, username) assert.Error(t, err, errs.ErrNoSuchUser) - da.UserCreate(ctx, rest.User{Username: "bar", Email: "bar"}) - defer da.UserDelete(ctx, "bar") + da.UserCreate(ctx, rest.User{Username: username, Email: useremail}) + defer da.UserDelete(ctx, username) - err = da.GroupAddUser(ctx, "foo", "bar") + err = da.GroupUserAdd(ctx, groupname, username) assert.NoError(t, err) - group, _ := da.GroupGet(ctx, "foo") + group, _ := da.GroupGet(ctx, groupname) - if len(group.Users) != 1 { - t.Error("Users list empty") + if !assert.Len(t, group.Users, 1) { t.FailNow() } - if len(group.Users) > 0 && group.Users[0].Username != "bar" { - t.Error("Wrong user!") + assert.Equal(t, group.Users[0].Username, username) + assert.Equal(t, group.Users[0].Email, useremail) +} + +func testGroupUserList(t *testing.T) { + var ( + groupname = "group-test-group-user-list" + expected = []rest.User{ + {Username: "user-test-group-user-list-0", Email: "user-test-group-user-list-0@email.com"}, + {Username: "user-test-group-user-list-1", Email: "user-test-group-user-list-1@email.com"}, + } + ) + + _, err := da.GroupUserList(ctx, groupname) + assert.Error(t, err) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) + + da.UserCreate(ctx, expected[0]) + defer da.UserDelete(ctx, expected[0].Username) + da.UserCreate(ctx, expected[1]) + defer da.UserDelete(ctx, expected[1].Username) + + da.GroupUserAdd(ctx, groupname, expected[0].Username) + da.GroupUserAdd(ctx, groupname, expected[1].Username) + + actual, err := da.GroupUserList(ctx, groupname) + if !assert.NoError(t, err) { + t.FailNow() + } + + if !assert.Len(t, actual, 2) { t.FailNow() } + + assert.Equal(t, expected, actual) } func testGroupCreate(t *testing.T) { @@ -126,34 +167,74 @@ func testGroupExists(t *testing.T) { } func testGroupGet(t *testing.T) { + groupname := "group-test-group-get" + var err error var group rest.Group // Expect an error _, err = da.GroupGet(ctx, "") - assert.Error(t, err, errs.ErrEmptyGroupName) + assert.ErrorIs(t, err, errs.ErrEmptyGroupName) // Expect an error - _, err = da.GroupGet(ctx, "test-get") - assert.Error(t, err, errs.ErrNoSuchGroup) + _, err = da.GroupGet(ctx, groupname) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - da.GroupCreate(ctx, rest.Group{Name: "test-get"}) - defer da.GroupDelete(ctx, "test-get") + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) // da.Group ctx, should exist now - exists, _ := da.GroupExists(ctx, "test-get") - if !exists { - t.Error("Group should exist now") + exists, _ := da.GroupExists(ctx, groupname) + if !assert.True(t, exists) { t.FailNow() } // Expect no error - group, err = da.GroupGet(ctx, "test-get") - assert.NoError(t, err) - if group.Name != "test-get" { - t.Errorf("Group name mismatch: %q is not \"test-get\"", group.Name) + group, err = da.GroupGet(ctx, groupname) + if !assert.NoError(t, err) { + t.FailNow() + } + if !assert.Equal(t, groupname, group.Name) { + t.FailNow() + } +} + +func testGroupPermissionList(t *testing.T) { + const ( + groupname = "group-test-group-permission-list" + rolename = "role-test-group-permission-list" + bundlename = "test" + ) + + var expected = rest.RolePermissionList{ + {BundleName: bundlename, Permission: "role-test-group-permission-list-1"}, + {BundleName: bundlename, Permission: "role-test-group-permission-list-2"}, + {BundleName: bundlename, Permission: "role-test-group-permission-list-3"}, + } + + var err error + + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + err = da.GroupRoleAdd(ctx, groupname, rolename) + if !assert.NoError(t, err) { + t.FailNow() + } + + da.RolePermissionAdd(ctx, rolename, expected[0].BundleName, expected[0].Permission) + da.RolePermissionAdd(ctx, rolename, expected[1].BundleName, expected[1].Permission) + da.RolePermissionAdd(ctx, rolename, expected[2].BundleName, expected[2].Permission) + + actual, err := da.GroupPermissionList(ctx, groupname) + if !assert.NoError(t, err) { t.FailNow() } + + assert.Equal(t, expected, actual) } func testGroupRoleAdd(t *testing.T) { @@ -190,7 +271,7 @@ func testGroupRoleAdd(t *testing.T) { }, } - roles, err := da.GroupListRoles(ctx, groupName) + roles, err := da.GroupRoleList(ctx, groupName) if !assert.NoError(t, err) { t.FailNow() } @@ -204,7 +285,7 @@ func testGroupRoleAdd(t *testing.T) { expectedRoles = []rest.Role{} - roles, err = da.GroupListRoles(ctx, groupName) + roles, err = da.GroupRoleList(ctx, groupName) if !assert.NoError(t, err) { t.FailNow() } @@ -238,45 +319,53 @@ func testGroupList(t *testing.T) { } } -func testGroupListRoles(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "group-test-group-list-roles"}) - defer da.GroupDelete(ctx, "group-test-group-list-roles") +func testGroupRoleList(t *testing.T) { + var ( + groupname = "group-test-group-list-roles" + rolenames = []string{ + "role-test-group-list-roles-0", + "role-test-group-list-roles-1", + "role-test-group-list-roles-2", + } + ) + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) - da.RoleCreate(ctx, "role-test-group-list-roles-1") - defer da.RoleDelete(ctx, "role-test-group-list-roles-1") + da.RoleCreate(ctx, rolenames[1]) + defer da.RoleDelete(ctx, rolenames[1]) - da.RoleCreate(ctx, "role-test-group-list-roles-0") - defer da.RoleDelete(ctx, "role-test-group-list-roles-0") + da.RoleCreate(ctx, rolenames[0]) + defer da.RoleDelete(ctx, rolenames[0]) - da.RoleCreate(ctx, "role-test-group-list-roles-2") - defer da.RoleDelete(ctx, "role-test-group-list-roles-2") + da.RoleCreate(ctx, rolenames[2]) + defer da.RoleDelete(ctx, rolenames[2]) - roles, err := da.GroupListRoles(ctx, "group-test-group-list-roles") + roles, err := da.GroupRoleList(ctx, groupname) if !assert.NoError(t, err) && !assert.Empty(t, roles) { t.FailNow() } - err = da.GroupRoleAdd(ctx, "group-test-group-list-roles", "role-test-group-list-roles-1") + err = da.GroupRoleAdd(ctx, groupname, rolenames[1]) if !assert.NoError(t, err) { t.FailNow() } - err = da.GroupRoleAdd(ctx, "group-test-group-list-roles", "role-test-group-list-roles-0") + err = da.GroupRoleAdd(ctx, groupname, rolenames[0]) if !assert.NoError(t, err) { t.FailNow() } - err = da.GroupRoleAdd(ctx, "group-test-group-list-roles", "role-test-group-list-roles-2") + err = da.GroupRoleAdd(ctx, groupname, rolenames[2]) if !assert.NoError(t, err) { t.FailNow() } // Note: alphabetically sorted! expected := []rest.Role{ - {Name: "role-test-group-list-roles-0", Permissions: []rest.RolePermission{}}, - {Name: "role-test-group-list-roles-1", Permissions: []rest.RolePermission{}}, - {Name: "role-test-group-list-roles-2", Permissions: []rest.RolePermission{}}, + {Name: rolenames[0], Permissions: []rest.RolePermission{}}, + {Name: rolenames[1], Permissions: []rest.RolePermission{}}, + {Name: rolenames[2], Permissions: []rest.RolePermission{}}, } - actual, err := da.GroupListRoles(ctx, "group-test-group-list-roles") + actual, err := da.GroupRoleList(ctx, groupname) if !assert.NoError(t, err) { t.FailNow() } @@ -284,14 +373,14 @@ func testGroupListRoles(t *testing.T) { assert.Equal(t, expected, actual) } -func testGroupRemoveUser(t *testing.T) { +func testGroupUserDelete(t *testing.T) { da.GroupCreate(ctx, rest.Group{Name: "foo"}) defer da.GroupDelete(ctx, "foo") da.UserCreate(ctx, rest.User{Username: "bat"}) defer da.UserDelete(ctx, "bat") - err := da.GroupAddUser(ctx, "foo", "bat") + err := da.GroupUserAdd(ctx, "foo", "bat") assert.NoError(t, err) group, err := da.GroupGet(ctx, "foo") @@ -307,7 +396,7 @@ func testGroupRemoveUser(t *testing.T) { t.FailNow() } - err = da.GroupRemoveUser(ctx, "foo", "bat") + err = da.GroupUserDelete(ctx, "foo", "bat") assert.NoError(t, err) group, err = da.GroupGet(ctx, "foo") diff --git a/dataaccess/memory/memory-data-access.go b/dataaccess/memory/memory-data-access.go index f3dd7d4..4be953c 100644 --- a/dataaccess/memory/memory-data-access.go +++ b/dataaccess/memory/memory-data-access.go @@ -26,21 +26,19 @@ import ( // InMemoryDataAccess is an entirely in-memory representation of a data access layer. // Great for testing and development. Terrible for production. type InMemoryDataAccess struct { - bundles map[string]*data.Bundle - groups map[string]*rest.Group - users map[string]*rest.User - roles map[string]*rest.Role - grouproles map[string]map[string]*rest.Role + bundles map[string]*data.Bundle + groups map[string]*rest.Group + users map[string]*rest.User + roles map[string]*rest.Role } // NewInMemoryDataAccess returns a new InMemoryDataAccess instance. func NewInMemoryDataAccess() *InMemoryDataAccess { da := InMemoryDataAccess{ - bundles: make(map[string]*data.Bundle), - groups: make(map[string]*rest.Group), - users: make(map[string]*rest.User), - roles: make(map[string]*rest.Role), - grouproles: make(map[string]map[string]*rest.Role), + bundles: make(map[string]*data.Bundle), + groups: make(map[string]*rest.Group), + users: make(map[string]*rest.User), + roles: make(map[string]*rest.Role), } return &da diff --git a/dataaccess/memory/role-access.go b/dataaccess/memory/role-access.go index 392b406..c416e4f 100644 --- a/dataaccess/memory/role-access.go +++ b/dataaccess/memory/role-access.go @@ -25,30 +25,19 @@ import ( ) // RoleCreate creates a new role. -func (da *InMemoryDataAccess) RoleCreate(ctx context.Context, name string) error { - if name == "" { +func (da *InMemoryDataAccess) RoleCreate(ctx context.Context, rolename string) error { + if rolename == "" { return errs.ErrEmptyRoleName } - if nil != da.roles[name] { + if nil != da.roles[rolename] { return errs.ErrRoleExists } - da.roles[name] = &rest.Role{Name: name, Permissions: []rest.RolePermission{}} + da.roles[rolename] = &rest.Role{Name: rolename, Permissions: []rest.RolePermission{}} return nil } -// RoleList -func (da *InMemoryDataAccess) RoleList(ctx context.Context) ([]rest.Role, error) { - list := make([]rest.Role, 0) - - for _, r := range da.roles { - list = append(list, *r) - } - - return list, nil -} - // RoleDelete func (da *InMemoryDataAccess) RoleDelete(ctx context.Context, name string) error { if name == "" { @@ -73,10 +62,10 @@ func (da *InMemoryDataAccess) RoleExists(ctx context.Context, name string) (bool } // RoleGet gets a specific group. -func (da *InMemoryDataAccess) RoleGet(ctx context.Context, name string) (rest.Role, error) { - role, ok := da.roles[name] +func (da *InMemoryDataAccess) RoleGet(ctx context.Context, rolename string) (rest.Role, error) { + role, ok := da.roles[rolename] - if name == "" { + if rolename == "" { return rest.Role{}, errs.ErrEmptyRoleName } @@ -87,14 +76,80 @@ func (da *InMemoryDataAccess) RoleGet(ctx context.Context, name string) (rest.Ro return *role, nil } -func (da *InMemoryDataAccess) RolePermissionAdd(ctx context.Context, rolename, bundlename, permission string) error { +// RolePermissionExists returns true if the given role has been granted the +// specified permission. It returns an error if rolename is empty or if no +// such role exists. +func (da *InMemoryDataAccess) RolePermissionExists(ctx context.Context, rolename, bundlename, permission string) (bool, error) { + perms, err := da.RolePermissionList(ctx, rolename) + if err != nil { + return false, err + } + + for _, p := range perms { + if p.BundleName == bundlename && p.Permission == permission { + return true, nil + } + } + + return false, nil +} + +// RoleList +func (da *InMemoryDataAccess) RoleList(ctx context.Context) ([]rest.Role, error) { + list := make([]rest.Role, 0) + + for _, r := range da.roles { + list = append(list, *r) + } + + return list, nil +} + +func (da *InMemoryDataAccess) RoleGroupAdd(ctx context.Context, rolename, groupname string) error { + return da.GroupRoleAdd(ctx, groupname, rolename) +} + +func (da *InMemoryDataAccess) RoleGroupDelete(ctx context.Context, rolename, groupname string) error { + return da.GroupRoleDelete(ctx, groupname, rolename) +} + +func (da *InMemoryDataAccess) RoleGroupExists(ctx context.Context, rolename, groupname string) (bool, error) { + groups, err := da.RoleGroupList(ctx, rolename) + if err != nil { + return false, err + } + + if exists, err := da.GroupExists(ctx, groupname); err != nil { + return false, err + } else if !exists { + return false, errs.ErrNoSuchGroup + } + + for _, g := range groups { + if g.Name == groupname { + return true, nil + } + } + + return false, nil +} + +func (da *InMemoryDataAccess) RoleGroupList(ctx context.Context, rolename string) ([]rest.Group, error) { role, ok := da.roles[rolename] + if !ok { + return nil, errs.ErrNoSuchRole + } + return role.Groups, nil +} +func (da *InMemoryDataAccess) RolePermissionAdd(ctx context.Context, rolename, bundlename, permission string) error { + role, ok := da.roles[rolename] if !ok { return errs.ErrNoSuchRole } role.Permissions = append(role.Permissions, rest.RolePermission{BundleName: bundlename, Permission: permission}) + return nil } @@ -119,28 +174,10 @@ func (da *InMemoryDataAccess) RolePermissionDelete(ctx context.Context, rolename return nil } -// RoleHasPermission returns true if the given role has been granted the -// specified permission. It returns an error if rolename is empty or if no -// such role exists. -func (da *InMemoryDataAccess) RoleHasPermission(ctx context.Context, rolename, bundlename, permission string) (bool, error) { - perms, err := da.RolePermissionList(ctx, rolename) - if err != nil { - return false, err - } - - for _, p := range perms { - if p.BundleName == bundlename && p.Permission == permission { - return true, nil - } - } - - return false, nil -} - // RolePermissionList returns returns an alphabetically-sorted list of // fully-qualified (i.e., "bundle:permission") permissions granted to // the role. -func (da *InMemoryDataAccess) RolePermissionList(ctx context.Context, rolename string) ([]rest.RolePermission, error) { +func (da *InMemoryDataAccess) RolePermissionList(ctx context.Context, rolename string) (rest.RolePermissionList, error) { role, err := da.RoleGet(ctx, rolename) if err != nil { return nil, err diff --git a/dataaccess/memory/role-access_test.go b/dataaccess/memory/role-access_test.go index d50b9f1..ff413b9 100644 --- a/dataaccess/memory/role-access_test.go +++ b/dataaccess/memory/role-access_test.go @@ -30,7 +30,12 @@ func testRoleAccess(t *testing.T) { t.Run("testRoleExists", testRoleExists) t.Run("testRoleDelete", testRoleDelete) t.Run("testRoleGet", testRoleGet) - t.Run("testRoleHasPermission", testRoleHasPermission) + t.Run("testRoleGroupAdd", testRoleGroupAdd) + t.Run("testRoleGroupDelete", testRoleGroupDelete) + t.Run("testRoleGroupExists", testRoleGroupExists) + t.Run("testRoleGroupList", testRoleGroupList) + t.Run("testRolePermissionExists", testRolePermissionExists) + t.Run("testRolePermissionAdd", testRolePermissionAdd) t.Run("testRolePermissionList", testRolePermissionList) } @@ -164,13 +169,193 @@ func testRoleGet(t *testing.T) { assert.Equal(t, expected, role) } -func testRoleHasPermission(t *testing.T) { +func testRoleGroupAdd(t *testing.T) { + var err error + + rolename := "role-test-role-group-add" + groupnames := []string{ + "perm-test-role-group-add-0", + "perm-test-role-group-add-1", + } + + // No such group yet + err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(ctx, groupnames[0]) + da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(ctx, groupnames[1]) + + // Groups exist now, but the role doesn't + err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + for _, groupname := range groupnames { + err = da.RoleGroupAdd(ctx, rolename, groupname) + assert.NoError(t, err) + } + + for _, groupname := range groupnames { + exists, _ := da.RoleGroupExists(ctx, rolename, groupname) + assert.True(t, exists, groupname) + } +} + +func testRoleGroupDelete(t *testing.T) { + +} + +func testRoleGroupExists(t *testing.T) { + var err error + + rolename := "role-test-role-group-exists" + groupnames := []string{ + "group-test-role-group-exists-0", + "group-test-role-group-exists-1", + } + groupnull := "group-test-role-group-exists-null" + + // No such role yet + _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + // Groups exist now, but the role doesn't + _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(ctx, groupnames[0]) + da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(ctx, groupnames[1]) + da.GroupCreate(ctx, rest.Group{Name: groupnull}) + defer da.GroupDelete(ctx, groupnull) + + for _, groupname := range groupnames { + da.RoleGroupAdd(ctx, rolename, groupname) + } + + for _, groupname := range groupnames { + exists, err := da.RoleGroupExists(ctx, rolename, groupname) + assert.NoError(t, err) + assert.True(t, exists) + } + + // Null group should NOT exist on the role + exists, err := da.RoleGroupExists(ctx, rolename, groupnull) + assert.NoError(t, err) + assert.False(t, exists) +} + +func testRoleGroupList(t *testing.T) { + var err error + + rolename := "role-test-role-group-list" + groupnames := []string{ + "group-test-role-group-list-0", + "group-test-role-group-list-1", + } + groupnull := "group-test-role-group-list-null" + + // No such role yet + _, err = da.RoleGroupList(ctx, rolename) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + // Groups exist now, but the role doesn't + groups, err := da.RoleGroupList(ctx, rolename) + assert.NoError(t, err) + assert.Empty(t, groups) + + da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(ctx, groupnames[1]) + da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(ctx, groupnames[0]) + da.GroupCreate(ctx, rest.Group{Name: groupnull}) + defer da.GroupDelete(ctx, groupnull) + + for _, groupname := range groupnames { + da.RoleGroupAdd(ctx, rolename, groupname) + } + + // Currently the groups are NOT expected to be fully described (i.e., + // their roles slices don't have to be complete). + groups, err = da.RoleGroupList(ctx, rolename) + assert.NoError(t, err) + assert.Len(t, groups, 2) + + for i, g := range groups { + assert.Equal(t, groupnames[i], g.Name) + } +} + +func testRolePermissionAdd(t *testing.T) { + var exists bool + var err error + + const rolename = "role-test-role-permission-add" + const bundlename = "test" + const permname1 = "perm-test-role-permission-add-0" + const permname2 = "perm-test-role-permission-add-1" + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + role, _ := da.RoleGet(ctx, rolename) + if !assert.Len(t, role.Permissions, 0) { + t.FailNow() + } + + // First permission + err = da.RolePermissionAdd(ctx, rolename, bundlename, permname1) + if !assert.NoError(t, err) { + t.FailNow() + } + defer da.RolePermissionDelete(ctx, rolename, bundlename, permname1) + + role, _ = da.RoleGet(ctx, rolename) + if !assert.Len(t, role.Permissions, 1) { + t.FailNow() + } + + exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname1) + if !assert.True(t, exists) { + t.FailNow() + } + + // Second permission + err = da.RolePermissionAdd(ctx, rolename, bundlename, permname2) + if !assert.NoError(t, err) { + t.FailNow() + } + defer da.RolePermissionDelete(ctx, rolename, bundlename, permname2) + + role, _ = da.RoleGet(ctx, rolename) + if !assert.Len(t, role.Permissions, 2) { + t.FailNow() + } + + exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname2) + if !assert.True(t, exists) { + t.FailNow() + } +} + +func testRolePermissionExists(t *testing.T) { var err error da.RoleCreate(ctx, "role-test-role-has-permission") defer da.RoleDelete(ctx, "role-test-role-has-permission") - has, err := da.RoleHasPermission(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + has, err := da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") if !assert.NoError(t, err) || !assert.False(t, has) { t.FailNow() } @@ -181,12 +366,12 @@ func testRoleHasPermission(t *testing.T) { } defer da.RolePermissionDelete(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - has, err = da.RoleHasPermission(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") if !assert.NoError(t, err) || !assert.True(t, has) { t.FailNow() } - has, err = da.RoleHasPermission(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-2") + has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-2") if !assert.NoError(t, err) || !assert.False(t, has) { t.FailNow() } @@ -217,7 +402,7 @@ func testRolePermissionList(t *testing.T) { defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") // Expect a sorted list! - expect := []rest.RolePermission{ + expect := rest.RolePermissionList{ {BundleName: "test", Permission: "permission-test-role-permission-list-1"}, {BundleName: "test", Permission: "permission-test-role-permission-list-2"}, {BundleName: "test", Permission: "permission-test-role-permission-list-3"}, diff --git a/dataaccess/memory/user-access.go b/dataaccess/memory/user-access.go index c52e72f..778cd0d 100644 --- a/dataaccess/memory/user-access.go +++ b/dataaccess/memory/user-access.go @@ -128,6 +128,33 @@ func (da *InMemoryDataAccess) UserGetByEmail(ctx context.Context, email string) return rest.User{}, errs.ErrNoSuchUser } +// UserGroupList returns a slice of Group values representing the specified user's group memberships. +// The groups' Users slice is never populated, and is always nil. +func (da *InMemoryDataAccess) UserGroupList(ctx context.Context, username string) ([]rest.Group, error) { + groups := make([]rest.Group, 0) + + for _, group := range da.groups { + for _, user := range group.Users { + if user.Username == username { + groups = append(groups, rest.Group{Name: group.Name}) + continue + } + } + } + + return groups, nil +} + +// UserGroupAdd comments TBD +func (da *InMemoryDataAccess) UserGroupAdd(ctx context.Context, username string, groupname string) error { + return da.GroupUserAdd(ctx, groupname, username) +} + +// UserGroupDelete comments TBD +func (da *InMemoryDataAccess) UserGroupDelete(ctx context.Context, username string, groupname string) error { + return da.GroupUserDelete(ctx, groupname, username) +} + // UserList returns a list of all known users in the datastore. // Passwords are not included. Nice try. func (da *InMemoryDataAccess) UserList(ctx context.Context) ([]rest.User, error) { @@ -141,34 +168,74 @@ func (da *InMemoryDataAccess) UserList(ctx context.Context) ([]rest.User, error) return list, nil } -// UserPermissions returns an alphabetically-sorted list of fully-qualified -// (i.e., "bundle:permission") permissions available to the specified user. -func (da *InMemoryDataAccess) UserPermissions(ctx context.Context, username string) ([]string, error) { - pp := []string{} +// UserPermissionList returns an alphabetically-sorted list of permissions +// available to the specified user. +func (da *InMemoryDataAccess) UserPermissionList(ctx context.Context, username string) (rest.RolePermissionList, error) { + mp := map[string]rest.RolePermission{} + // Permissions aren't attached to users: they're attached to roles, which + // are attached to groups. groups, err := da.UserGroupList(ctx, username) if err != nil { return nil, err } + // Collect all permissions from all groups to remove any repeats. for _, group := range groups { - roles, err := da.GroupListRoles(ctx, group.Name) + gpl, err := da.GroupPermissionList(ctx, group.Name) if err != nil { return nil, err } - for _, role := range roles { - for _, p := range role.Permissions { - pp = append(pp, p.BundleName+":"+p.Permission) - } + for _, p := range gpl { + mp[p.String()] = p } } - sort.Strings(pp) + pp := []rest.RolePermission{} + + for _, p := range mp { + pp = append(pp, p) + } + + sort.Slice(pp, func(i, j int) bool { return pp[i].String() < pp[j].String() }) return pp, nil } +// UserRoleList returns a slice of Role values representing the specified +// user's indirect roles (indirect because users are members of groups, +// and groups have roles). +func (da *InMemoryDataAccess) UserRoleList(ctx context.Context, username string) ([]rest.Role, error) { + rm := map[string]rest.Role{} + + groups, err := da.UserGroupList(ctx, username) + if err != nil { + return []rest.Role{}, err + } + + for _, gr := range groups { + rl, err := da.GroupRoleList(ctx, gr.Name) + if err != nil { + return []rest.Role{}, err + } + + for _, r := range rl { + rm[r.Name] = r + } + } + + roles := []rest.Role{} + + for _, r := range rm { + roles = append(roles, r) + } + + sort.Slice(roles, func(i, j int) bool { return roles[i].Name < roles[j].Name }) + + return roles, nil +} + // UserUpdate is used to update an existing user. An error is returned if the // username is empty or if the user doesn't exist. // TODO Should we let this create users that don't exist? @@ -189,30 +256,3 @@ func (da *InMemoryDataAccess) UserUpdate(ctx context.Context, user rest.User) er return nil } - -// UserGroupList returns a slice of Group values representing the specified user's group memberships. -// The groups' Users slice is never populated, and is always nil. -func (da *InMemoryDataAccess) UserGroupList(ctx context.Context, username string) ([]rest.Group, error) { - groups := make([]rest.Group, 0) - - for _, group := range da.groups { - for _, user := range group.Users { - if user.Username == username { - groups = append(groups, rest.Group{Name: group.Name}) - continue - } - } - } - - return groups, nil -} - -// UserGroupAdd comments TBD -func (da *InMemoryDataAccess) UserGroupAdd(ctx context.Context, username string, groupname string) error { - return da.GroupAddUser(ctx, groupname, username) -} - -// UserGroupDelete comments TBD -func (da *InMemoryDataAccess) UserGroupDelete(ctx context.Context, username string, groupname string) error { - return da.GroupUserDelete(ctx, groupname, username) -} diff --git a/dataaccess/memory/user-access_test.go b/dataaccess/memory/user-access_test.go index 0d96b9a..82a402d 100644 --- a/dataaccess/memory/user-access_test.go +++ b/dataaccess/memory/user-access_test.go @@ -33,7 +33,7 @@ func testUserAccess(t *testing.T) { t.Run("testUserGroupList", testUserGroupList) t.Run("testUserList", testUserList) t.Run("testUserNotExists", testUserNotExists) - t.Run("testUserPermissions", testUserPermissions) + t.Run("testUserPermissionList", testUserPermissionList) t.Run("testUserUpdate", testUserUpdate) } @@ -179,7 +179,7 @@ func testUserGroupList(t *testing.T) { da.UserCreate(ctx, rest.User{Username: "user-test-user-group-list"}) defer da.UserDelete(ctx, "user-test-user-group-list") - da.GroupAddUser(ctx, "group-test-user-group-list-0", "user-test-user-group-list") + da.GroupUserAdd(ctx, "group-test-user-group-list-0", "user-test-user-group-list") expected := []rest.Group{{Name: "group-test-user-group-list-0", Users: nil}} @@ -238,7 +238,8 @@ func testUserNotExists(t *testing.T) { t.FailNow() } } -func testUserPermissions(t *testing.T) { + +func testUserPermissionList(t *testing.T) { var err error err = da.GroupCreate(ctx, rest.Group{Name: "test-perms"}) @@ -252,7 +253,7 @@ func testUserPermissions(t *testing.T) { if !assert.NoError(t, err) { t.FailNow() } - err = da.GroupAddUser(ctx, "test-perms", "test-perms") + err = da.GroupUserAdd(ctx, "test-perms", "test-perms") if !assert.NoError(t, err) { t.FailNow() } @@ -283,12 +284,12 @@ func testUserPermissions(t *testing.T) { // Expected: a sorted list of strings expected := []string{"test:test-perms-0", "test:test-perms-1", "test:test-perms-2"} - actual, err := da.UserPermissions(ctx, "test-perms") + actual, err := da.UserPermissionList(ctx, "test-perms") if !assert.NoError(t, err) { t.FailNow() } - assert.Equal(t, expected, actual) + assert.Equal(t, expected, actual.Strings()) } func testUserUpdate(t *testing.T) { @@ -307,7 +308,7 @@ func testUserUpdate(t *testing.T) { // Get the user we just added. Emails should match. user1, _ := da.UserGet(ctx, "test-update") if userA.Email != user1.Email { - t.Errorf("Email mistatch: %q vs %q", userA.Email, user1.Email) + t.Errorf("Email mismatch: %q vs %q", userA.Email, user1.Email) t.FailNow() } @@ -319,7 +320,7 @@ func testUserUpdate(t *testing.T) { // Get the user we just updated. Emails should match. user2, _ := da.UserGet(ctx, "test-update") if userB.Email != user2.Email { - t.Errorf("Email mistatch: %q vs %q", userB.Email, user2.Email) + t.Errorf("Email mismatch: %q vs %q", userB.Email, user2.Email) t.FailNow() } } diff --git a/dataaccess/postgres/bundle-access.go b/dataaccess/postgres/bundle-access.go index 504abbc..12e34f1 100644 --- a/dataaccess/postgres/bundle-access.go +++ b/dataaccess/postgres/bundle-access.go @@ -364,10 +364,63 @@ func (da PostgresDataAccess) BundleList(ctx context.Context) ([]data.Bundle, err return bundles, nil } -// BundleListVersions TBD -func (da PostgresDataAccess) BundleListVersions(ctx context.Context, name string) ([]data.Bundle, error) { +// BundleUpdate TBD +func (da PostgresDataAccess) BundleUpdate(ctx context.Context, bundle data.Bundle) error { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.BundleUpdate") + defer sp.End() + + if bundle.Name == "" { + return errs.ErrEmptyBundleName + } + + if bundle.Version == "" { + return errs.ErrEmptyBundleVersion + } + + db, err := da.connect(ctx, "gort") + if err != nil { + return err + } + defer db.Close() + + tx, err := db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return gerr.Wrap(errs.ErrDataAccess, err) + } + + exists, err := da.doBundleExists(ctx, tx, bundle.Name, bundle.Version) + if err != nil { + return err + } else if !exists { + return errs.ErrNoSuchBundle + } + + err = da.doBundleDelete(ctx, tx, bundle.Name, bundle.Version) + if err != nil { + tx.Rollback() + return err + } + + err = da.doBundleInsert(ctx, tx, bundle) + if err != nil { + tx.Rollback() + return err + } + + err = tx.Commit() + if err != nil { + tx.Rollback() + return gerr.Wrap(errs.ErrDataAccess, err) + } + + return nil +} + +// BundleVersionList TBD +func (da PostgresDataAccess) BundleVersionList(ctx context.Context, name string) ([]data.Bundle, error) { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.BundleListVersions") + ctx, sp := tr.Start(ctx, "postgres.BundleVersionList") defer sp.End() // This is hacky as fuck. I know. @@ -421,59 +474,6 @@ func (da PostgresDataAccess) BundleListVersions(ctx context.Context, name string return bundles, nil } -// BundleUpdate TBD -func (da PostgresDataAccess) BundleUpdate(ctx context.Context, bundle data.Bundle) error { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.BundleUpdate") - defer sp.End() - - if bundle.Name == "" { - return errs.ErrEmptyBundleName - } - - if bundle.Version == "" { - return errs.ErrEmptyBundleVersion - } - - db, err := da.connect(ctx, "gort") - if err != nil { - return err - } - defer db.Close() - - tx, err := db.BeginTx(ctx, &sql.TxOptions{}) - if err != nil { - return gerr.Wrap(errs.ErrDataAccess, err) - } - - exists, err := da.doBundleExists(ctx, tx, bundle.Name, bundle.Version) - if err != nil { - return err - } else if !exists { - return errs.ErrNoSuchBundle - } - - err = da.doBundleDelete(ctx, tx, bundle.Name, bundle.Version) - if err != nil { - tx.Rollback() - return err - } - - err = da.doBundleInsert(ctx, tx, bundle) - if err != nil { - tx.Rollback() - return err - } - - err = tx.Commit() - if err != nil { - tx.Rollback() - return gerr.Wrap(errs.ErrDataAccess, err) - } - - return nil -} - // FindCommandEntry is used to find the enabled commands with the provided // bundle and command names. If either is empty, it is treated as a wildcard. // Importantly, this must only return ENABLED commands! diff --git a/dataaccess/postgres/bundle-access_test.go b/dataaccess/postgres/bundle-access_test.go index 364d947..f19ed11 100644 --- a/dataaccess/postgres/bundle-access_test.go +++ b/dataaccess/postgres/bundle-access_test.go @@ -35,7 +35,7 @@ func testBundleAccess(t *testing.T) { t.Run("testBundleDelete", testBundleDelete) t.Run("testBundleGet", testBundleGet) t.Run("testBundleList", testBundleList) - t.Run("testBundleListVersions", testBundleListVersions) + t.Run("testBundleVersionList", testBundleVersionList) t.Run("testFindCommandEntry", testFindCommandEntry) } @@ -340,7 +340,7 @@ func testBundleList(t *testing.T) { } } -func testBundleListVersions(t *testing.T) { +func testBundleVersionList(t *testing.T) { da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.0", Description: "foo"}) defer da.BundleDelete(ctx, "test-list-0", "0.0") da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-0", Version: "0.1", Description: "foo"}) @@ -350,7 +350,7 @@ func testBundleListVersions(t *testing.T) { da.BundleCreate(ctx, data.Bundle{GortBundleVersion: 5, Name: "test-list-1", Version: "0.1", Description: "foo"}) defer da.BundleDelete(ctx, "test-list-1", "0.1") - bundles, err := da.BundleListVersions(ctx, "test-list-0") + bundles, err := da.BundleVersionList(ctx, "test-list-0") assert.NoError(t, err) if len(bundles) != 2 { diff --git a/dataaccess/postgres/group-access.go b/dataaccess/postgres/group-access.go index 90b0e9b..3ef12ec 100644 --- a/dataaccess/postgres/group-access.go +++ b/dataaccess/postgres/group-access.go @@ -18,6 +18,8 @@ package postgres import ( "context" + "database/sql" + "sort" "go.opentelemetry.io/otel" @@ -27,51 +29,6 @@ import ( "github.com/getgort/gort/telemetry" ) -// GroupAddUser adds a user to a group -func (da PostgresDataAccess) GroupAddUser(ctx context.Context, groupname string, username string) error { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.GroupAddUser") - defer sp.End() - - if groupname == "" { - return errs.ErrEmptyGroupName - } - - exists, err := da.GroupExists(ctx, groupname) - if err != nil { - return err - } - if !exists { - return errs.ErrNoSuchGroup - } - - if username == "" { - return errs.ErrEmptyUserName - } - - exists, err = da.UserExists(ctx, username) - if err != nil { - return err - } - if !exists { - return errs.ErrNoSuchUser - } - - db, err := da.connect(ctx, DatabaseGort) - if err != nil { - return err - } - defer db.Close() - - query := `INSERT INTO groupusers (groupname, username) VALUES ($1, $2);` - _, err = db.ExecContext(ctx, query, groupname, username) - if err != nil { - err = gerr.Wrap(errs.ErrDataAccess, err) - } - - return err -} - // GroupCreate creates a new user group. func (da PostgresDataAccess) GroupCreate(ctx context.Context, group rest.Group) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) @@ -195,11 +152,13 @@ func (da PostgresDataAccess) GroupGet(ctx context.Context, groupname string) (re group := rest.Group{} err = db.QueryRowContext(ctx, query, groupname).Scan(&group.Name) - if err != nil { - return group, gerr.Wrap(errs.ErrNoSuchGroup, err) + if err == sql.ErrNoRows { + return group, errs.ErrNoSuchGroup + } else if err != nil { + return group, gerr.Wrap(errs.ErrDataAccess, err) } - users, err := da.GroupListUsers(ctx, groupname) + users, err := da.GroupUserList(ctx, groupname) if err != nil { return group, err } @@ -209,6 +168,75 @@ func (da PostgresDataAccess) GroupGet(ctx context.Context, groupname string) (re return group, nil } +// GroupList returns a list of all known groups in the datastore. +// Passwords are not included. Nice try. +func (da PostgresDataAccess) GroupList(ctx context.Context) ([]rest.Group, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.GroupList") + defer sp.End() + + groups := make([]rest.Group, 0) + + db, err := da.connect(ctx, DatabaseGort) + if err != nil { + return groups, err + } + defer db.Close() + + query := `SELECT groupname FROM groups` + rows, err := db.QueryContext(ctx, query) + if err != nil { + return groups, gerr.Wrap(errs.ErrDataAccess, err) + } + + for rows.Next() { + group := rest.Group{} + + err = rows.Scan(&group.Name) + if err != nil { + return groups, gerr.Wrap(errs.ErrNoSuchGroup, err) + } + + groups = append(groups, group) + } + + return groups, nil +} + +func (da PostgresDataAccess) GroupPermissionList(ctx context.Context, groupname string) (rest.RolePermissionList, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.GroupPermissionList") + defer sp.End() + + roles, err := da.GroupRoleList(ctx, groupname) + if err != nil { + return rest.RolePermissionList{}, err + } + + mp := map[string]rest.RolePermission{} + + for _, r := range roles { + rpl, err := da.RolePermissionList(ctx, r.Name) + if err != nil { + return rest.RolePermissionList{}, err + } + + for _, rp := range rpl { + mp[rp.String()] = rp + } + } + + pp := []rest.RolePermission{} + + for _, p := range mp { + pp = append(pp, p) + } + + sort.Slice(pp, func(i, j int) bool { return pp[i].String() < pp[j].String() }) + + return pp, nil +} + // GroupRoleAdd grants one or more roles to a group. func (da PostgresDataAccess) GroupRoleAdd(ctx context.Context, groupname, rolename string) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) @@ -251,44 +279,39 @@ func (da PostgresDataAccess) GroupRoleAdd(ctx context.Context, groupname, rolena return err } -// GroupList returns a list of all known groups in the datastore. -// Passwords are not included. Nice try. -func (da PostgresDataAccess) GroupList(ctx context.Context) ([]rest.Group, error) { +// GroupRoleDelete revokes a role from a group. +func (da PostgresDataAccess) GroupRoleDelete(ctx context.Context, groupname, rolename string) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.GroupList") + ctx, sp := tr.Start(ctx, "postgres.GroupRoleDelete") defer sp.End() - groups := make([]rest.Group, 0) + if groupname == "" { + return errs.ErrEmptyGroupName + } + + if rolename == "" { + return errs.ErrEmptyRoleName + } db, err := da.connect(ctx, DatabaseGort) if err != nil { - return groups, err + return err } defer db.Close() - query := `SELECT groupname FROM groups` - rows, err := db.QueryContext(ctx, query) + query := `DELETE FROM group_roles + WHERE group_name=$1 AND role_name=$2;` + _, err = db.ExecContext(ctx, query, groupname, rolename) if err != nil { - return groups, gerr.Wrap(errs.ErrDataAccess, err) - } - - for rows.Next() { - group := rest.Group{} - - err = rows.Scan(&group.Name) - if err != nil { - return groups, gerr.Wrap(errs.ErrNoSuchGroup, err) - } - - groups = append(groups, group) + return gerr.Wrap(errs.ErrDataAccess, err) } - return groups, nil + return err } -func (da PostgresDataAccess) GroupListRoles(ctx context.Context, groupname string) ([]rest.Role, error) { +func (da PostgresDataAccess) GroupRoleList(ctx context.Context, groupname string) ([]rest.Role, error) { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.GroupListRoles") + ctx, sp := tr.Start(ctx, "postgres.GroupRoleList") defer sp.End() if groupname == "" { @@ -326,7 +349,7 @@ func (da PostgresDataAccess) GroupListRoles(ctx context.Context, groupname strin err = rows.Scan(&name) if err != nil { - return nil, gerr.Wrap(errs.ErrNoSuchUser, err) + return nil, gerr.Wrap(errs.ErrDataAccess, err) } role, err := da.RoleGet(ctx, name) @@ -340,51 +363,49 @@ func (da PostgresDataAccess) GroupListRoles(ctx context.Context, groupname strin return roles, nil } -// GroupListUsers returns a list of all known users in a group. -func (da PostgresDataAccess) GroupListUsers(ctx context.Context, groupname string) ([]rest.User, error) { +// GroupUpdate is used to update an existing group. An error is returned if the +// groupname is empty or if the group doesn't exist. +// TODO Should we let this create groups that don't exist? +func (da PostgresDataAccess) GroupUpdate(ctx context.Context, group rest.Group) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.GroupListUsers") + ctx, sp := tr.Start(ctx, "postgres.GroupUpdate") defer sp.End() - users := make([]rest.User, 0) + if group.Name == "" { + return errs.ErrEmptyGroupName + } - db, err := da.connect(ctx, DatabaseGort) + exists, err := da.UserExists(ctx, group.Name) if err != nil { - return users, err + return err + } + if !exists { + return errs.ErrNoSuchGroup } - defer db.Close() - - query := `SELECT email, full_name, username - FROM users - WHERE username IN ( - SELECT username - FROM groupusers - WHERE groupname = $1 - )` - rows, err := db.QueryContext(ctx, query, groupname) + db, err := da.connect(ctx, DatabaseGort) if err != nil { - return users, gerr.Wrap(errs.ErrDataAccess, err) + return err } + defer db.Close() - for rows.Next() { - user := rest.User{} - - err = rows.Scan(&user.Email, &user.FullName, &user.Username) - if err != nil { - return users, gerr.Wrap(errs.ErrNoSuchUser, err) - } + // There will be more eventually + query := `UPDATE groupname + SET groupname=$1 + WHERE groupname=$1;` - users = append(users, user) + _, err = db.ExecContext(ctx, query, group.Name) + if err != nil { + err = gerr.Wrap(errs.ErrDataAccess, err) } - return users, nil + return err } -// GroupRemoveUser removes a user from a group. -func (da PostgresDataAccess) GroupRemoveUser(ctx context.Context, groupname string, username string) error { +// GroupUserAdd adds a user to a group +func (da PostgresDataAccess) GroupUserAdd(ctx context.Context, groupname string, username string) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.GroupRemoveUser") + ctx, sp := tr.Start(ctx, "postgres.GroupUserAdd") defer sp.End() if groupname == "" { @@ -399,13 +420,25 @@ func (da PostgresDataAccess) GroupRemoveUser(ctx context.Context, groupname stri return errs.ErrNoSuchGroup } + if username == "" { + return errs.ErrEmptyUserName + } + + exists, err = da.UserExists(ctx, username) + if err != nil { + return err + } + if !exists { + return errs.ErrNoSuchUser + } + db, err := da.connect(ctx, DatabaseGort) if err != nil { return err } defer db.Close() - query := "DELETE FROM groupusers WHERE groupname=$1 AND username=$2;" + query := `INSERT INTO groupusers (groupname, username) VALUES ($1, $2);` _, err = db.ExecContext(ctx, query, groupname, username) if err != nil { err = gerr.Wrap(errs.ErrDataAccess, err) @@ -414,18 +447,22 @@ func (da PostgresDataAccess) GroupRemoveUser(ctx context.Context, groupname stri return err } -// GroupRoleDelete revokes a role from a group. -func (da PostgresDataAccess) GroupRoleDelete(ctx context.Context, groupname, rolename string) error { +// GroupUserDelete removes a user from a group. +func (da PostgresDataAccess) GroupUserDelete(ctx context.Context, groupname string, username string) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.GroupRoleDelete") + ctx, sp := tr.Start(ctx, "postgres.GroupUserDelete") defer sp.End() if groupname == "" { return errs.ErrEmptyGroupName } - if rolename == "" { - return errs.ErrEmptyRoleName + exists, err := da.GroupExists(ctx, groupname) + if err != nil { + return err + } + if !exists { + return errs.ErrNoSuchGroup } db, err := da.connect(ctx, DatabaseGort) @@ -434,61 +471,64 @@ func (da PostgresDataAccess) GroupRoleDelete(ctx context.Context, groupname, rol } defer db.Close() - query := `DELETE FROM group_roles - WHERE group_name=$1 AND role_name=$2;` - _, err = db.ExecContext(ctx, query, groupname, rolename) + query := "DELETE FROM groupusers WHERE groupname=$1 AND username=$2;" + _, err = db.ExecContext(ctx, query, groupname, username) if err != nil { - return gerr.Wrap(errs.ErrDataAccess, err) + err = gerr.Wrap(errs.ErrDataAccess, err) } return err } -// GroupUpdate is used to update an existing group. An error is returned if the -// groupname is empty or if the group doesn't exist. -// TODO Should we let this create groups that don't exist? -func (da PostgresDataAccess) GroupUpdate(ctx context.Context, group rest.Group) error { +// GroupUserList returns a list of all known users in a group. +func (da PostgresDataAccess) GroupUserList(ctx context.Context, groupname string) ([]rest.User, error) { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.GroupUpdate") + ctx, sp := tr.Start(ctx, "postgres.GroupUserList") defer sp.End() - if group.Name == "" { - return errs.ErrEmptyGroupName + users := make([]rest.User, 0) + + if groupname == "" { + return users, errs.ErrEmptyGroupName } - exists, err := da.UserExists(ctx, group.Name) + exists, err := da.GroupExists(ctx, groupname) if err != nil { - return err + return users, err } if !exists { - return errs.ErrNoSuchGroup + return users, errs.ErrNoSuchGroup } db, err := da.connect(ctx, DatabaseGort) if err != nil { - return err + return users, err } defer db.Close() - // There will be more eventually - query := `UPDATE groupname - SET groupname=$1 - WHERE groupname=$1;` + query := `SELECT email, full_name, username + FROM users + WHERE username IN ( + SELECT username + FROM groupusers + WHERE groupname = $1 + )` - _, err = db.ExecContext(ctx, query, group.Name) + rows, err := db.QueryContext(ctx, query, groupname) if err != nil { - err = gerr.Wrap(errs.ErrDataAccess, err) + return users, gerr.Wrap(errs.ErrDataAccess, err) } - return err -} + for rows.Next() { + user := rest.User{} -// GroupUserAdd comments TBD -func (da PostgresDataAccess) GroupUserAdd(ctx context.Context, group string, user string) error { - return errs.ErrNotImplemented -} + err = rows.Scan(&user.Email, &user.FullName, &user.Username) + if err != nil { + return users, gerr.Wrap(errs.ErrNoSuchUser, err) + } -// GroupUserDelete comments TBD -func (da PostgresDataAccess) GroupUserDelete(ctx context.Context, group string, user string) error { - return errs.ErrNotImplemented + users = append(users, user) + } + + return users, nil } diff --git a/dataaccess/postgres/group-access_test.go b/dataaccess/postgres/group-access_test.go index 5513bdb..1ff6fe4 100644 --- a/dataaccess/postgres/group-access_test.go +++ b/dataaccess/postgres/group-access_test.go @@ -25,44 +25,85 @@ import ( ) func testGroupAccess(t *testing.T) { - t.Run("testGroupAddUser", testGroupAddUser) + t.Run("testGroupUserAdd", testGroupUserAdd) + t.Run("testGroupUserList", testGroupUserList) t.Run("testGroupCreate", testGroupCreate) t.Run("testGroupDelete", testGroupDelete) t.Run("testGroupExists", testGroupExists) t.Run("testGroupGet", testGroupGet) t.Run("testGroupRoleAdd", testGroupRoleAdd) + t.Run("testGroupPermissionList", testGroupPermissionList) t.Run("testGroupList", testGroupList) - t.Run("testGroupListRoles", testGroupListRoles) - t.Run("testGroupRemoveUser", testGroupRemoveUser) + t.Run("testGroupRoleList", testGroupRoleList) + t.Run("testGroupUserDelete", testGroupUserDelete) } -func testGroupAddUser(t *testing.T) { - err := da.GroupAddUser(ctx, "foo", "bar") +func testGroupUserAdd(t *testing.T) { + var ( + groupname = "group-test-group-user-add" + username = "user-test-group-user-add" + useremail = "user@foo.bar" + ) + + err := da.GroupUserAdd(ctx, groupname, username) assert.Error(t, err, errs.ErrNoSuchGroup) - da.GroupCreate(ctx, rest.Group{Name: "foo"}) - defer da.GroupDelete(ctx, "foo") + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) - err = da.GroupAddUser(ctx, "foo", "bar") + err = da.GroupUserAdd(ctx, groupname, username) assert.Error(t, err, errs.ErrNoSuchUser) - da.UserCreate(ctx, rest.User{Username: "bar", Email: "bar"}) - defer da.UserDelete(ctx, "bar") + da.UserCreate(ctx, rest.User{Username: username, Email: useremail}) + defer da.UserDelete(ctx, username) - err = da.GroupAddUser(ctx, "foo", "bar") + err = da.GroupUserAdd(ctx, groupname, username) assert.NoError(t, err) - group, _ := da.GroupGet(ctx, "foo") + group, _ := da.GroupGet(ctx, groupname) - if len(group.Users) != 1 { - t.Error("Users list empty") + if !assert.Len(t, group.Users, 1) { t.FailNow() } - if len(group.Users) > 0 && group.Users[0].Username != "bar" { - t.Error("Wrong user!") + assert.Equal(t, group.Users[0].Username, username) + assert.Equal(t, group.Users[0].Email, useremail) +} + +func testGroupUserList(t *testing.T) { + var ( + groupname = "group-test-group-user-list" + expected = []rest.User{ + {Username: "user-test-group-user-list-0", Email: "user-test-group-user-list-0@email.com"}, + {Username: "user-test-group-user-list-1", Email: "user-test-group-user-list-1@email.com"}, + } + ) + + _, err := da.GroupUserList(ctx, groupname) + assert.Error(t, err) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) + + da.UserCreate(ctx, expected[0]) + defer da.UserDelete(ctx, expected[0].Username) + da.UserCreate(ctx, expected[1]) + defer da.UserDelete(ctx, expected[1].Username) + + da.GroupUserAdd(ctx, groupname, expected[0].Username) + da.GroupUserAdd(ctx, groupname, expected[1].Username) + + actual, err := da.GroupUserList(ctx, groupname) + if !assert.NoError(t, err) { + t.FailNow() + } + + if !assert.Len(t, actual, 2) { t.FailNow() } + + assert.Equal(t, expected, actual) } func testGroupCreate(t *testing.T) { @@ -126,34 +167,74 @@ func testGroupExists(t *testing.T) { } func testGroupGet(t *testing.T) { + groupname := "group-test-group-get" + var err error var group rest.Group // Expect an error _, err = da.GroupGet(ctx, "") - assert.Error(t, err, errs.ErrEmptyGroupName) + assert.ErrorIs(t, err, errs.ErrEmptyGroupName) // Expect an error - _, err = da.GroupGet(ctx, "test-get") - assert.Error(t, err, errs.ErrNoSuchGroup) + _, err = da.GroupGet(ctx, groupname) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) - da.GroupCreate(ctx, rest.Group{Name: "test-get"}) - defer da.GroupDelete(ctx, "test-get") + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) // da.Group ctx, should exist now - exists, _ := da.GroupExists(ctx, "test-get") - if !exists { - t.Error("Group should exist now") + exists, _ := da.GroupExists(ctx, groupname) + if !assert.True(t, exists) { t.FailNow() } // Expect no error - group, err = da.GroupGet(ctx, "test-get") - assert.NoError(t, err) - if group.Name != "test-get" { - t.Errorf("Group name mismatch: %q is not \"test-get\"", group.Name) + group, err = da.GroupGet(ctx, groupname) + if !assert.NoError(t, err) { + t.FailNow() + } + if !assert.Equal(t, groupname, group.Name) { + t.FailNow() + } +} + +func testGroupPermissionList(t *testing.T) { + const ( + groupname = "group-test-group-permission-list" + rolename = "role-test-group-permission-list" + bundlename = "test" + ) + + var expected = rest.RolePermissionList{ + {BundleName: bundlename, Permission: "role-test-group-permission-list-1"}, + {BundleName: bundlename, Permission: "role-test-group-permission-list-2"}, + {BundleName: bundlename, Permission: "role-test-group-permission-list-3"}, + } + + var err error + + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + err = da.GroupRoleAdd(ctx, groupname, rolename) + if !assert.NoError(t, err) { + t.FailNow() + } + + da.RolePermissionAdd(ctx, rolename, expected[0].BundleName, expected[0].Permission) + da.RolePermissionAdd(ctx, rolename, expected[1].BundleName, expected[1].Permission) + da.RolePermissionAdd(ctx, rolename, expected[2].BundleName, expected[2].Permission) + + actual, err := da.GroupPermissionList(ctx, groupname) + if !assert.NoError(t, err) { t.FailNow() } + + assert.Equal(t, expected, actual) } func testGroupRoleAdd(t *testing.T) { @@ -190,7 +271,7 @@ func testGroupRoleAdd(t *testing.T) { }, } - roles, err := da.GroupListRoles(ctx, groupName) + roles, err := da.GroupRoleList(ctx, groupName) if !assert.NoError(t, err) { t.FailNow() } @@ -204,7 +285,7 @@ func testGroupRoleAdd(t *testing.T) { expectedRoles = []rest.Role{} - roles, err = da.GroupListRoles(ctx, groupName) + roles, err = da.GroupRoleList(ctx, groupName) if !assert.NoError(t, err) { t.FailNow() } @@ -238,45 +319,53 @@ func testGroupList(t *testing.T) { } } -func testGroupListRoles(t *testing.T) { - da.GroupCreate(ctx, rest.Group{Name: "group-test-group-list-roles"}) - defer da.GroupDelete(ctx, "group-test-group-list-roles") +func testGroupRoleList(t *testing.T) { + var ( + groupname = "group-test-group-list-roles" + rolenames = []string{ + "role-test-group-list-roles-0", + "role-test-group-list-roles-1", + "role-test-group-list-roles-2", + } + ) + da.GroupCreate(ctx, rest.Group{Name: groupname}) + defer da.GroupDelete(ctx, groupname) - da.RoleCreate(ctx, "role-test-group-list-roles-1") - defer da.RoleDelete(ctx, "role-test-group-list-roles-1") + da.RoleCreate(ctx, rolenames[1]) + defer da.RoleDelete(ctx, rolenames[1]) - da.RoleCreate(ctx, "role-test-group-list-roles-0") - defer da.RoleDelete(ctx, "role-test-group-list-roles-0") + da.RoleCreate(ctx, rolenames[0]) + defer da.RoleDelete(ctx, rolenames[0]) - da.RoleCreate(ctx, "role-test-group-list-roles-2") - defer da.RoleDelete(ctx, "role-test-group-list-roles-2") + da.RoleCreate(ctx, rolenames[2]) + defer da.RoleDelete(ctx, rolenames[2]) - roles, err := da.GroupListRoles(ctx, "group-test-group-list-roles") + roles, err := da.GroupRoleList(ctx, groupname) if !assert.NoError(t, err) && !assert.Empty(t, roles) { t.FailNow() } - err = da.GroupRoleAdd(ctx, "group-test-group-list-roles", "role-test-group-list-roles-1") + err = da.GroupRoleAdd(ctx, groupname, rolenames[1]) if !assert.NoError(t, err) { t.FailNow() } - err = da.GroupRoleAdd(ctx, "group-test-group-list-roles", "role-test-group-list-roles-0") + err = da.GroupRoleAdd(ctx, groupname, rolenames[0]) if !assert.NoError(t, err) { t.FailNow() } - err = da.GroupRoleAdd(ctx, "group-test-group-list-roles", "role-test-group-list-roles-2") + err = da.GroupRoleAdd(ctx, groupname, rolenames[2]) if !assert.NoError(t, err) { t.FailNow() } // Note: alphabetically sorted! expected := []rest.Role{ - {Name: "role-test-group-list-roles-0", Permissions: []rest.RolePermission{}}, - {Name: "role-test-group-list-roles-1", Permissions: []rest.RolePermission{}}, - {Name: "role-test-group-list-roles-2", Permissions: []rest.RolePermission{}}, + {Name: rolenames[0], Permissions: []rest.RolePermission{}}, + {Name: rolenames[1], Permissions: []rest.RolePermission{}}, + {Name: rolenames[2], Permissions: []rest.RolePermission{}}, } - actual, err := da.GroupListRoles(ctx, "group-test-group-list-roles") + actual, err := da.GroupRoleList(ctx, groupname) if !assert.NoError(t, err) { t.FailNow() } @@ -284,14 +373,14 @@ func testGroupListRoles(t *testing.T) { assert.Equal(t, expected, actual) } -func testGroupRemoveUser(t *testing.T) { +func testGroupUserDelete(t *testing.T) { da.GroupCreate(ctx, rest.Group{Name: "foo"}) defer da.GroupDelete(ctx, "foo") da.UserCreate(ctx, rest.User{Username: "bat"}) defer da.UserDelete(ctx, "bat") - err := da.GroupAddUser(ctx, "foo", "bat") + err := da.GroupUserAdd(ctx, "foo", "bat") assert.NoError(t, err) group, err := da.GroupGet(ctx, "foo") @@ -307,7 +396,7 @@ func testGroupRemoveUser(t *testing.T) { t.FailNow() } - err = da.GroupRemoveUser(ctx, "foo", "bat") + err = da.GroupUserDelete(ctx, "foo", "bat") assert.NoError(t, err) group, err = da.GroupGet(ctx, "foo") diff --git a/dataaccess/postgres/role-access.go b/dataaccess/postgres/role-access.go index 03d6ca7..ba2b037 100644 --- a/dataaccess/postgres/role-access.go +++ b/dataaccess/postgres/role-access.go @@ -62,65 +62,6 @@ func (da PostgresDataAccess) RoleCreate(ctx context.Context, name string) error return err } -// RoleList gets all roles. -func (da PostgresDataAccess) RoleList(ctx context.Context) ([]rest.Role, error) { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.RoleList") - defer sp.End() - - db, err := da.connect(ctx, DatabaseGort) - if err != nil { - return nil, err - } - defer db.Close() - - var rolesByName = make(map[string]*rest.Role) - // Load all role names and add to the roles map - query := `SELECT role_name - FROM roles` - - rows, err := db.QueryContext(ctx, query) - if err != nil { - return nil, gerr.Wrap(errs.ErrNoSuchRole, err) - } - defer rows.Close() - - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - log.Fatal(err) - } - rolesByName[name] = &rest.Role{Name: name} - } - - // Load all permissions and add to role objects - query = `SELECT role_name, bundle_name, permission - FROM role_permissions` - rows, err = db.QueryContext(ctx, query) - if err != nil { - return nil, gerr.Wrap(errs.ErrNoSuchRole, err) - } - defer rows.Close() - - for rows.Next() { - var ( - rolename string - permission rest.RolePermission - ) - if err := rows.Scan(&rolename, &permission.BundleName, &permission.Permission); err != nil { - log.Fatal(err) - } - rolesByName[rolename].Permissions = append(rolesByName[rolename].Permissions, permission) - } - - var roles []rest.Role - for _, role := range rolesByName { - roles = append(roles, *role) - } - - return roles, nil -} - // RoleDelete func (da PostgresDataAccess) RoleDelete(ctx context.Context, name string) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) @@ -225,6 +166,160 @@ func (da PostgresDataAccess) RoleGet(ctx context.Context, name string) (rest.Rol return role, nil } +func (da PostgresDataAccess) RoleGroupAdd(ctx context.Context, rolename, groupname string) error { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.RoleGroupAdd") + defer sp.End() + + return da.GroupRoleAdd(ctx, groupname, rolename) +} + +func (da PostgresDataAccess) RoleGroupDelete(ctx context.Context, rolename, groupname string) error { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.RoleGroupDelete") + defer sp.End() + + return da.GroupRoleDelete(ctx, groupname, rolename) +} + +func (da PostgresDataAccess) RoleGroupExists(ctx context.Context, rolename, groupname string) (bool, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.RoleGroupExists") + defer sp.End() + + groups, err := da.RoleGroupList(ctx, rolename) + if err != nil { + return false, err + } + + if exists, err := da.GroupExists(ctx, groupname); err != nil { + return false, err + } else if !exists { + return false, errs.ErrNoSuchGroup + } + + for _, g := range groups { + if g.Name == groupname { + return true, nil + } + } + + return false, nil +} + +func (da PostgresDataAccess) RoleGroupList(ctx context.Context, rolename string) ([]rest.Group, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.RoleGroupList") + defer sp.End() + + if rolename == "" { + return nil, errs.ErrEmptyRoleName + } + + exists, err := da.RoleExists(ctx, rolename) + if err != nil { + return nil, err + } + if !exists { + return nil, errs.ErrNoSuchRole + } + + db, err := da.connect(ctx, DatabaseGort) + if err != nil { + return nil, err + } + defer db.Close() + + query := `SELECT group_name + FROM group_roles + WHERE role_name = $1 + ORDER BY role_name` + + rows, err := db.QueryContext(ctx, query, rolename) + if err != nil { + return nil, gerr.Wrap(errs.ErrDataAccess, err) + } + + groups := []rest.Group{} + + for rows.Next() { + var name string + + err = rows.Scan(&name) + if err != nil { + return nil, gerr.Wrap(errs.ErrDataAccess, err) + } + + group, err := da.GroupGet(ctx, name) + if err != nil { + return nil, err + } + + groups = append(groups, group) + } + + return groups, nil +} + +// RoleList gets all roles. +func (da PostgresDataAccess) RoleList(ctx context.Context) ([]rest.Role, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.RoleList") + defer sp.End() + + db, err := da.connect(ctx, DatabaseGort) + if err != nil { + return nil, err + } + defer db.Close() + + var rolesByName = make(map[string]*rest.Role) + // Load all role names and add to the roles map + query := `SELECT role_name + FROM roles` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, gerr.Wrap(errs.ErrNoSuchRole, err) + } + defer rows.Close() + + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + log.Fatal(err) + } + rolesByName[name] = &rest.Role{Name: name} + } + + // Load all permissions and add to role objects + query = `SELECT role_name, bundle_name, permission + FROM role_permissions` + rows, err = db.QueryContext(ctx, query) + if err != nil { + return nil, gerr.Wrap(errs.ErrNoSuchRole, err) + } + defer rows.Close() + + for rows.Next() { + var ( + rolename string + permission rest.RolePermission + ) + if err := rows.Scan(&rolename, &permission.BundleName, &permission.Permission); err != nil { + log.Fatal(err) + } + rolesByName[rolename].Permissions = append(rolesByName[rolename].Permissions, permission) + } + + var roles []rest.Role + for _, role := range rolesByName { + roles = append(roles, *role) + } + + return roles, nil +} + func (da PostgresDataAccess) RolePermissionAdd(ctx context.Context, rolename, bundle, permission string) error { tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) ctx, sp := tr.Start(ctx, "postgres.RolePermissionAdd") @@ -299,46 +394,10 @@ func (da PostgresDataAccess) RolePermissionDelete(ctx context.Context, rolename, return err } -func (da PostgresDataAccess) doGetRolePermissions(ctx context.Context, name string) ([]rest.RolePermission, error) { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.doGetRolePermissions") - defer sp.End() - - perms := make([]rest.RolePermission, 0) - - db, err := da.connect(ctx, DatabaseGort) - if err != nil { - return perms, err - } - defer db.Close() - - query := `SELECT bundle_name, permission - FROM role_permissions - WHERE role_name = $1` - - rows, err := db.QueryContext(ctx, query, name) - if err != nil { - return perms, gerr.Wrap(errs.ErrDataAccess, err) - } - - for rows.Next() { - perm := rest.RolePermission{} - - err = rows.Scan(&perm.BundleName, &perm.Permission) - if err != nil { - return perms, gerr.Wrap(errs.ErrNoSuchUser, err) - } - - perms = append(perms, perm) - } - - return perms, nil -} - -// RoleHasPermission returns true if the given role has been granted the +// RolePermissionExists returns true if the given role has been granted the // specified permission. It returns an error if rolename is empty or if no // such role exists. -func (da PostgresDataAccess) RoleHasPermission(ctx context.Context, rolename, bundlename, permission string) (bool, error) { +func (da PostgresDataAccess) RolePermissionExists(ctx context.Context, rolename, bundlename, permission string) (bool, error) { // TODO Make this more efficient. perms, err := da.RolePermissionList(ctx, rolename) @@ -358,7 +417,7 @@ func (da PostgresDataAccess) RoleHasPermission(ctx context.Context, rolename, bu // RolePermissionList returns returns an alphabetically-sorted list of // fully-qualified (i.e., "bundle:permission") permissions granted to // the role. -func (da PostgresDataAccess) RolePermissionList(ctx context.Context, rolename string) ([]rest.RolePermission, error) { +func (da PostgresDataAccess) RolePermissionList(ctx context.Context, rolename string) (rest.RolePermissionList, error) { // TODO Make this more efficient. role, err := da.RoleGet(ctx, rolename) @@ -372,3 +431,39 @@ func (da PostgresDataAccess) RolePermissionList(ctx context.Context, rolename st return perms, nil } + +func (da PostgresDataAccess) doGetRolePermissions(ctx context.Context, name string) (rest.RolePermissionList, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.doGetRolePermissions") + defer sp.End() + + perms := make([]rest.RolePermission, 0) + + db, err := da.connect(ctx, DatabaseGort) + if err != nil { + return perms, err + } + defer db.Close() + + query := `SELECT bundle_name, permission + FROM role_permissions + WHERE role_name = $1` + + rows, err := db.QueryContext(ctx, query, name) + if err != nil { + return perms, gerr.Wrap(errs.ErrDataAccess, err) + } + + for rows.Next() { + perm := rest.RolePermission{} + + err = rows.Scan(&perm.BundleName, &perm.Permission) + if err != nil { + return perms, gerr.Wrap(errs.ErrNoSuchUser, err) + } + + perms = append(perms, perm) + } + + return perms, nil +} diff --git a/dataaccess/postgres/role-access_test.go b/dataaccess/postgres/role-access_test.go index 4dd2673..3297bba 100644 --- a/dataaccess/postgres/role-access_test.go +++ b/dataaccess/postgres/role-access_test.go @@ -30,7 +30,12 @@ func testRoleAccess(t *testing.T) { t.Run("testRoleExists", testRoleExists) t.Run("testRoleDelete", testRoleDelete) t.Run("testRoleGet", testRoleGet) - t.Run("testRoleHasPermission", testRoleHasPermission) + t.Run("testRoleGroupAdd", testRoleGroupAdd) + t.Run("testRoleGroupDelete", testRoleGroupDelete) + t.Run("testRoleGroupExists", testRoleGroupExists) + t.Run("testRoleGroupList", testRoleGroupList) + t.Run("testRolePermissionExists", testRolePermissionExists) + t.Run("testRolePermissionAdd", testRolePermissionAdd) t.Run("testRolePermissionList", testRolePermissionList) } @@ -164,13 +169,193 @@ func testRoleGet(t *testing.T) { assert.Equal(t, expected, role) } -func testRoleHasPermission(t *testing.T) { +func testRoleGroupAdd(t *testing.T) { + var err error + + rolename := "role-test-role-group-add" + groupnames := []string{ + "perm-test-role-group-add-0", + "perm-test-role-group-add-1", + } + + // No such group yet + err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(ctx, groupnames[0]) + da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(ctx, groupnames[1]) + + // Groups exist now, but the role doesn't + err = da.RoleGroupAdd(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + for _, groupname := range groupnames { + err = da.RoleGroupAdd(ctx, rolename, groupname) + assert.NoError(t, err) + } + + for _, groupname := range groupnames { + exists, _ := da.RoleGroupExists(ctx, rolename, groupname) + assert.True(t, exists, groupname) + } +} + +func testRoleGroupDelete(t *testing.T) { + +} + +func testRoleGroupExists(t *testing.T) { + var err error + + rolename := "role-test-role-group-exists" + groupnames := []string{ + "group-test-role-group-exists-0", + "group-test-role-group-exists-1", + } + groupnull := "group-test-role-group-exists-null" + + // No such role yet + _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + // Groups exist now, but the role doesn't + _, err = da.RoleGroupExists(ctx, rolename, groupnames[1]) + assert.ErrorIs(t, err, errs.ErrNoSuchGroup) + + da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(ctx, groupnames[0]) + da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(ctx, groupnames[1]) + da.GroupCreate(ctx, rest.Group{Name: groupnull}) + defer da.GroupDelete(ctx, groupnull) + + for _, groupname := range groupnames { + da.RoleGroupAdd(ctx, rolename, groupname) + } + + for _, groupname := range groupnames { + exists, err := da.RoleGroupExists(ctx, rolename, groupname) + assert.NoError(t, err) + assert.True(t, exists) + } + + // Null group should NOT exist on the role + exists, err := da.RoleGroupExists(ctx, rolename, groupnull) + assert.NoError(t, err) + assert.False(t, exists) +} + +func testRoleGroupList(t *testing.T) { + var err error + + rolename := "role-test-role-group-list" + groupnames := []string{ + "group-test-role-group-list-0", + "group-test-role-group-list-1", + } + groupnull := "group-test-role-group-list-null" + + // No such role yet + _, err = da.RoleGroupList(ctx, rolename) + assert.ErrorIs(t, err, errs.ErrNoSuchRole) + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + // Groups exist now, but the role doesn't + groups, err := da.RoleGroupList(ctx, rolename) + assert.NoError(t, err) + assert.Empty(t, groups) + + da.GroupCreate(ctx, rest.Group{Name: groupnames[1]}) + defer da.GroupDelete(ctx, groupnames[1]) + da.GroupCreate(ctx, rest.Group{Name: groupnames[0]}) + defer da.GroupDelete(ctx, groupnames[0]) + da.GroupCreate(ctx, rest.Group{Name: groupnull}) + defer da.GroupDelete(ctx, groupnull) + + for _, groupname := range groupnames { + da.RoleGroupAdd(ctx, rolename, groupname) + } + + // Currently the groups are NOT expected to be fully described (i.e., + // their roles slices don't have to be complete). + groups, err = da.RoleGroupList(ctx, rolename) + assert.NoError(t, err) + assert.Len(t, groups, 2) + + for i, g := range groups { + assert.Equal(t, groupnames[i], g.Name) + } +} + +func testRolePermissionAdd(t *testing.T) { + var exists bool + var err error + + const rolename = "role-test-role-permission-add" + const bundlename = "test" + const permname1 = "perm-test-role-permission-add-0" + const permname2 = "perm-test-role-permission-add-1" + + da.RoleCreate(ctx, rolename) + defer da.RoleDelete(ctx, rolename) + + role, _ := da.RoleGet(ctx, rolename) + if !assert.Len(t, role.Permissions, 0) { + t.FailNow() + } + + // First permission + err = da.RolePermissionAdd(ctx, rolename, bundlename, permname1) + if !assert.NoError(t, err) { + t.FailNow() + } + defer da.RolePermissionDelete(ctx, rolename, bundlename, permname1) + + role, _ = da.RoleGet(ctx, rolename) + if !assert.Len(t, role.Permissions, 1) { + t.FailNow() + } + + exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname1) + if !assert.True(t, exists) { + t.FailNow() + } + + // Second permission + err = da.RolePermissionAdd(ctx, rolename, bundlename, permname2) + if !assert.NoError(t, err) { + t.FailNow() + } + defer da.RolePermissionDelete(ctx, rolename, bundlename, permname2) + + role, _ = da.RoleGet(ctx, rolename) + if !assert.Len(t, role.Permissions, 2) { + t.FailNow() + } + + exists, _ = da.RolePermissionExists(ctx, rolename, bundlename, permname2) + if !assert.True(t, exists) { + t.FailNow() + } +} + +func testRolePermissionExists(t *testing.T) { var err error da.RoleCreate(ctx, "role-test-role-has-permission") defer da.RoleDelete(ctx, "role-test-role-has-permission") - has, err := da.RoleHasPermission(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + has, err := da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") if !assert.NoError(t, err) || !assert.False(t, has) { t.FailNow() } @@ -181,12 +366,12 @@ func testRoleHasPermission(t *testing.T) { } defer da.RolePermissionDelete(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") - has, err = da.RoleHasPermission(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") + has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-1") if !assert.NoError(t, err) || !assert.True(t, has) { t.FailNow() } - has, err = da.RoleHasPermission(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-2") + has, err = da.RolePermissionExists(ctx, "role-test-role-has-permission", "test", "permission-test-role-has-permission-2") if !assert.NoError(t, err) || !assert.False(t, has) { t.FailNow() } @@ -217,7 +402,7 @@ func testRolePermissionList(t *testing.T) { defer da.RolePermissionDelete(ctx, "role-test-role-permission-list", "test", "permission-test-role-permission-list-2") // Expect a sorted list! - expect := []rest.RolePermission{ + expect := rest.RolePermissionList{ {BundleName: "test", Permission: "permission-test-role-permission-list-1"}, {BundleName: "test", Permission: "permission-test-role-permission-list-2"}, {BundleName: "test", Permission: "permission-test-role-permission-list-3"}, diff --git a/dataaccess/postgres/user-access.go b/dataaccess/postgres/user-access.go index c5f3180..ba1e531 100644 --- a/dataaccess/postgres/user-access.go +++ b/dataaccess/postgres/user-access.go @@ -240,139 +240,6 @@ func (da PostgresDataAccess) UserGetByEmail(ctx context.Context, email string) ( return user, err } -// UserList returns a list of all known users in the datastore. -// Passwords are not included. Nice try. -func (da PostgresDataAccess) UserList(ctx context.Context) ([]rest.User, error) { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.UserList") - defer sp.End() - - db, err := da.connect(ctx, DatabaseGort) - if err != nil { - return nil, err - } - defer db.Close() - - query := `SELECT email, full_name, username FROM users` - rows, err := db.QueryContext(ctx, query) - if err != nil { - return nil, err - } - defer rows.Close() - - users := make([]rest.User, 0) - for rows.Next() { - user := rest.User{} - err = rows.Scan(&user.Email, &user.FullName, &user.Username) - if err != nil { - err = gerr.Wrap(errs.ErrNoSuchUser, err) - } - users = append(users, user) - } - - return users, err -} - -// UserPermissions returns an alphabetically-sorted list of fully-qualified -// (i.e., "bundle:permission") permissions available to the specified user. -func (da PostgresDataAccess) UserPermissions(ctx context.Context, username string) ([]string, error) { - // TODO This is horribly inefficient -- use a real SQL query instead! - - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.UserPermissions") - defer sp.End() - - pp := []string{} - - groups, err := da.UserGroupList(ctx, username) - if err != nil { - return nil, err - } - - for _, group := range groups { - roles, err := da.GroupListRoles(ctx, group.Name) - if err != nil { - return nil, err - } - - for _, role := range roles { - for _, p := range role.Permissions { - pp = append(pp, p.BundleName+":"+p.Permission) - } - } - } - - sort.Strings(pp) - - return pp, nil -} - -// UserUpdate is used to update an existing user. An error is returned if the -// username is empty or if the user doesn't exist. -func (da PostgresDataAccess) UserUpdate(ctx context.Context, user rest.User) error { - tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) - ctx, sp := tr.Start(ctx, "postgres.UserUpdate") - defer sp.End() - - if user.Username == "" { - return errs.ErrEmptyUserName - } - - exists, err := da.UserExists(ctx, user.Username) - if err != nil { - return err - } - if !exists { - return errs.ErrNoSuchUser - } - - db, err := da.connect(ctx, DatabaseGort) - if err != nil { - return err - } - defer db.Close() - - query := `SELECT email, full_name, username, password_hash - FROM users - WHERE username=$1` - - userOld := rest.User{} - err = db. - QueryRowContext(ctx, query, user.Username). - Scan(&userOld.Email, &userOld.FullName, &userOld.Username, &userOld.Password) - - if err != nil { - return gerr.Wrap(errs.ErrNoSuchUser, err) - } - - if user.Email != "" { - userOld.Email = user.Email - } - - if user.FullName != "" { - userOld.FullName = user.FullName - } - - if user.Password != "" { - userOld.Password, err = data.HashPassword(user.Password) - if err != nil { - return err - } - } - - query = `UPDATE users - SET email=$1, full_name=$2, password_hash=$3 - WHERE username=$4;` - - _, err = db.ExecContext(ctx, query, userOld.Email, userOld.FullName, userOld.Password, userOld.Username) - - if err != nil { - err = gerr.Wrap(errs.ErrDataAccess, err) - } - - return err -} - // UserGroupList returns a slice of Group values representing the specified user's group memberships. // The groups' Users slice is never populated, and is always nil. func (da PostgresDataAccess) UserGroupList(ctx context.Context, username string) ([]rest.Group, error) { @@ -501,3 +368,174 @@ func (da PostgresDataAccess) UserGroupDelete(ctx context.Context, username strin return err } + +// UserList returns a list of all known users in the datastore. +// Passwords are not included. Nice try. +func (da PostgresDataAccess) UserList(ctx context.Context) ([]rest.User, error) { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.UserList") + defer sp.End() + + db, err := da.connect(ctx, DatabaseGort) + if err != nil { + return nil, err + } + defer db.Close() + + query := `SELECT email, full_name, username FROM users` + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + users := make([]rest.User, 0) + for rows.Next() { + user := rest.User{} + err = rows.Scan(&user.Email, &user.FullName, &user.Username) + if err != nil { + err = gerr.Wrap(errs.ErrNoSuchUser, err) + } + users = append(users, user) + } + + return users, err +} + +// UserPermissionList returns an alphabetically-sorted list of permissions +// available to the specified user. +func (da PostgresDataAccess) UserPermissionList(ctx context.Context, username string) (rest.RolePermissionList, error) { + // TODO This is HORRIBLY inefficient -- use a real SQL query instead! + + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.UserPermissionList") + defer sp.End() + + pp := []rest.RolePermission{} + + groups, err := da.UserGroupList(ctx, username) + if err != nil { + return nil, err + } + + for _, group := range groups { + roles, err := da.GroupRoleList(ctx, group.Name) + if err != nil { + return nil, err + } + + for _, role := range roles { + rp, err := da.RolePermissionList(ctx, role.Name) + if err != nil { + return nil, err + } + + for _, p := range rp { + pp = append(pp, p) + } + } + } + + sort.Slice(pp, func(i, j int) bool { return pp[i].String() < pp[j].String() }) + + return pp, nil +} + +// UserRoleList returns a slice of Role values representing the specified +// user's indirect roles (indirect because users are members of groups, +// and groups have roles). +func (da PostgresDataAccess) UserRoleList(ctx context.Context, username string) ([]rest.Role, error) { + rm := map[string]rest.Role{} + + groups, err := da.UserGroupList(ctx, username) + if err != nil { + return []rest.Role{}, err + } + + for _, gr := range groups { + rl, err := da.GroupRoleList(ctx, gr.Name) + if err != nil { + return []rest.Role{}, err + } + + for _, r := range rl { + rm[r.Name] = r + } + } + + roles := []rest.Role{} + + for _, r := range rm { + roles = append(roles, r) + } + + sort.Slice(roles, func(i, j int) bool { return roles[i].Name < roles[j].Name }) + + return roles, nil +} + +// UserUpdate is used to update an existing user. An error is returned if the +// username is empty or if the user doesn't exist. +func (da PostgresDataAccess) UserUpdate(ctx context.Context, user rest.User) error { + tr := otel.GetTracerProvider().Tracer(telemetry.ServiceName) + ctx, sp := tr.Start(ctx, "postgres.UserUpdate") + defer sp.End() + + if user.Username == "" { + return errs.ErrEmptyUserName + } + + exists, err := da.UserExists(ctx, user.Username) + if err != nil { + return err + } + if !exists { + return errs.ErrNoSuchUser + } + + db, err := da.connect(ctx, DatabaseGort) + if err != nil { + return err + } + defer db.Close() + + query := `SELECT email, full_name, username, password_hash + FROM users + WHERE username=$1` + + userOld := rest.User{} + err = db. + QueryRowContext(ctx, query, user.Username). + Scan(&userOld.Email, &userOld.FullName, &userOld.Username, &userOld.Password) + + if err != nil { + return gerr.Wrap(errs.ErrNoSuchUser, err) + } + + if user.Email != "" { + userOld.Email = user.Email + } + + if user.FullName != "" { + userOld.FullName = user.FullName + } + + if user.Password != "" { + userOld.Password, err = data.HashPassword(user.Password) + if err != nil { + return err + } + } + + query = `UPDATE users + SET email=$1, full_name=$2, password_hash=$3 + WHERE username=$4;` + + _, err = db.ExecContext(ctx, query, userOld.Email, userOld.FullName, userOld.Password, userOld.Username) + + if err != nil { + err = gerr.Wrap(errs.ErrDataAccess, err) + } + + return err +} diff --git a/dataaccess/postgres/user-access_test.go b/dataaccess/postgres/user-access_test.go index b99960e..4e2cabc 100644 --- a/dataaccess/postgres/user-access_test.go +++ b/dataaccess/postgres/user-access_test.go @@ -33,7 +33,7 @@ func testUserAccess(t *testing.T) { t.Run("testUserGroupList", testUserGroupList) t.Run("testUserList", testUserList) t.Run("testUserNotExists", testUserNotExists) - t.Run("testUserPermissions", testUserPermissions) + t.Run("testUserPermissionList", testUserPermissionList) t.Run("testUserUpdate", testUserUpdate) } @@ -179,7 +179,7 @@ func testUserGroupList(t *testing.T) { da.UserCreate(ctx, rest.User{Username: "user-test-user-group-list"}) defer da.UserDelete(ctx, "user-test-user-group-list") - da.GroupAddUser(ctx, "group-test-user-group-list-0", "user-test-user-group-list") + da.GroupUserAdd(ctx, "group-test-user-group-list-0", "user-test-user-group-list") expected := []rest.Group{{Name: "group-test-user-group-list-0", Users: nil}} @@ -238,7 +238,8 @@ func testUserNotExists(t *testing.T) { t.FailNow() } } -func testUserPermissions(t *testing.T) { + +func testUserPermissionList(t *testing.T) { var err error err = da.GroupCreate(ctx, rest.Group{Name: "test-perms"}) @@ -252,7 +253,7 @@ func testUserPermissions(t *testing.T) { if !assert.NoError(t, err) { t.FailNow() } - err = da.GroupAddUser(ctx, "test-perms", "test-perms") + err = da.GroupUserAdd(ctx, "test-perms", "test-perms") if !assert.NoError(t, err) { t.FailNow() } @@ -283,12 +284,12 @@ func testUserPermissions(t *testing.T) { // Expected: a sorted list of strings expected := []string{"test:test-perms-0", "test:test-perms-1", "test:test-perms-2"} - actual, err := da.UserPermissions(ctx, "test-perms") + actual, err := da.UserPermissionList(ctx, "test-perms") if !assert.NoError(t, err) { t.FailNow() } - assert.Equal(t, expected, actual) + assert.Equal(t, expected, actual.Strings()) } func testUserUpdate(t *testing.T) { @@ -307,7 +308,7 @@ func testUserUpdate(t *testing.T) { // Get the user we just added. Emails should match. user1, _ := da.UserGet(ctx, "test-update") if userA.Email != user1.Email { - t.Errorf("Email mistatch: %q vs %q", userA.Email, user1.Email) + t.Errorf("Email mismatch: %q vs %q", userA.Email, user1.Email) t.FailNow() } @@ -319,7 +320,7 @@ func testUserUpdate(t *testing.T) { // Get the user we just updated. Emails should match. user2, _ := da.UserGet(ctx, "test-update") if userB.Email != user2.Email { - t.Errorf("Email mistatch: %q vs %q", userB.Email, user2.Email) + t.Errorf("Email mismatch: %q vs %q", userB.Email, user2.Email) t.FailNow() } } From 43315fca94825cc181c54968c9e8c10e4443b433 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Fri, 9 Jul 2021 14:34:33 -0400 Subject: [PATCH 04/21] Updating function references --- adapter/adapter.go | 4 ++-- service/bundle-handlers.go | 2 +- service/group-handlers.go | 6 +++--- service/service.go | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/adapter/adapter.go b/adapter/adapter.go index 73ddf45..edf4238 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -453,7 +453,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity "arg": cmdInput.Parameters, } - perms, err := da.UserPermissions(ctx, id.GortUser.Username) + perms, err := da.UserPermissionList(ctx, id.GortUser.Username) if err != nil { da.RequestError(ctx, request, err) telemetry.Errors().WithError(err).Commit(ctx) @@ -463,7 +463,7 @@ func TriggerCommand(ctx context.Context, rawCommand string, id RequestorIdentity return nil, fmt.Errorf("user permission load error: %w", err) } - allowed, err := auth.EvaluateCommandEntry(perms, cmdEntry, env) + allowed, err := auth.EvaluateCommandEntry(perms.Strings(), cmdEntry, env) if err != nil { da.RequestError(ctx, request, err) telemetry.Errors().WithError(err).Commit(ctx) diff --git a/service/bundle-handlers.go b/service/bundle-handlers.go index 24a3504..6f2f9c5 100644 --- a/service/bundle-handlers.go +++ b/service/bundle-handlers.go @@ -59,7 +59,7 @@ func handleGetBundleVersions(w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) name := params["name"] - bundles, err := dataAccessLayer.BundleListVersions(r.Context(), name) + bundles, err := dataAccessLayer.BundleVersionList(r.Context(), name) if err != nil { respondAndLogError(r.Context(), w, err) return diff --git a/service/group-handlers.go b/service/group-handlers.go index 4ae2093..f776cf5 100644 --- a/service/group-handlers.go +++ b/service/group-handlers.go @@ -67,7 +67,7 @@ func handleDeleteGroupMember(w http.ResponseWriter, r *http.Request) { return } - err = dataAccessLayer.GroupRemoveUser(r.Context(), groupname, username) + err = dataAccessLayer.GroupUserDelete(r.Context(), groupname, username) if err != nil { respondAndLogError(r.Context(), w, err) } @@ -182,7 +182,7 @@ func handleGetGroupRoles(w http.ResponseWriter, r *http.Request) { return } - roles, err := dataAccessLayer.GroupListRoles(r.Context(), groupname) + roles, err := dataAccessLayer.GroupRoleList(r.Context(), groupname) if err != nil { respondAndLogError(r.Context(), w, err) return @@ -253,7 +253,7 @@ func handlePutGroupMember(w http.ResponseWriter, r *http.Request) { return } - err = dataAccessLayer.GroupAddUser(r.Context(), groupname, username) + err = dataAccessLayer.GroupUserAdd(r.Context(), groupname, username) if err != nil { respondAndLogError(r.Context(), w, err) } diff --git a/service/service.go b/service/service.go index 8a6ee97..b32c91c 100644 --- a/service/service.go +++ b/service/service.go @@ -317,7 +317,7 @@ func doBootstrap(ctx context.Context, user rest.User) (rest.User, error) { } // Add the admin user to the admin group. - err = dataAccessLayer.GroupAddUser(ctx, adminGroup, user.Username) + err = dataAccessLayer.GroupUserAdd(ctx, adminGroup, user.Username) if err != nil { return user, err } @@ -589,7 +589,7 @@ func doAuthenticateUser(r *http.Request, gortCommand string, args ...string) (bo return false, err } - perms, err := dataAccessLayer.UserPermissions(r.Context(), token.User) + perms, err := dataAccessLayer.UserPermissionList(r.Context(), token.User) if err != nil { return false, err } @@ -609,7 +609,7 @@ func doAuthenticateUser(r *http.Request, gortCommand string, args ...string) (bo env := rules.EvaluationEnvironment{"arg": argValues} - return auth.EvaluateCommandEntry(perms, ce, env) + return auth.EvaluateCommandEntry(perms.Strings(), ce, env) } // getGortBundleCommand retrieves the data.BundleCommand value from the default From 8ff4090d49f4d6208da6bc1715433bc8a9ddace6 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Fri, 9 Jul 2021 15:25:19 -0400 Subject: [PATCH 05/21] Implement gort role info command --- cli/role-info.go | 81 ++++++++++++++++++++++++++++++++++++++++ cli/role.go | 1 + client/client-group.go | 25 +++++++------ client/client-role.go | 60 +++++++++++++++++++++-------- client/client-user.go | 38 ++++++++++++++++--- data/bundle.go | 2 +- data/rest/role-data.go | 2 +- service/role-handlers.go | 77 ++++++++++++++++++++++++++++++++++++-- service/service.go | 2 + service/user-handlers.go | 16 ++++++++ 10 files changed, 265 insertions(+), 39 deletions(-) create mode 100644 cli/role-info.go diff --git a/cli/role-info.go b/cli/role-info.go new file mode 100644 index 0000000..95f961b --- /dev/null +++ b/cli/role-info.go @@ -0,0 +1,81 @@ +/* + * Copyright 2021 The Gort Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cli + +import ( + "fmt" + "strings" + + "github.com/getgort/gort/client" + "github.com/spf13/cobra" +) + +const ( + roleInfoUse = "info" + roleInfoShort = "Retrieve information about an existing role" + roleInfoLong = "Retrieve information about an existing role." + roleInfoUsage = `Usage: + gort role info [flags] role_name [version] + +Flags: + -h, --help Show this message and exit + +Global Flags: + -P, --profile string The Gort profile within the config file to use +` +) + +// GetRoleInfoCmd is a command +func GetRoleInfoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: roleInfoUse, + Short: roleInfoShort, + Long: roleInfoLong, + RunE: roleInfoCmd, + Args: cobra.ExactArgs(1), + } + + cmd.SetUsageTemplate(roleInfoUsage) + + return cmd +} + +func roleInfoCmd(cmd *cobra.Command, args []string) error { + gortClient, err := client.Connect(FlagGortProfile) + if err != nil { + return err + } + + rolename := args[0] + + role, err := gortClient.RoleGet(rolename) + if err != nil { + return err + } + + const format = `Name %s +Permissions %s +Groups %s +` + + fmt.Printf(format, + role.Name, + strings.Join(role.Permissions.Strings(), ", "), + strings.Join(groupNames(role.Groups), ", ")) + + return nil +} diff --git a/cli/role.go b/cli/role.go index add73f2..a53c77d 100644 --- a/cli/role.go +++ b/cli/role.go @@ -54,6 +54,7 @@ func GetRoleCmd() *cobra.Command { cmd.AddCommand(GetRoleCreateCmd()) cmd.AddCommand(GetRoleDeleteCmd()) cmd.AddCommand(GetRoleGrantCmd()) + cmd.AddCommand(GetRoleInfoCmd()) cmd.AddCommand(GetRoleListCmd()) cmd.AddCommand(GetRoleRevokeCmd()) diff --git a/client/client-group.go b/client/client-group.go index c120ede..2644189 100644 --- a/client/client-group.go +++ b/client/client-group.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/http" "github.com/getgort/gort/data/rest" ) @@ -34,7 +35,7 @@ func (c *GortClient) GroupDelete(groupname string) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -52,9 +53,9 @@ func (c *GortClient) GroupExists(groupname string) (bool, error) { defer resp.Body.Close() switch resp.StatusCode { - case 200: + case http.StatusOK: return true, nil - case 404: + case http.StatusNotFound: return false, nil default: return false, getResponseError(resp) @@ -70,7 +71,7 @@ func (c *GortClient) GroupGet(groupname string) (rest.Group, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return rest.Group{}, getResponseError(resp) } @@ -97,7 +98,7 @@ func (c *GortClient) GroupList() ([]rest.Group, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return []rest.Group{}, getResponseError(resp) } @@ -124,7 +125,7 @@ func (c *GortClient) GroupMemberAdd(groupname string, username string) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -140,7 +141,7 @@ func (c *GortClient) GroupMemberDelete(groupname string, username string) error } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -156,7 +157,7 @@ func (c *GortClient) GroupMemberList(groupname string) ([]rest.User, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return []rest.User{}, getResponseError(resp) } @@ -189,7 +190,7 @@ func (c *GortClient) GroupSave(group rest.Group) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -205,7 +206,7 @@ func (c *GortClient) GroupRoleAdd(groupname string, rolename string) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -221,7 +222,7 @@ func (c *GortClient) GroupRoleDelete(groupname string, rolename string) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -237,7 +238,7 @@ func (c *GortClient) GroupRoleList(groupname string) ([]rest.Role, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return []rest.Role{}, getResponseError(resp) } diff --git a/client/client-role.go b/client/client-role.go index edfd5ec..657f938 100644 --- a/client/client-role.go +++ b/client/client-role.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/http" "github.com/getgort/gort/data/rest" ) @@ -34,7 +35,7 @@ func (c *GortClient) RoleDelete(rolename string) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -51,7 +52,7 @@ func (c *GortClient) RoleCreate(rolename string) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -67,7 +68,7 @@ func (c *GortClient) RoleList() ([]rest.Group, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return []rest.Group{}, getResponseError(resp) } @@ -96,9 +97,9 @@ func (c *GortClient) RoleExists(rolename string) (bool, error) { defer resp.Body.Close() switch resp.StatusCode { - case 200: + case http.StatusOK: return true, nil - case 404: + case http.StatusNotFound: return false, nil default: return false, getResponseError(resp) @@ -106,30 +107,57 @@ func (c *GortClient) RoleExists(rolename string) (bool, error) { } // RoleGet gets an existing role. -func (c *GortClient) RoleGet(rolename string) (rest.Group, error) { +func (c *GortClient) RoleGet(rolename string) (rest.Role, error) { url := fmt.Sprintf("%s/v2/roles/%s", c.profile.URL.String(), rolename) resp, err := c.doRequest("GET", url, []byte{}) if err != nil { - return rest.Group{}, err + return rest.Role{}, err } defer resp.Body.Close() - if resp.StatusCode != 200 { - return rest.Group{}, getResponseError(resp) + if resp.StatusCode != http.StatusOK { + return rest.Role{}, getResponseError(resp) } body, err := ioutil.ReadAll(resp.Body) if err != nil { - return rest.Group{}, err + return rest.Role{}, err } - group := rest.Group{} - err = json.Unmarshal(body, &group) + role := rest.Role{} + err = json.Unmarshal(body, &role) if err != nil { - return rest.Group{}, err + return rest.Role{}, err } - return group, nil + return role, nil +} + +// RolePermissionList comments to be written... +func (c *GortClient) RolePermissionList(username string) (rest.RolePermissionList, error) { + url := fmt.Sprintf("%s/v2/roles/%s/permissions", c.profile.URL.String(), username) + resp, err := c.doRequest("GET", url, []byte{}) + if err != nil { + return rest.RolePermissionList{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return rest.RolePermissionList{}, getResponseError(resp) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return rest.RolePermissionList{}, err + } + + rpl := rest.RolePermissionList{} + err = json.Unmarshal(body, &rpl) + if err != nil { + return rest.RolePermissionList{}, err + } + + return rpl, nil } // RolePermissionRevoke revokes an existing permission from a role @@ -142,7 +170,7 @@ func (c *GortClient) RolePermissionRevoke(rolename string, bundlename string, pe } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -159,7 +187,7 @@ func (c *GortClient) RolePermissionGrant(rolename string, bundlename string, per } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } diff --git a/client/client-user.go b/client/client-user.go index fd86cb7..f62dedc 100644 --- a/client/client-user.go +++ b/client/client-user.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/http" "github.com/getgort/gort/data/rest" ) @@ -34,7 +35,7 @@ func (c *GortClient) UserDelete(username string) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } @@ -70,7 +71,7 @@ func (c *GortClient) UserGet(username string) (rest.User, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return rest.User{}, getResponseError(resp) } @@ -97,7 +98,7 @@ func (c *GortClient) UserGroupList(username string) ([]rest.Group, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return []rest.Group{}, getResponseError(resp) } @@ -124,7 +125,7 @@ func (c *GortClient) UserList() ([]rest.User, error) { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return []rest.User{}, getResponseError(resp) } @@ -142,6 +143,33 @@ func (c *GortClient) UserList() ([]rest.User, error) { return users, nil } +// UserPermissionList comments to be written... +func (c *GortClient) UserPermissionList(username string) (rest.RolePermissionList, error) { + url := fmt.Sprintf("%s/v2/users/%s/permissions", c.profile.URL.String(), username) + resp, err := c.doRequest("GET", url, []byte{}) + if err != nil { + return rest.RolePermissionList{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return rest.RolePermissionList{}, getResponseError(resp) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return rest.RolePermissionList{}, err + } + + rpl := rest.RolePermissionList{} + err = json.Unmarshal(body, &rpl) + if err != nil { + return rest.RolePermissionList{}, err + } + + return rpl, nil +} + // UserSave will create or update a user. Note the the key is the username: if // this is called with a user whose username exists that user is updated // (empty fields will not be overwritten); otherwise a new user is created. @@ -159,7 +187,7 @@ func (c *GortClient) UserSave(user rest.User) error { } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return getResponseError(resp) } diff --git a/data/bundle.go b/data/bundle.go index d5d0a55..767deec 100644 --- a/data/bundle.go +++ b/data/bundle.go @@ -60,7 +60,7 @@ type BundleDocker struct { // section of the config. type BundleCommand struct { Description string `yaml:",omitempty" json:"description,omitempty"` - Executable []string `yaml:",omitempty" json:"executable,omitempty"` + Executable []string `yaml:",omitempty,flow" json:"executable,omitempty"` Name string `yaml:"-" json:"-"` Rules []string `yaml:",omitempty" json:"rules,omitempty"` } diff --git a/data/rest/role-data.go b/data/rest/role-data.go index 34b54ec..340bc06 100644 --- a/data/rest/role-data.go +++ b/data/rest/role-data.go @@ -20,7 +20,7 @@ import "fmt" type Role struct { Name string - Permissions []RolePermission + Permissions RolePermissionList Groups []Group } diff --git a/service/role-handlers.go b/service/role-handlers.go index 6f7c48b..9532376 100644 --- a/service/role-handlers.go +++ b/service/role-handlers.go @@ -19,7 +19,9 @@ package service import ( "encoding/json" "net/http" + "sync" + "github.com/getgort/gort/data/rest" "github.com/gorilla/mux" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -37,7 +39,6 @@ func handleDeleteRole(w http.ResponseWriter, r *http.Request) { // handleGetRoles handles "GET /v2/roles" func handleGetRoles(w http.ResponseWriter, r *http.Request) { - roles, err := dataAccessLayer.RoleList(r.Context()) if err != nil { @@ -63,13 +64,80 @@ func handleGetRole(w http.ResponseWriter, r *http.Request) { return } - role, err := dataAccessLayer.RoleGet(r.Context(), rolename) + var errs chan error = make(chan error, 3) + var role rest.Role + var perms rest.RolePermissionList + var groups []rest.Group + + wg := sync.WaitGroup{} + wg.Add(3) + + go func() { + o, err := dataAccessLayer.RoleGet(r.Context(), rolename) + if err != nil { + errs <- err + } + + role = o + wg.Done() + }() + + go func() { + o, err := dataAccessLayer.RolePermissionList(r.Context(), rolename) + if err != nil { + errs <- err + } + + perms = o + wg.Done() + }() + + go func() { + o, err := dataAccessLayer.RoleGroupList(r.Context(), rolename) + if err != nil { + errs <- err + } + + groups = o + wg.Done() + }() + + wg.Wait() + + close(errs) + + if err := <-errs; err != nil { + respondAndLogError(r.Context(), w, err) + } + + role.Permissions = perms + role.Groups = groups + + json.NewEncoder(w).Encode(role) +} + +// handleGetRole handles "GET /v2/roles/{rolename}" +func handleGetRolePermissions(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + rolename := params["rolename"] + + exists, err := dataAccessLayer.RoleExists(r.Context(), rolename) + if err != nil { + respondAndLogError(r.Context(), w, err) + return + } + if !exists { + http.Error(w, "no such role", http.StatusNotFound) + return + } + + rpl, err := dataAccessLayer.RolePermissionList(r.Context(), rolename) if err != nil { respondAndLogError(r.Context(), w, err) return } - json.NewEncoder(w).Encode(role) + json.NewEncoder(w).Encode(rpl) } // handleGrantRolePermission handles "PUT /v2/roles/{rolename}/bundles/{bundlename}/permissions/{permissionname}" @@ -135,11 +203,12 @@ func handlePutRole(w http.ResponseWriter, r *http.Request) { func addRoleMethodsToRouter(router *mux.Router) { // Basic role methods router.Handle("/v2/roles", otelhttp.NewHandler(authCommand(handleGetRoles, "role", "list"), "handleGetRoles")).Methods("GET") - router.Handle("/v2/roles/{rolename}", otelhttp.NewHandler(authCommand(handleGetRole, "role", "infp"), "handleGetRole")).Methods("GET") + router.Handle("/v2/roles/{rolename}", otelhttp.NewHandler(authCommand(handleGetRole, "role", "info"), "handleGetRole")).Methods("GET") router.Handle("/v2/roles/{rolename}", otelhttp.NewHandler(authCommand(handlePutRole, "role", "create"), "handlePutRole")).Methods("PUT") router.Handle("/v2/roles/{rolename}", otelhttp.NewHandler(authCommand(handleDeleteRole, "role", "delete"), "handleDeleteRole")).Methods("DELETE") // Role permissions + router.Handle("/v2/roles/{rolename}/permissions", otelhttp.NewHandler(authCommand(handleGetRolePermissions, "role", "info"), "handleGetRolePermissions")).Methods("GET") router.Handle("/v2/roles/{rolename}/bundles/{bundlename}/permissions/{permissionname}", otelhttp.NewHandler(authCommand(handleRevokeRolePermission, "role", "revoke-permission"), "handleDeleteRolePermission")).Methods("DELETE") router.Handle("/v2/roles/{rolename}/bundles/{bundlename}/permissions/{permissionname}", otelhttp.NewHandler(authCommand(handleGrantRolePermission, "role", "grant-permission"), "handlePutRolePermission")).Methods("PUT") } diff --git a/service/service.go b/service/service.go index b32c91c..71171c5 100644 --- a/service/service.go +++ b/service/service.go @@ -443,6 +443,8 @@ func respondAndLogError(ctx context.Context, w http.ResponseWriter, err error) { fallthrough case gerrs.Is(err, errs.ErrNoSuchGroup): fallthrough + case gerrs.Is(err, errs.ErrNoSuchRole): + fallthrough case gerrs.Is(err, errs.ErrNoSuchToken): fallthrough case gerrs.Is(err, errs.ErrNoSuchUser): diff --git a/service/user-handlers.go b/service/user-handlers.go index dce3e9f..5d7156a 100644 --- a/service/user-handlers.go +++ b/service/user-handlers.go @@ -100,6 +100,19 @@ func handleGetUsers(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(users) } +// handleGetUserPermissions handles "GET /v2/users/{username}/groups" +func handleGetUserPermissions(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + + perms, err := dataAccessLayer.UserPermissionList(r.Context(), params["username"]) + if err != nil { + respondAndLogError(r.Context(), w, err) + return + } + + json.NewEncoder(w).Encode(perms) +} + // handlePutUser handles "POST /v2/users/{username}" func handlePutUser(w http.ResponseWriter, r *http.Request) { var user rest.User @@ -148,4 +161,7 @@ func addUserMethodsToRouter(router *mux.Router) { router.Handle("/v2/users/{username}/groups", otelhttp.NewHandler(authCommand(handleGetUserGroups, "user", "info"), "handleGetUserGroups")).Methods("GET") router.Handle("/v2/users/{username}/groups/{username}", otelhttp.NewHandler(authCommand(handleDeleteUserGroup, "user", "update"), "handleDeleteUserGroup")).Methods("DELETE") router.Handle("/v2/users/{username}/groups/{username}", otelhttp.NewHandler(authCommand(handlePutUserGroup, "user", "update"), "handlePutUserGroup")).Methods("PUT") + + // User permissions list + router.Handle("/v2/users/{username}/permissions", otelhttp.NewHandler(authCommand(handleGetUserPermissions, "user", "info"), "handleGetUserPermissions")).Methods("GET") } From 19b540a6896f93c542c218e529888f996b25121c Mon Sep 17 00:00:00 2001 From: Matt Titmus Date: Fri, 9 Jul 2021 15:28:49 -0400 Subject: [PATCH 06/21] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 979039c..5c4c3b4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Gort is a chatbot framework designed from the ground up for chatops. Gort brings the power of the command line to the place you collaborate with your team all the time -- your chat window. Its open-ended command bundle support allows developers to implement functionality in the language of their choice, while powerful access control means you can collaborate around even the most sensitive tasks with confidence. A focus on extensibility and adaptability means that you can respond quickly to the unexpected, without your team losing visibility. +## Documentation + +You may wish to skip this page and go directly to the documentation: [The Gort Guide](http://guide.getgort.io/). + ## Rationale Gort was initially conceived of as a Go re-implementation of Operable's [Cog Slack Bot](https://github.com/operable/cog), and while it remains heavily inspired by Cog, Gort has largely gone its own way. From e09bb58a446beff4b9e9de4ab9b6dcf34457d792 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Fri, 9 Jul 2021 15:45:47 -0400 Subject: [PATCH 07/21] Simplify range --- cli/hidden-command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/hidden-command.go b/cli/hidden-command.go index d81104c..23bfff1 100644 --- a/cli/hidden-command.go +++ b/cli/hidden-command.go @@ -69,7 +69,7 @@ func hiddenCommandCmd(cmd *cobra.Command, args []string) error { cmds := []string{} for _, b := range bundles { - for k, _ := range b.Commands { + for k := range b.Commands { cmds = append(cmds, fmt.Sprintf("- %s:%s", b.Name, k)) } } From 1c7560b82ba2b39306f88c87dd21547949d0ad91 Mon Sep 17 00:00:00 2001 From: Matt Titmus Date: Fri, 9 Jul 2021 19:12:41 -0400 Subject: [PATCH 08/21] Update README.md --- README.md | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5c4c3b4..f727d5e 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,6 @@ Gort brings the power of the command line to the place you collaborate with your You may wish to skip this page and go directly to the documentation: [The Gort Guide](http://guide.getgort.io/). -## Rationale - -Gort was initially conceived of as a Go re-implementation of Operable's [Cog Slack Bot](https://github.com/operable/cog), and while it remains heavily inspired by Cog, Gort has largely gone its own way. - -Cog was originally designed as a distributed computation engine that was later re-branded as a chatops tool, and much of this original intent was reflected in its design, implementation, and feature set. As a result, many of Cog’s features, however innovative, went largely unused, and the codebase had become difficult to extend and maintain. - -The solution, which was discussed for many months on the [Cog Slack workspace](https://cogbot.slack.com), was to rewrite Cog from scratch in a more accessible language, such as [Go](http://golang.org), removing some of less-used functionality and reducing complexity in the process. - -This gives us the opportunity to consider and possibly redefine what Cog was meant to be. To choose the features that make sense, and to discard those that don't. In this way, Gort can be described more as a “spiritual successor” to Cog than a faithful re-implementation: many things will change, others will cease to exist entirely. - ## Features The primary goal of this project is to re-implement the core features of Cog that made it stand out among other chatops tools. Specifically, to: @@ -37,13 +27,9 @@ The primary goal of this project is to re-implement the core features of Cog tha This includes all of the [high-level features listed in the Cog documentation](https://web.archive.org/web/20191130061912/http://book.cog.bot/sections/introducing_cog.html#current-featuress). - - -## Gort Design + ## How to Run the Gort Controller From f9ecf94f5aba926f02e4290f64b87fd93db990e4 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Fri, 9 Jul 2021 19:57:17 -0400 Subject: [PATCH 09/21] README refinement --- README.md | 49 +++++++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f727d5e..09a1c92 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Gort is a chatbot framework designed from the ground up for chatops. -Gort brings the power of the command line to the place you collaborate with your team all the time -- your chat window. Its open-ended command bundle support allows developers to implement functionality in the language of their choice, while powerful access control means you can collaborate around even the most sensitive tasks with confidence. A focus on extensibility and adaptability means that you can respond quickly to the unexpected, without your team losing visibility. +Gort brings the power of the command line to the place you collaborate with your team: your chat window. Its open-ended command bundle support allows developers to implement functionality in the language of their choice, while powerful access control means you can collaborate around even the most sensitive tasks with confidence. A focus on extensibility and adaptability means that you can respond quickly to the unexpected, without your team losing visibility. ## Documentation @@ -16,16 +16,17 @@ You may wish to skip this page and go directly to the documentation: [The Gort G ## Features -The primary goal of this project is to re-implement the core features of Cog that made it stand out among other chatops tools. Specifically, to: +Gort's design philosophy emphasizes flexibility and security by allowing you to: -- define arbitrary command functionality in any programming language, -- package those commands into bundles that can be installed in Gort, -- allow users to trigger commands through Slack or another chat provider and be presented with the output, -- execute triggered commands anywhere a relay is installed using a tag-based targeting system, -- regulate the use of commands with a built-in authentication/authorization system, -- and record activity in an audit log. +- Define arbitrary command functionality in any programming language, +- Package those commands into bundles that can be installed in Gort, +- Allow users to trigger commands through Slack or another chat provider and be presented with the output, +- Decide who can use commands (or flags, or parameters) with a built-in authentication/authorization system, and +- Record all activity in an audit log. -This includes all of the [high-level features listed in the Cog documentation](https://web.archive.org/web/20191130061912/http://book.cog.bot/sections/introducing_cog.html#current-featuress). +Gort lets you build commands in any language you want, using tooling you're already comfortable with, and can tightly control who can use them and how. + + diff --git a/images/hello-gort.png b/images/hello-gort.png new file mode 100644 index 0000000000000000000000000000000000000000..25d71e917b2eeca6edcf440bdf963cba2974e985 GIT binary patch literal 48681 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 literal 0 HcmV?d00001 From 743f04c5a8f2c8d9ed5a12dad739edd881099e72 Mon Sep 17 00:00:00 2001 From: Matthew Titmus Date: Tue, 13 Jul 2021 10:43:14 -0400 Subject: [PATCH 19/21] READSME tweak --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 538f667..10cf3ee 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ More information about permissions and rules can be found in the Gort Guide: All command activity is emitted as log events and recorded in [an audit log](https://guide.getgort.io/audit-log-events.html) in the database. +More information about audit logging can be found in the Gort Guide: + +* [Gort Guide: Audit Log Events](https://guide.getgort.io/audit-log-events.html) +