diff --git a/backend/database/seeding/helpers.go b/backend/database/seeding/helpers.go index 072e85b89..fcee24cf7 100644 --- a/backend/database/seeding/helpers.go +++ b/backend/database/seeding/helpers.go @@ -236,6 +236,15 @@ func newUserOpPermission(user models.User, op models.Operation, role policy.Oper } } +func newUserGroupOpPermission(userGroup models.UserGroup, op models.Operation, role policy.OperationRole) models.UserGroupOperationPermission { + return models.UserGroupOperationPermission{ + UserGroupID: userGroup.ID, + OperationID: op.ID, + Role: role, + CreatedAt: internalClock.Now(), + } +} + func newUserOperationPreferences(user models.User, op models.Operation, isFavorite bool) models.UserOperationPreferences { return models.UserOperationPreferences{ UserID: user.ID, @@ -245,6 +254,38 @@ func newUserOperationPreferences(user models.User, op models.Operation, isFavori } } +func newUserGroupGen(first int64) func(name string, deleted bool) models.UserGroup { + id := iotaLike(first) + return func(name string, deleted bool) models.UserGroup { + if deleted { + now := internalClock.Now() + return models.UserGroup{ + ID: id(), + Slug: name, + Name: name, + CreatedAt: internalClock.Now(), + DeletedAt: &now, + } + } else { + return models.UserGroup{ + ID: id(), + Slug: name, + Name: name, + CreatedAt: internalClock.Now(), + } + } + + } +} + +func newUserGroupMapping(user models.User, group models.UserGroup) models.UserGroupMap { + return models.UserGroupMap{ + GroupID: group.ID, + UserID: user.ID, + CreatedAt: internalClock.Now(), + } +} + func newQueryGen(first int64) func(opID int64, name, query, qType string) models.Query { id := iotaLike(first) return func(opID int64, name, query, qType string) models.Query { diff --git a/backend/database/seeding/hp_seed_data.go b/backend/database/seeding/hp_seed_data.go index 98da0cd86..2c50b067d 100644 --- a/backend/database/seeding/hp_seed_data.go +++ b/backend/database/seeding/hp_seed_data.go @@ -20,6 +20,15 @@ var HarryPotterSeedData = Seeder{ Users: []models.User{UserHarry, UserRon, UserGinny, UserHermione, UserNeville, UserSeamus, UserDraco, UserSnape, UserDumbledore, UserHagrid, UserTomRiddle, UserHeadlessNick, UserCedric, UserFleur, UserViktor, UserAlastor, UserMinerva, UserLucius, UserSirius, UserPeter, UserParvati, UserPadma, UserCho, }, + UserGroups: []models.UserGroup{ + UserGroupGryffindor, UserGroupHufflepuff, UserGroupRavenclaw, UserGroupSlytherin, UserGroupOtherHouse, + }, + UserGroupMaps: []models.UserGroupMap{ + AddHarryToGryffindor, AddRonToGryffindor, AddGinnyToGryffindor, AddHermioneToGryffindor, + AddMalfoyToSlytherin, AddSnapeToSlytherin, AddLuciusToSlytherin, + AddCedricToHufflepuff, AddFleurToHufflepuff, + AddChoToRavenclaw, AddViktorToRavenclaw, + }, Operations: []models.Operation{OpSorcerersStone, OpChamberOfSecrets, OpPrisonerOfAzkaban, OpGobletOfFire, OpOrderOfThePhoenix, OpHalfBloodPrince, OpDeathlyHallows}, Tags: []models.Tag{ TagFamily, TagFriendship, TagHome, TagLoyalty, TagCourage, TagGoodVsEvil, TagSupernatural, @@ -97,6 +106,11 @@ var HarryPotterSeedData = Seeder{ newUserOpPermission(UserDumbledore, OpChamberOfSecrets, policy.OperationRoleAdmin), newUserOpPermission(UserDumbledore, OpGobletOfFire, policy.OperationRoleAdmin), }, + UserGroupOpMap: []models.UserGroupOperationPermission{ + newUserGroupOpPermission(UserGroupGryffindor, OpSorcerersStone, policy.OperationRoleRead), + newUserGroupOpPermission(UserGroupHufflepuff, OpSorcerersStone, policy.OperationRoleWrite), + newUserGroupOpPermission(UserGroupSlytherin, OpSorcerersStone, policy.OperationRoleAdmin), + }, Findings: []models.Finding{ FindingBook2Magic, FindingBook2CGI, FindingBook2SpiderFear, FindingBook2Robes, }, @@ -171,6 +185,31 @@ var UserHeadlessNick = newHPUser(newUserInput{FirstName: "Nicholas", LastName: " // Reserved users: Luna Lovegood (Create user test) +var newUserGroup = newUserGroupGen(1) + +var UserGroupGryffindor = newUserGroup("Gryffindor", false) +var UserGroupHufflepuff = newUserGroup("Hufflepuff", false) +var UserGroupRavenclaw = newUserGroup("Ravenclaw", false) +var UserGroupSlytherin = newUserGroup("Slytherin", false) + +// UserGroupOtherHouse is reserved to test deleted user groups +var UserGroupOtherHouse = newUserGroup("Other House", true) + +var AddHarryToGryffindor = newUserGroupMapping(UserHarry, UserGroupGryffindor) +var AddRonToGryffindor = newUserGroupMapping(UserRon, UserGroupGryffindor) +var AddGinnyToGryffindor = newUserGroupMapping(UserGinny, UserGroupGryffindor) +var AddHermioneToGryffindor = newUserGroupMapping(UserHermione, UserGroupGryffindor) + +var AddMalfoyToSlytherin = newUserGroupMapping(UserDraco, UserGroupSlytherin) +var AddLuciusToSlytherin = newUserGroupMapping(UserLucius, UserGroupSlytherin) +var AddSnapeToSlytherin = newUserGroupMapping(UserSnape, UserGroupSlytherin) + +var AddCedricToHufflepuff = newUserGroupMapping(UserCedric, UserGroupHufflepuff) +var AddFleurToHufflepuff = newUserGroupMapping(UserFleur, UserGroupHufflepuff) + +var AddViktorToRavenclaw = newUserGroupMapping(UserViktor, UserGroupRavenclaw) +var AddChoToRavenclaw = newUserGroupMapping(UserCho, UserGroupRavenclaw) + var newAPIKey = newAPIKeyGen(1) var APIKeyHarry1 = newAPIKey(UserHarry.ID, "harry-abc", []byte{0x01, 0x02, 0x03}) var APIKeyHarry2 = newAPIKey(UserHarry.ID, "harry-123", []byte{0x11, 0x12, 0x13}) diff --git a/backend/database/seeding/seeder.go b/backend/database/seeding/seeder.go index 9beaa4372..645e9e0ae 100644 --- a/backend/database/seeding/seeder.go +++ b/backend/database/seeding/seeder.go @@ -26,10 +26,13 @@ type Seeder struct { Evidences []models.Evidence EvidenceMetadatas []models.EvidenceMetadata Users []models.User + UserGroups []models.UserGroup + UserGroupMaps []models.UserGroupMap Operations []models.Operation DefaultTags []models.DefaultTag Tags []models.Tag UserOpMap []models.UserOperationPermission + UserGroupOpMap []models.UserGroupOperationPermission UserOpPrefMap []models.UserOperationPreferences TagEviMap []models.TagEvidenceMap EviFindingsMap []models.EvidenceFindingMap @@ -83,6 +86,24 @@ func (seed Seeder) ApplyTo(db *database.Connection) error { "deleted_at": seed.Users[i].DeletedAt, } }) + tx.BatchInsert("user_groups", len(seed.UserGroups), func(i int) map[string]interface{} { + return map[string]interface{}{ + "id": seed.UserGroups[i].ID, + "slug": seed.UserGroups[i].Slug, + "name": seed.UserGroups[i].Name, + "created_at": seed.UserGroups[i].CreatedAt, + "updated_at": seed.UserGroups[i].UpdatedAt, + "deleted_at": seed.UserGroups[i].DeletedAt, + } + }) + tx.BatchInsert("group_user_map", len(seed.UserGroupMaps), func(i int) map[string]interface{} { + return map[string]interface{}{ + "group_id": seed.UserGroupMaps[i].GroupID, + "user_id": seed.UserGroupMaps[i].UserID, + "created_at": seed.UserGroupMaps[i].CreatedAt, + "updated_at": seed.UserGroupMaps[i].UpdatedAt, + } + }) tx.BatchInsert("api_keys", len(seed.APIKeys), func(i int) map[string]interface{} { return map[string]interface{}{ "id": seed.APIKeys[i].ID, @@ -127,6 +148,15 @@ func (seed Seeder) ApplyTo(db *database.Connection) error { "updated_at": seed.UserOpMap[i].UpdatedAt, } }) + tx.BatchInsert("user_group_operation_permissions", len(seed.UserGroupOpMap), func(i int) map[string]interface{} { + return map[string]interface{}{ + "group_id": seed.UserGroupOpMap[i].UserGroupID, + "operation_id": seed.UserGroupOpMap[i].OperationID, + "role": seed.UserGroupOpMap[i].Role, + "created_at": seed.UserGroupOpMap[i].CreatedAt, + "updated_at": seed.UserGroupOpMap[i].UpdatedAt, + } + }) tx.BatchInsert("user_operation_preferences", len(seed.UserOpPrefMap), func(i int) map[string]interface{} { return map[string]interface{}{ "user_id": seed.UserOpPrefMap[i].UserID, @@ -309,6 +339,15 @@ func (seed Seeder) GetUserFromID(id int64) models.User { return models.User{} } +func (seed Seeder) GetUserGroupFromID(id int64) models.UserGroup { + for _, item := range seed.UserGroups { + if item.ID == id { + return item + } + } + return models.UserGroup{} +} + func (seed Seeder) UsersForOp(op models.Operation) []models.User { rtn := make([]models.User, 0) @@ -320,6 +359,17 @@ func (seed Seeder) UsersForOp(op models.Operation) []models.User { return rtn } +func (seed Seeder) UserGroupsForOp(op models.Operation) []models.UserGroup { + rtn := make([]models.UserGroup, 0) + + for _, row := range seed.UserGroupOpMap { + if row.OperationID == op.ID { + rtn = append(rtn, seed.GetUserGroupFromID(row.UserGroupID)) + } + } + return rtn +} + func (seed Seeder) UserRoleForOp(user models.User, op models.Operation) policy.OperationRole { for _, row := range seed.UserOpMap { if row.OperationID == op.ID && row.UserID == user.ID { @@ -329,6 +379,15 @@ func (seed Seeder) UserRoleForOp(user models.User, op models.Operation) policy.O return "" } +func (seed Seeder) UserGroupRoleForOp(userGroup models.UserGroup, op models.Operation) policy.OperationRole { + for _, row := range seed.UserGroupOpMap { + if row.OperationID == op.ID && row.UserGroupID == userGroup.ID { + return row.Role + } + } + return "" +} + func (seed Seeder) EvidenceForOperation(opID int64) []models.Evidence { evidence := make([]models.Evidence, 0) for _, row := range seed.Evidences { diff --git a/backend/database/seeding/test_helpers.go b/backend/database/seeding/test_helpers.go index 211f07039..72b61b244 100644 --- a/backend/database/seeding/test_helpers.go +++ b/backend/database/seeding/test_helpers.go @@ -82,6 +82,7 @@ func ClearDB(db *database.Connection) error { err := db.WithTx(context.Background(), func(tx *database.Transactable) { tx.Delete(sq.Delete("sessions")) tx.Delete(sq.Delete("user_operation_permissions")) + tx.Delete(sq.Delete("user_group_operation_permissions")) tx.Delete(sq.Delete("user_operation_preferences")) tx.Delete(sq.Delete("api_keys")) tx.Delete(sq.Delete("auth_scheme_data")) @@ -94,7 +95,9 @@ func ClearDB(db *database.Connection) error { tx.Delete(sq.Delete("evidence")) tx.Delete(sq.Delete("findings")) tx.Delete(sq.Delete("finding_categories")) + tx.Delete(sq.Delete("group_user_map")) tx.Delete(sq.Delete("users")) + tx.Delete(sq.Delete("user_groups")) tx.Delete(sq.Delete("queries")) tx.Delete(sq.Delete("operations")) tx.Delete(sq.Delete("service_workers")) @@ -550,6 +553,30 @@ func GetUsersWithRoleForOperationByOperationID(t *testing.T, db *database.Connec return allUserOpRoles } +type UserGroupOpPermJoinUser struct { + models.UserGroup + Role policy.OperationRole `db:"role"` +} + +func GetUserGroupsWithRoleForOperationByOperationID(t *testing.T, db *database.Connection, id int64) []UserGroupOpPermJoinUser { + var allUserGroupOpRoles []UserGroupOpPermJoinUser + err := db.Select(&allUserGroupOpRoles, sq.Select("user_group_operation_permissions.role", "user_groups.name", "user_groups.slug"). + From("user_group_operation_permissions"). + LeftJoin("user_groups ON user_groups.id = user_group_operation_permissions.group_id"). + Where(sq.Eq{"operation_id": id})) + require.NoError(t, err) + return allUserGroupOpRoles +} + +func GetUserGroupFromSlug(t *testing.T, db *database.Connection, slug string) models.UserGroup { + var fullUserGroup models.UserGroup + err := db.Get(&fullUserGroup, sq.Select("id", "slug", "name"). + From("user_groups"). + Where(sq.Eq{"slug": slug})) + require.NoError(t, err) + return fullUserGroup +} + type PreferencesOperations struct { models.UserOperationPreferences Slug string `db:"slug"` diff --git a/backend/dtos/dtos.go b/backend/dtos/dtos.go index 97ac5803b..002216959 100644 --- a/backend/dtos/dtos.go +++ b/backend/dtos/dtos.go @@ -60,14 +60,15 @@ type EvidenceCount struct { } type Operation struct { - Slug string `json:"slug"` - Name string `json:"name"` - NumUsers int `json:"numUsers"` - NumEvidence int `json:"numEvidence"` - NumTags int `json:"numTags"` - Favorite bool `json:"favorite"` - TopContribs []TopContrib `json:"topContribs"` - EvidenceCount EvidenceCount `json:"evidenceCount,omitempty"` + Slug string `json:"slug"` + Name string `json:"name"` + NumUsers int `json:"numUsers"` + NumEvidence int `json:"numEvidence"` + NumTags int `json:"numTags"` + Favorite bool `json:"favorite"` + TopContribs []TopContrib `json:"topContribs"` + EvidenceCount EvidenceCount `json:"evidenceCount,omitempty"` + UserCanViewGroups *bool `json:"userCanViewGroups,omitempty"` } type Query struct { @@ -129,6 +130,11 @@ type UserOperationRole struct { Role policy.OperationRole `json:"role"` } +type UserGroupOperationRole struct { + UserGroup UserGroupAdminView `json:"userGroup"` + Role policy.OperationRole `json:"role"` +} + type PaginationWrapper struct { Content interface{} `json:"content"` PageNumber int64 `json:"page"` @@ -190,6 +196,18 @@ type CreateUserOutput struct { UserID int64 `json:"-"` // don't transmit the userid } +type UserGroupAdminView struct { + Slug string `json:"slug"` + Name string `json:"name"` + UserSlugs []string `json:"userSlugs"` + Deleted bool `json:"deleted"` +} + +type UserGroup struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + type ServiceWorker struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/backend/dtos/gentypes/generate_typescript_types.go b/backend/dtos/gentypes/generate_typescript_types.go index db198b498..6fb7e4d6b 100644 --- a/backend/dtos/gentypes/generate_typescript_types.go +++ b/backend/dtos/gentypes/generate_typescript_types.go @@ -46,6 +46,9 @@ func main() { gen(dtos.ServiceWorkerTestOutput{}) gen(dtos.ActiveServiceWorker{}) gen(dtos.Flags{}) + gen(dtos.UserGroup{}) + gen(dtos.UserGroupAdminView{}) + gen(dtos.UserGroupOperationRole{}) // Since this file only contains typescript types, webpack doesn't pick up the // changes unless there is some actual executable javascript referenced from diff --git a/backend/migrations/20221121165342-add-groups.sql b/backend/migrations/20221121165342-add-groups.sql new file mode 100644 index 000000000..c362da1f8 --- /dev/null +++ b/backend/migrations/20221121165342-add-groups.sql @@ -0,0 +1,25 @@ +-- +migrate Up +CREATE TABLE user_groups ( + id INT AUTO_INCREMENT, + slug VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP, + PRIMARY KEY (id) +) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; + + +CREATE TABLE group_user_map ( + user_id INT NOT NULL, + group_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + PRIMARY KEY (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE +) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; + +-- +migrate Down +DROP TABLE group_user_map; +DROP TABLE user_groups; diff --git a/backend/migrations/20221216195811-add-user-group-permissions-table.sql b/backend/migrations/20221216195811-add-user-group-permissions-table.sql new file mode 100644 index 000000000..091a77e1c --- /dev/null +++ b/backend/migrations/20221216195811-add-user-group-permissions-table.sql @@ -0,0 +1,14 @@ +-- +migrate Up +CREATE TABLE user_group_operation_permissions ( + group_id INT NOT NULL, + operation_id INT NOT NULL, + role VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + PRIMARY KEY (group_id, operation_id), + FOREIGN KEY (group_id) REFERENCES user_groups(id), + FOREIGN KEY (operation_id) REFERENCES operations(id) +) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; + +-- +migrate Down +DROP TABLE user_group_operation_permissions; diff --git a/backend/models/models.go b/backend/models/models.go index d8cf92824..829013543 100644 --- a/backend/models/models.go +++ b/backend/models/models.go @@ -114,6 +114,24 @@ type User struct { DeletedAt *time.Time `db:"deleted_at"` } +// Group reflects the structure of the database table 'user_groups' +type UserGroup struct { + ID int64 `db:"id"` + Slug string `db:"slug"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt *time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` +} + +// TagEvidenceMap reflects the structure of the database table 'user_group_map' +type UserGroupMap struct { + GroupID int64 `db:"group_id"` + UserID int64 `db:"user_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt *time.Time `db:"updated_at"` +} + // UserOperationPermission reflects the structure of the database table 'user_operation_permissions' type UserOperationPermission struct { UserID int64 `db:"user_id"` @@ -123,6 +141,15 @@ type UserOperationPermission struct { UpdatedAt *time.Time `db:"updated_at"` } +// UserOperationPermission reflects the structure of the database table 'user_group_operation_permissions' +type UserGroupOperationPermission struct { + UserGroupID int64 `db:"group_id"` + OperationID int64 `db:"operation_id"` + Role policy.OperationRole `db:"role"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt *time.Time `db:"updated_at"` +} + type UserOperationPreferences struct { UserID int64 `db:"user_id"` OperationID int64 `db:"operation_id"` diff --git a/backend/policy/operation.go b/backend/policy/operation.go index aba99c6d1..f0b847108 100644 --- a/backend/policy/operation.go +++ b/backend/policy/operation.go @@ -30,6 +30,8 @@ func (o *Operation) Check(permission Permission) bool { case CanModifyUserOfOperation: return p.UserID != o.UserID && // A user cannot modify their own permissions (to prevent lockout) o.hasRole(p.OperationID, OperationRoleAdmin) + case CanModifyUserGroupOfOperation: + return o.hasRole(p.OperationID, OperationRoleAdmin) case CanDeleteOperation: return o.hasRole(p.OperationID, OperationRoleAdmin) @@ -49,6 +51,9 @@ func (o *Operation) Check(permission Permission) bool { return o.hasRole(p.OperationID, OperationRoleAdmin, OperationRoleWrite, OperationRoleRead) || o.IsHeadless case CanReadOperation: return o.hasRole(p.OperationID, OperationRoleAdmin, OperationRoleWrite, OperationRoleRead) || o.IsHeadless + + case CanListUserGroupsOfOperation: + return o.hasRole(p.OperationID, OperationRoleAdmin) || o.IsHeadless } return false } diff --git a/backend/policy/permissions.go b/backend/policy/permissions.go index 093943b28..31b2c2e1f 100644 --- a/backend/policy/permissions.go +++ b/backend/policy/permissions.go @@ -39,3 +39,9 @@ type CanModifyUserOfOperation struct { OperationID int64 UserID int64 } + +type CanListUserGroupsOfOperation struct{ OperationID int64 } +type CanModifyUserGroupOfOperation struct { + OperationID int64 + UserGroupID int64 +} diff --git a/backend/schema.sql b/backend/schema.sql index 29167084e..a4a66956d 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -1,8 +1,8 @@ --- MySQL dump 10.13 Distrib 8.0.22, for Linux (x86_64) +-- MySQL dump 10.13 Distrib 8.0.31, for Linux (aarch64) -- -- Host: localhost Database: migrate_db -- ------------------------------------------------------ --- Server version 8.0.22 +-- Server version 8.0.31 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; @@ -34,7 +34,7 @@ CREATE TABLE `api_keys` ( UNIQUE KEY `access_key` (`access_key`), KEY `user_id` (`user_id`), CONSTRAINT `api_keys_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -61,7 +61,7 @@ CREATE TABLE `auth_scheme_data` ( UNIQUE KEY `auth_scheme_user_key` (`auth_scheme`,`username`), KEY `fk_user_id__users_id` (`user_id`), CONSTRAINT `fk_user_id__users_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -79,7 +79,7 @@ CREATE TABLE `default_tags` ( `updated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -102,7 +102,7 @@ CREATE TABLE `email_queue` ( PRIMARY KEY (`id`), KEY `email_queue__email_status` (`email_status`), KEY `email_queue__email_to` (`to_email`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -130,7 +130,7 @@ CREATE TABLE `evidence` ( KEY `operator_id` (`operator_id`), CONSTRAINT `evidence_ibfk_1` FOREIGN KEY (`operation_id`) REFERENCES `operations` (`id`), CONSTRAINT `evidence_ibfk_2` FOREIGN KEY (`operator_id`) REFERENCES `users` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -149,7 +149,7 @@ CREATE TABLE `evidence_finding_map` ( KEY `event_id` (`finding_id`), CONSTRAINT `evidence_finding_map_ibfk_1` FOREIGN KEY (`evidence_id`) REFERENCES `evidence` (`id`) ON DELETE CASCADE, CONSTRAINT `evidence_finding_map_ibfk_2` FOREIGN KEY (`finding_id`) REFERENCES `findings` (`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -174,7 +174,7 @@ CREATE TABLE `evidence_metadata` ( UNIQUE KEY `evidence_id` (`evidence_id`,`source`), KEY `evidence_id_2` (`evidence_id`), CONSTRAINT `evidence_metadata_ibfk_1` FOREIGN KEY (`evidence_id`) REFERENCES `evidence` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -192,7 +192,7 @@ CREATE TABLE `finding_categories` ( `deleted_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `category` (`category`) -) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8; +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -219,7 +219,7 @@ CREATE TABLE `findings` ( KEY `fk_category_id__finding_categories_id` (`category_id`), CONSTRAINT `findings_ibfk_1` FOREIGN KEY (`operation_id`) REFERENCES `operations` (`id`), CONSTRAINT `fk_category_id__finding_categories_id` FOREIGN KEY (`category_id`) REFERENCES `finding_categories` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -233,7 +233,26 @@ CREATE TABLE `gorp_migrations` ( `id` varchar(255) NOT NULL, `applied_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `group_user_map` +-- + +DROP TABLE IF EXISTS `group_user_map`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `group_user_map` ( + `user_id` int NOT NULL, + `group_id` int NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`user_id`,`group_id`), + KEY `group_id` (`group_id`), + CONSTRAINT `group_user_map_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), + CONSTRAINT `group_user_map_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `user_groups` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -253,7 +272,7 @@ CREATE TABLE `operations` ( `updated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `slug` (`slug`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -275,7 +294,7 @@ CREATE TABLE `queries` ( UNIQUE KEY `name` (`name`,`operation_id`,`type`), UNIQUE KEY `query` (`query`,`operation_id`,`type`), KEY `operation_id` (`operation_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -294,7 +313,7 @@ CREATE TABLE `service_workers` ( `deleted_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -333,7 +352,7 @@ CREATE TABLE `tag_evidence_map` ( KEY `evidence_id` (`evidence_id`), CONSTRAINT `tag_evidence_map_ibfk_1` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`), CONSTRAINT `tag_evidence_map_ibfk_2` FOREIGN KEY (`evidence_id`) REFERENCES `evidence` (`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -353,7 +372,47 @@ CREATE TABLE `tags` ( PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`,`operation_id`), KEY `operation_id` (`operation_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_group_operation_permissions` +-- + +DROP TABLE IF EXISTS `user_group_operation_permissions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_group_operation_permissions` ( + `group_id` int NOT NULL, + `operation_id` int NOT NULL, + `role` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`group_id`,`operation_id`), + KEY `operation_id` (`operation_id`), + CONSTRAINT `user_group_operation_permissions_ibfk_1` FOREIGN KEY (`group_id`) REFERENCES `user_groups` (`id`), + CONSTRAINT `user_group_operation_permissions_ibfk_2` FOREIGN KEY (`operation_id`) REFERENCES `operations` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_groups` +-- + +DROP TABLE IF EXISTS `user_groups`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_groups` ( + `id` int NOT NULL AUTO_INCREMENT, + `slug` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`), + UNIQUE KEY `name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -373,7 +432,7 @@ CREATE TABLE `user_operation_permissions` ( KEY `operation_id` (`operation_id`), CONSTRAINT `user_operation_permissions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), CONSTRAINT `user_operation_permissions_ibfk_2` FOREIGN KEY (`operation_id`) REFERENCES `operations` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -393,7 +452,7 @@ CREATE TABLE `user_operation_preferences` ( KEY `operation_id` (`operation_id`), CONSTRAINT `user_operation_preferences_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), CONSTRAINT `user_operation_preferences_ibfk_2` FOREIGN KEY (`operation_id`) REFERENCES `operations` (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -418,7 +477,7 @@ CREATE TABLE `users` ( PRIMARY KEY (`id`), UNIQUE KEY `slug` (`slug`), UNIQUE KEY `unique_email` (`email`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; @@ -430,12 +489,12 @@ CREATE TABLE `users` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2022-11-11 22:48:03 --- MySQL dump 10.13 Distrib 8.0.22, for Linux (x86_64) +-- Dump completed on 2022-12-22 20:05:00 +-- MySQL dump 10.13 Distrib 8.0.31, for Linux (aarch64) -- -- Host: localhost Database: migrate_db -- ------------------------------------------------------ --- Server version 8.0.22 +-- Server version 8.0.31 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; @@ -454,7 +513,7 @@ CREATE TABLE `users` ( LOCK TABLES `gorp_migrations` WRITE; /*!40000 ALTER TABLE `gorp_migrations` DISABLE KEYS */; -INSERT INTO `gorp_migrations` VALUES ('20190705190058-create-users-table.sql','2022-11-11 22:47:58'),('20190708185420-create-operations-table.sql','2022-11-11 22:47:58'),('20190708185427-create-events-table.sql','2022-11-11 22:47:58'),('20190708185432-create-evidence-table.sql','2022-11-11 22:47:58'),('20190708185441-create-evidence-event-map-table.sql','2022-11-11 22:47:59'),('20190716190100-create-user-operation-map-table.sql','2022-11-11 22:47:59'),('20190722193434-create-tags-table.sql','2022-11-11 22:47:59'),('20190722193937-create-tag-event-map.sql','2022-11-11 22:47:59'),('20190909183500-add-short-name-to-users-table.sql','2022-11-11 22:47:59'),('20190909190416-add-short-name-index.sql','2022-11-11 22:47:59'),('20190926205116-evidence-name.sql','2022-11-11 22:47:59'),('20190930173342-add-saved-searches.sql','2022-11-11 22:47:59'),('20191001182541-evidence-tags.sql','2022-11-11 22:47:59'),('20191008005212-add-uuid-to-events-evidence.sql','2022-11-11 22:48:00'),('20191015235306-add-slug-to-operations.sql','2022-11-11 22:48:00'),('20191018172105-modular-auth.sql','2022-11-11 22:48:00'),('20191023170906-codeblock.sql','2022-11-11 22:48:00'),('20191101185207-replace-events-with-findings.sql','2022-11-11 22:48:00'),('20191114211948-add-operation-to-tags.sql','2022-11-11 22:48:00'),('20191205182830-create-api-keys-table.sql','2022-11-11 22:48:00'),('20191213222629-users-with-email.sql','2022-11-11 22:48:01'),('20200103194053-rename-short-name-to-slug.sql','2022-11-11 22:48:01'),('20200104013804-rework-ashirt-auth.sql','2022-11-11 22:48:01'),('20200116070736-add-admin-flag.sql','2022-11-11 22:48:01'),('20200130175541-fix-color-truncation.sql','2022-11-11 22:48:01'),('20200205200208-disable-user-support.sql','2022-11-11 22:48:01'),('20200215015330-optional-user-id.sql','2022-11-11 22:48:01'),('20200221195107-deletable-user.sql','2022-11-11 22:48:01'),('20200303215004-move-last-login.sql','2022-11-11 22:48:01'),('20200306221628-add-explicit-headless.sql','2022-11-11 22:48:02'),('20200331155258-finding-status.sql','2022-11-11 22:48:02'),('20200617193248-case-senitive-apikey.sql','2022-11-11 22:48:02'),('20200928160958-add-totp-secret-to-auth-table.sql','2022-11-11 22:48:02'),('20210120205510-create-email-queue-table.sql','2022-11-11 22:48:02'),('20210401220807-dynamic-categories.sql','2022-11-11 22:48:02'),('20210408212206-remove-findings-category.sql','2022-11-11 22:48:02'),('20210730170543-add-auth-type.sql','2022-11-11 22:48:02'),('20220211181557-add-default-tags.sql','2022-11-11 22:48:02'),('20220512174013-evidence-metadata.sql','2022-11-11 22:48:02'),('20220516163424-add-worker-services.sql','2022-11-11 22:48:03'),('20220811153414-webauthn-credentials.sql','2022-11-11 22:48:03'),('20220908193523-switch-to-username.sql','2022-11-11 22:48:03'),('20220912185024-add-is_favorite.sql','2022-11-11 22:48:03'),('20220916190855-remove-null-as-value-for-is_favorite.sql','2022-11-11 22:48:03'),('20221027152757-remove-operation-status.sql','2022-11-11 22:48:03'),('20221111221242-create-user-operation-preferences.sql','2022-11-11 22:48:03'); +INSERT INTO `gorp_migrations` VALUES ('20190705190058-create-users-table.sql','2022-12-22 20:04:58'),('20190708185420-create-operations-table.sql','2022-12-22 20:04:58'),('20190708185427-create-events-table.sql','2022-12-22 20:04:58'),('20190708185432-create-evidence-table.sql','2022-12-22 20:04:58'),('20190708185441-create-evidence-event-map-table.sql','2022-12-22 20:04:58'),('20190716190100-create-user-operation-map-table.sql','2022-12-22 20:04:58'),('20190722193434-create-tags-table.sql','2022-12-22 20:04:58'),('20190722193937-create-tag-event-map.sql','2022-12-22 20:04:58'),('20190909183500-add-short-name-to-users-table.sql','2022-12-22 20:04:58'),('20190909190416-add-short-name-index.sql','2022-12-22 20:04:58'),('20190926205116-evidence-name.sql','2022-12-22 20:04:59'),('20190930173342-add-saved-searches.sql','2022-12-22 20:04:59'),('20191001182541-evidence-tags.sql','2022-12-22 20:04:59'),('20191008005212-add-uuid-to-events-evidence.sql','2022-12-22 20:04:59'),('20191015235306-add-slug-to-operations.sql','2022-12-22 20:04:59'),('20191018172105-modular-auth.sql','2022-12-22 20:04:59'),('20191023170906-codeblock.sql','2022-12-22 20:04:59'),('20191101185207-replace-events-with-findings.sql','2022-12-22 20:04:59'),('20191114211948-add-operation-to-tags.sql','2022-12-22 20:04:59'),('20191205182830-create-api-keys-table.sql','2022-12-22 20:04:59'),('20191213222629-users-with-email.sql','2022-12-22 20:04:59'),('20200103194053-rename-short-name-to-slug.sql','2022-12-22 20:04:59'),('20200104013804-rework-ashirt-auth.sql','2022-12-22 20:04:59'),('20200116070736-add-admin-flag.sql','2022-12-22 20:04:59'),('20200130175541-fix-color-truncation.sql','2022-12-22 20:04:59'),('20200205200208-disable-user-support.sql','2022-12-22 20:04:59'),('20200215015330-optional-user-id.sql','2022-12-22 20:04:59'),('20200221195107-deletable-user.sql','2022-12-22 20:04:59'),('20200303215004-move-last-login.sql','2022-12-22 20:04:59'),('20200306221628-add-explicit-headless.sql','2022-12-22 20:04:59'),('20200331155258-finding-status.sql','2022-12-22 20:04:59'),('20200617193248-case-senitive-apikey.sql','2022-12-22 20:04:59'),('20200928160958-add-totp-secret-to-auth-table.sql','2022-12-22 20:04:59'),('20210120205510-create-email-queue-table.sql','2022-12-22 20:04:59'),('20210401220807-dynamic-categories.sql','2022-12-22 20:04:59'),('20210408212206-remove-findings-category.sql','2022-12-22 20:05:00'),('20210730170543-add-auth-type.sql','2022-12-22 20:05:00'),('20220211181557-add-default-tags.sql','2022-12-22 20:05:00'),('20220512174013-evidence-metadata.sql','2022-12-22 20:05:00'),('20220516163424-add-worker-services.sql','2022-12-22 20:05:00'),('20220811153414-webauthn-credentials.sql','2022-12-22 20:05:00'),('20220908193523-switch-to-username.sql','2022-12-22 20:05:00'),('20220912185024-add-is_favorite.sql','2022-12-22 20:05:00'),('20220916190855-remove-null-as-value-for-is_favorite.sql','2022-12-22 20:05:00'),('20221027152757-remove-operation-status.sql','2022-12-22 20:05:00'),('20221111221242-create-user-operation-preferences.sql','2022-12-22 20:05:00'),('20221121165342-add-groups.sql','2022-12-22 20:05:00'),('20221216195811-add-user-group-permissions-table.sql','2022-12-22 20:05:00'); /*!40000 ALTER TABLE `gorp_migrations` ENABLE KEYS */; UNLOCK TABLES; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; @@ -467,4 +526,4 @@ UNLOCK TABLES; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2022-11-11 22:48:03 +-- Dump completed on 2022-12-22 20:05:00 diff --git a/backend/server/middleware/authenticator.go b/backend/server/middleware/authenticator.go index 9966609b7..8ab1c0015 100644 --- a/backend/server/middleware/authenticator.go +++ b/backend/server/middleware/authenticator.go @@ -133,9 +133,24 @@ func buildContextForUser(ctx context.Context, db *database.Connection, userID in func buildPolicyForUser(ctx context.Context, db *database.Connection, userID int64, isSuperAdmin, isHeadless bool) policy.Policy { var roles []models.UserOperationPermission - err := db.Select(&roles, sq.Select("operation_id", "role"). - From("user_operation_permissions"). - Where(sq.Eq{"user_id": userID})) + + var groupRoles []models.UserGroupOperationPermission + + err := db.WithTx(context.Background(), func(tx *database.Transactable) { + tx.Select(&roles, sq.Select("operation_id", "role"). + From("user_operation_permissions"). + Where(sq.Eq{"user_id": userID})) + + var userGroupIds []int64 + tx.Select(&userGroupIds, sq.Select("group_id"). + From("group_user_map"). + Where(sq.Eq{"user_id": userID})) + + tx.Select(&groupRoles, sq.Select("operation_id", "role"). + From("user_group_operation_permissions"). + Where(sq.Eq{"group_id": userGroupIds})) + }) + if err != nil { logging.Log(ctx, "msg", "Unable to build user policy", "error", err.Error()) return &policy.Deny{} @@ -144,6 +159,16 @@ func buildPolicyForUser(ctx context.Context, db *database.Connection, userID int for _, role := range roles { roleMap[role.OperationID] = role.Role } + for _, role := range groupRoles { + val, ok := roleMap[role.OperationID] + noRole := !ok + assignedRoleIsLowest := ok && val == policy.OperationRoleRead + groupRoleIsHigher := ok && val == policy.OperationRoleWrite && role.Role == policy.OperationRoleAdmin + + if noRole || assignedRoleIsLowest || groupRoleIsHigher { + roleMap[role.OperationID] = role.Role + } + } return &policy.Union{ P1: policy.NewAuthenticatedPolicy(userID, isSuperAdmin), P2: &policy.Operation{ diff --git a/backend/server/web.go b/backend/server/web.go index 0bb43d6ec..07ca2ad44 100644 --- a/backend/server/web.go +++ b/backend/server/web.go @@ -141,6 +141,19 @@ func bindWebRoutes(r *mux.Router, db *database.Connection, contentStore contents return services.ListUsers(r.Context(), db, i) })) + route(r, "GET", "/usergroups", jsonHandler(func(r *http.Request) (interface{}, error) { + dr := dissectJSONRequest(r) + i := services.ListUserGroupsInput{ + Query: dr.FromQuery("query").Required().AsString(), + IncludeDeleted: dr.FromQuery("includeDeleted").OrDefault(false).AsBool(), + OperationSlug: dr.FromQuery("operationSlug").Required().AsString(), + } + if dr.Error != nil { + return nil, dr.Error + } + return services.ListUserGroups(r.Context(), db, i) + })) + route(r, "GET", "/admin/users", jsonHandler(func(r *http.Request) (interface{}, error) { dr := dissectJSONRequest(r) i := services.ListUsersForAdminInput{ @@ -194,6 +207,58 @@ func bindWebRoutes(r *mux.Router, db *database.Connection, contentStore contents return nil, services.SetUserFlags(r.Context(), db, i) })) + route(r, "GET", "/admin/usergroups", jsonHandler(func(r *http.Request) (interface{}, error) { + dr := dissectJSONRequest(r) + i := services.ListUserGroupsForAdminInput{ + UserGroupFilter: services.ParseRequestQueryUserGroupFilter(dr), + Pagination: services.ParseRequestQueryPagination(dr, 10), + IncludeDeleted: dr.FromQuery("deleted").OrDefault(false).AsBool(), + } + if dr.Error != nil { + return nil, dr.Error + } + return services.ListUserGroupsForAdmin(r.Context(), db, i) + })) + + route(r, "POST", "/admin/usergroups", jsonHandler(func(r *http.Request) (interface{}, error) { + dr := dissectJSONRequest(r) + i := services.CreateUserGroupInput{ + Slug: dr.FromBody("slug").Required().AsString(), + Name: dr.FromBody("name").Required().AsString(), + UserSlugs: dr.FromBody("userSlugs").Required().AsStringSlice(), + } + + if dr.Error != nil { + return nil, dr.Error + } + return services.CreateUserGroup(r.Context(), db, i) + })) + + route(r, "PUT", "/admin/usergroups/{group_slug}", jsonHandler(func(r *http.Request) (interface{}, error) { + dr := dissectJSONRequest(r) + i := services.ModifyUserGroupInput{ + Name: dr.FromBody("newName").AsString(), + UsersToAdd: dr.FromBody("userSlugsToAdd").AsStringSlice(), + UsersToRemove: dr.FromBody("userSlugsToRemove").AsStringSlice(), + Slug: dr.FromURL("group_slug").Required().AsString(), + } + + if dr.Error != nil { + return nil, dr.Error + } + return services.ModifyUserGroup(r.Context(), db, i) + })) + + route(r, "DELETE", "/admin/usergroups/{group_slug}", jsonHandler(func(r *http.Request) (interface{}, error) { + dr := dissectJSONRequest(r) + groupSlug := dr.FromURL("group_slug").AsString() + + if dr.Error != nil { + return nil, dr.Error + } + return nil, services.DeleteUserGroup(r.Context(), db, groupSlug) + })) + route(r, "GET", "/auths", jsonHandler(func(r *http.Request) (interface{}, error) { return supportedAuthSchemes, nil })) @@ -281,6 +346,19 @@ func bindWebRoutes(r *mux.Router, db *database.Connection, contentStore contents return services.ListUsersForOperation(r.Context(), db, i) })) + route(r, "GET", "/operations/{operation_slug}/usergroups", jsonHandler(func(r *http.Request) (interface{}, error) { + dr := dissectJSONRequest(r) + i := services.ListUserGroupsForOperationInput{ + OperationSlug: dr.FromURL("operation_slug").Required().AsString(), + UserGroupFilter: services.ParseRequestQueryUserGroupFilter(dr), + } + if dr.Error != nil { + return nil, dr.Error + } + + return services.ListUserGroupsForOperation(r.Context(), db, i) + })) + route(r, "PATCH", "/operations/{operation_slug}/users", jsonHandler(func(r *http.Request) (interface{}, error) { dr := dissectJSONRequest(r) i := services.SetUserOperationRoleInput{ @@ -294,6 +372,19 @@ func bindWebRoutes(r *mux.Router, db *database.Connection, contentStore contents return nil, services.SetUserOperationRole(r.Context(), db, i) })) + route(r, "PATCH", "/operations/{operation_slug}/usergroups", jsonHandler(func(r *http.Request) (interface{}, error) { + dr := dissectJSONRequest(r) + i := services.SetUserGroupOperationRoleInput{ + OperationSlug: dr.FromURL("operation_slug").Required().AsString(), + UserGroupSlug: dr.FromBody("userGroupSlug").Required().AsString(), + Role: policy.OperationRole(dr.FromBody("role").Required().AsString()), + } + if dr.Error != nil { + return nil, dr.Error + } + return nil, services.SetUserGroupOperationRole(r.Context(), db, i) + })) + route(r, "GET", "/operations/{operation_slug}/findings", jsonHandler(func(r *http.Request) (interface{}, error) { dr := dissectJSONRequest(r) timelineFilters, err := helpers.ParseTimelineQuery(dr.FromQuery("query").AsString()) diff --git a/backend/services/helpers.go b/backend/services/helpers.go index 1d799f4e6..c575b6a37 100644 --- a/backend/services/helpers.go +++ b/backend/services/helpers.go @@ -6,6 +6,8 @@ package services import ( "context" "fmt" + "regexp" + "strings" "github.com/theparanoids/ashirt-server/backend" "github.com/theparanoids/ashirt-server/backend/database" @@ -219,6 +221,28 @@ func userSlugToUserID(db *database.Connection, slug string) (int64, error) { return userID, err } +func userGroupSlugToUserGroupID(db *database.Connection, slug string) (int64, error) { + var userGroupID int64 + err := db.Get(&userGroupID, sq.Select("id").From("user_groups").Where(sq.Eq{"slug": slug})) + if err != nil { + return userGroupID, backend.WrapError("Unable to look up user group by slug", err) + } + return userGroupID, err +} + +// lookupUserGroup returns an user group model for the given slug +func lookupUserGroup(db *database.Connection, userGroupSlug string) (*models.UserGroup, error) { + var userGroup models.UserGroup + + err := db.Get(&userGroup, sq.Select("id", "name", "slug"). + From("user_groups"). + Where(sq.Eq{"slug": userGroupSlug})) + if err != nil { + return &userGroup, backend.WrapError("Unable to lookup user group by slug", err) + } + return &userGroup, nil +} + func SelfOrSlugToUserID(ctx context.Context, db *database.Connection, slug string) (int64, error) { if slug == "" { return middleware.UserID(ctx), nil @@ -242,3 +266,15 @@ func ListActiveServices(ctx context.Context, db *database.Connection) ([]*dtos.A }) return servicesDTO, nil } + +var disallowedCharactersRegex = regexp.MustCompile(`[^A-Za-z0-9]+`) + +// SanitizeOperationSlug removes objectionable characters from a slug and returns the new slug. +// Current logic: only allow alphanumeric characters and hyphen, with hypen excluded at the start +// and end +func SanitizeSlug(slug string) string { + return strings.Trim( + disallowedCharactersRegex.ReplaceAllString(strings.ToLower(slug), "-"), + "-", + ) +} diff --git a/backend/services/helpers_test.go b/backend/services/helpers_test.go new file mode 100644 index 000000000..dc9e9f6c1 --- /dev/null +++ b/backend/services/helpers_test.go @@ -0,0 +1,20 @@ +// Copyright 2023, Yahoo Inc. +// Licensed under the terms of the MIT. See LICENSE file in project root for terms. + +package services_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/theparanoids/ashirt-server/backend/services" +) + +func TestSanitizeSlug(t *testing.T) { + require.Equal(t, services.SanitizeSlug("?One?Two?Three?"), "one-two-three") + require.Equal(t, services.SanitizeSlug("Harry"), "harry") + require.Equal(t, services.SanitizeSlug("Harry Potter"), "harry-potter") + require.Equal(t, services.SanitizeSlug("fancy_name"), "fancy-name") + require.Equal(t, services.SanitizeSlug("Lots_Of-Fancy! Characters"), "lots-of-fancy-characters") + require.Equal(t, services.SanitizeSlug("$$prefixed_and_postfixed$$"), "prefixed-and-postfixed") +} diff --git a/backend/services/operation_role.go b/backend/services/operation_role.go index 9d5079c14..bfb95faef 100644 --- a/backend/services/operation_role.go +++ b/backend/services/operation_role.go @@ -21,6 +21,12 @@ type SetUserOperationRoleInput struct { Role policy.OperationRole } +type SetUserGroupOperationRoleInput struct { + OperationSlug string + UserGroupSlug string + Role policy.OperationRole +} + func SetUserOperationRole(ctx context.Context, db *database.Connection, i SetUserOperationRoleInput) error { operation, err := lookupOperation(db, i.OperationSlug) if err != nil { @@ -79,3 +85,58 @@ func SetUserOperationRole(ctx context.Context, db *database.Connection, i SetUse } return nil } + +func SetUserGroupOperationRole(ctx context.Context, db *database.Connection, i SetUserGroupOperationRoleInput) error { + operation, err := lookupOperation(db, i.OperationSlug) + if err != nil { + return backend.WrapError("Unable to set user group role", backend.UnauthorizedWriteErr(err)) + } + + if i.UserGroupSlug == "" { + return backend.MissingValueErr("User Group Slug") + } + + userGroupID, err := userGroupSlugToUserGroupID(db, i.UserGroupSlug) + if err != nil { + return backend.WrapError("Unable to get user group id from slug", backend.BadInputErr(err, fmt.Sprintf(`No user with slug "%s" was found`, i.UserGroupSlug))) + } + + if err := policyRequireWithAdminBypass(ctx, policy.CanModifyUserGroupOfOperation{UserGroupID: userGroupID, OperationID: operation.ID}); err != nil { + return backend.WrapError("Unwilling to set user group role", backend.UnauthorizedWriteErr(err)) + } + + if i.Role == "" { + err := db.Delete(sq.Delete("user_group_operation_permissions").Where(sq.Eq{"group_id": userGroupID, "operation_id": operation.ID})) + + if err != nil { + return backend.WrapError("Cannot delete user group role", backend.DatabaseErr(err)) + } + return nil + } + + var permissions []models.UserGroupOperationPermission + err = db.WithTx(context.Background(), func(tx *database.Transactable) { + tx.Select(&permissions, sq.Select("*"). + From("user_group_operation_permissions"). + Where(sq.Eq{ + "group_id": userGroupID, + "operation_id": operation.ID, + })) + if len(permissions) == 0 { + tx.Insert("user_group_operation_permissions", map[string]interface{}{ + "group_id": userGroupID, + "operation_id": operation.ID, + "role": i.Role, + }) + } else if permissions[0].Role != i.Role { + tx.Update(sq.Update("user_group_operation_permissions"). + Set("role", i.Role). + Where(sq.Eq{"group_id": userGroupID, "operation_id": operation.ID})) + } + }) + if err != nil { + return backend.WrapError("Unable to add user role", backend.DatabaseErr(err)) + } + + return nil +} diff --git a/backend/services/operation_role_test.go b/backend/services/operation_role_test.go index 6f20822fb..bf0fdaa80 100644 --- a/backend/services/operation_role_test.go +++ b/backend/services/operation_role_test.go @@ -70,3 +70,61 @@ func TestSetUserOperationRole(t *testing.T) { require.Equal(t, string(targetRole), newRole) }) } + +// write a test for SetUserGroupOperationRole +func TestSetUserGroupOperationRole(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, seed TestSeedData) { + ctx := contextForUser(UserDumbledore, db) + + masterOp := OpSorcerersStone + targetUserGroup := UserGroupSlytherin + targetRole := policy.OperationRoleRead + input := services.SetUserGroupOperationRoleInput{ + OperationSlug: masterOp.Slug, + UserGroupSlug: targetUserGroup.Slug, + Role: targetRole, + } + + initialRole := seed.UserGroupRoleForOp(targetUserGroup, masterOp) + require.NotContains(t, []policy.OperationRole{targetRole, ""}, initialRole, "Test user group should have a role, but not have the role we want to use") + + err := services.SetUserGroupOperationRole(ctx, db, input) + require.NoError(t, err) + + getDBRole := func() (string, error) { + var newRole string + err := db.Get(&newRole, sq.Select("role"). + From("user_group_operation_permissions"). + Where(sq.Eq{"operation_id": masterOp.ID, "group_id": targetUserGroup.ID})) + return newRole, err + } + newRole, err := getDBRole() + require.NoError(t, err) + require.Equal(t, string(targetRole), newRole) + + input = services.SetUserGroupOperationRoleInput{ + OperationSlug: masterOp.Slug, + UserGroupSlug: targetUserGroup.Slug, + Role: "", + } + + err = services.SetUserGroupOperationRole(ctx, db, input) + require.NoError(t, err) + + _, err = getDBRole() + require.True(t, database.IsEmptyResultSetError(err)) + + targetRole = policy.OperationRoleAdmin + input = services.SetUserGroupOperationRoleInput{ + OperationSlug: masterOp.Slug, + UserGroupSlug: targetUserGroup.Slug, + Role: targetRole, + } + err = services.SetUserGroupOperationRole(ctx, db, input) + require.NoError(t, err) + + newRole, err = getDBRole() + require.NoError(t, err) + require.Equal(t, string(targetRole), newRole) + }) +} diff --git a/backend/services/operations.go b/backend/services/operations.go index fbc84bf93..974d55a3b 100644 --- a/backend/services/operations.go +++ b/backend/services/operations.go @@ -7,8 +7,6 @@ import ( "context" "errors" "fmt" - "regexp" - "strings" "github.com/theparanoids/ashirt-server/backend" "github.com/theparanoids/ashirt-server/backend/contentstore" @@ -63,7 +61,7 @@ func CreateOperation(ctx context.Context, db *database.Connection, i CreateOpera return nil, backend.MissingValueErr("Slug") } - cleanSlug := SanitizeOperationSlug(i.Slug) + cleanSlug := SanitizeSlug(i.Slug) if cleanSlug == "" { return nil, backend.BadInputErr(errors.New("Unable to create operation. Invalid operation slug"), "Slug must contain english letters or numbers") } @@ -278,15 +276,23 @@ func ReadOperation(ctx context.Context, db *database.Connection, operationSlug s topContribsForOp = []dtos.TopContrib{} } + var userCanViewGroups bool + if middleware.IsAdmin(ctx) { + userCanViewGroups = true + } else if err := policyRequireWithAdminBypass(ctx, policy.CanListUserGroupsOfOperation{OperationID: operation.ID}); err == nil { + userCanViewGroups = true + } + return &dtos.Operation{ - Slug: operationSlug, - Name: operation.Name, - NumUsers: numUsers, - Favorite: favorite, - NumEvidence: operation.NumEvidence, - NumTags: operation.NumTags, - TopContribs: topContribsForOp, - EvidenceCount: evidenceCountForOp, + Slug: operationSlug, + Name: operation.Name, + NumUsers: numUsers, + Favorite: favorite, + NumEvidence: operation.NumEvidence, + NumTags: operation.NumTags, + TopContribs: topContribsForOp, + EvidenceCount: evidenceCountForOp, + UserCanViewGroups: &userCanViewGroups, }, nil } @@ -435,18 +441,6 @@ func SetFavoriteOperation(ctx context.Context, db *database.Connection, i SetFav return nil } -var disallowedCharactersRegex = regexp.MustCompile(`[^A-Za-z0-9]+`) - -// SanitizeOperationSlug removes objectionable characters from a slug and returns the new slug. -// Current logic: only allow alphanumeric characters and hyphen, with hypen excluded at the start -// and end -func SanitizeOperationSlug(slug string) string { - return strings.Trim( - disallowedCharactersRegex.ReplaceAllString(strings.ToLower(slug), "-"), - "-", - ) -} - var getDataFromEvidence string = ` SELECT slug, diff --git a/backend/services/operations_test.go b/backend/services/operations_test.go index 300aac23e..c6d8733fd 100644 --- a/backend/services/operations_test.go +++ b/backend/services/operations_test.go @@ -195,14 +195,6 @@ func TestSetFavoriteOperation(t *testing.T) { }) } -func TestSanitizeOperationSlug(t *testing.T) { - require.Equal(t, services.SanitizeOperationSlug("?One?Two?Three?"), "one-two-three") - require.Equal(t, services.SanitizeOperationSlug("Harry"), "harry") - require.Equal(t, services.SanitizeOperationSlug("Harry Potter"), "harry-potter") - require.Equal(t, services.SanitizeOperationSlug("fancy_name"), "fancy-name") - require.Equal(t, services.SanitizeOperationSlug("Lots_Of-Fancy! Characters"), "lots-of-fancy-characters") -} - func TestUpdateOperation(t *testing.T) { RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { ctx := contextForUser(UserRon, db) diff --git a/backend/services/seeding_rewrap_test.go b/backend/services/seeding_rewrap_test.go index ce13e0707..d0e5bdafc 100644 --- a/backend/services/seeding_rewrap_test.go +++ b/backend/services/seeding_rewrap_test.go @@ -24,11 +24,13 @@ var TinyCodeblock = seeding.TinyCodeblock var TinyTermRec = seeding.TinyTermRec type UserOpPermJoinUser = seeding.UserOpPermJoinUser +type UserGroupOpPermJoinUser = seeding.UserGroupOpPermJoinUser type FullEvidence = seeding.FullEvidence // Exported functions/helpers var initTest = seeding.InitTest var getUsersWithRoleForOperationByOperationID = seeding.GetUsersWithRoleForOperationByOperationID +var getUserGroupsWithRoleForOperationByOperationID = seeding.GetUserGroupsWithRoleForOperationByOperationID var contextForUser = seeding.ContextForUser var GetInternalClock = seeding.GetInternalClock @@ -65,6 +67,7 @@ var getAuthsForUser = seeding.GetAuthsForUser var getUsersForAuth = seeding.GetUsersForAuth var getRealUsers = seeding.GetRealUsers var getTagUsage = seeding.GetTagUsage +var getUserGroupFromSlug = seeding.GetUserGroupFromSlug var getServiceWorkerByName = seeding.GetServiceWorkerByName var getServiceWorkerByID = seeding.GetServiceWorkerByID @@ -109,6 +112,12 @@ var UserParvati = seeding.UserParvati var UserPadma = seeding.UserPadma var UserCho = seeding.UserCho +var UserGroupGryffindor = seeding.UserGroupGryffindor +var UserGroupSlytherin = seeding.UserGroupSlytherin +var UserGroupHufflepuff = seeding.UserGroupHufflepuff +var UserGroupRavenclaw = seeding.UserGroupRavenclaw +var UserGroupOtherHouse = seeding.UserGroupOtherHouse + var APIKeyHarry1 = seeding.APIKeyHarry1 var APIKeyHarry2 = seeding.APIKeyHarry2 var APIKeyRon1 = seeding.APIKeyRon1 @@ -227,6 +236,10 @@ func (seed TestSeedData) UserRoleForOp(user models.User, op models.Operation) po return seed.Seeder.UserRoleForOp(user, op) } +func (seed TestSeedData) UserGroupRoleForOp(userGroup models.UserGroup, op models.Operation) policy.OperationRole { + return seed.Seeder.UserGroupRoleForOp(userGroup, op) +} + func (seed TestSeedData) EvidenceForOperation(opID int64) []models.Evidence { return seed.Seeder.EvidenceForOperation(opID) } diff --git a/backend/services/service_helper_user_group_filter.go b/backend/services/service_helper_user_group_filter.go new file mode 100644 index 000000000..2897011c1 --- /dev/null +++ b/backend/services/service_helper_user_group_filter.go @@ -0,0 +1,34 @@ +// Copyright 2023, Yahoo Inc. +// Licensed under the terms of the MIT. See LICENSE file in project root for terms. + +package services + +import ( + "strings" + + "github.com/theparanoids/ashirt-server/backend/server/dissectors" + + sq "github.com/Masterminds/squirrel" +) + +// UserFilter provides a mechanism to alter queries such that users are filtered +type UserGroupFilter struct { + NameParts []string + UserGroupsTable string +} + +// ParseRequestQueryUserFilter generates a UserFilter object from a given request. +// This expects that filtering is specified by the query parameter "name" +func ParseRequestQueryUserGroupFilter(dr dissectors.DissectedRequest) UserGroupFilter { + return UserGroupFilter{ + NameParts: strings.Fields(dr.FromQuery("name").OrDefault("").AsString()), + UserGroupsTable: "user_groups", + } +} + +// AddWhere adds to the given SelectBuilder a Where clause that will apply the filtering +func (uf *UserGroupFilter) AddWhere(sb *sq.SelectBuilder) { + if len(uf.NameParts) > 0 { + *sb = sb.Where(sq.Like{"name": "%" + strings.Join(uf.NameParts, "%") + "%"}) + } +} diff --git a/backend/services/user_groups.go b/backend/services/user_groups.go new file mode 100644 index 000000000..175f0e0f4 --- /dev/null +++ b/backend/services/user_groups.go @@ -0,0 +1,406 @@ +// Copyright 2022, Yahoo Inc. +// Licensed under the terms of the MIT. See LICENSE file in project root for terms. + +package services + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode" + + "github.com/theparanoids/ashirt-server/backend" + "github.com/theparanoids/ashirt-server/backend/database" + "github.com/theparanoids/ashirt-server/backend/dtos" + "github.com/theparanoids/ashirt-server/backend/models" + "github.com/theparanoids/ashirt-server/backend/policy" + "github.com/theparanoids/ashirt-server/backend/server/middleware" + + sq "github.com/Masterminds/squirrel" +) + +type CreateUserGroupInput struct { + Name string + Slug string + UserSlugs []string +} + +type ModifyUserGroupInput struct { + Name string + Slug string + UsersToAdd []string + UsersToRemove []string +} + +type ListUserGroupsForAdminInput struct { + UserGroupFilter + Pagination + IncludeDeleted bool +} + +type ListUserGroupsForOperationInput struct { + Pagination + UserGroupFilter + OperationSlug string +} + +type userGroupAndRole struct { + models.UserGroup + Role policy.OperationRole `db:"role"` +} + +type ListUserGroupsInput struct { + Query string + IncludeDeleted bool + OperationSlug string +} + +func (i ModifyUserGroupInput) validateUserGroupInput() error { + if i.Slug == "" { + return backend.MissingValueErr("Slug") + } + return nil +} + +func (i CreateUserGroupInput) validateUserGroupInput() error { + if i.Slug == "" { + return backend.MissingValueErr("Slug") + } + if i.Name == "" { + return backend.MissingValueErr("Name") + } + return nil +} + +func AddUsersToGroup(tx *database.Transactable, userSlugs []string, groupID int64) error { + if len(userSlugs) == 0 { + return nil + } + + peopleToAdd := sq.Select("id", strconv.FormatInt(groupID, 10)). + From("users"). + Where(sq.Eq{"slug": userSlugs}) + + insertQuery := sq. + Insert("group_user_map"). + Options("Ignore"). + Columns("user_id", "group_id").Select(peopleToAdd) + + err := tx.Exec(insertQuery) + + if err != nil { + return backend.WrapError("Unable to add users to group", backend.DatabaseErr(err)) + } + return nil +} + +func CreateUserGroup(ctx context.Context, db *database.Connection, i CreateUserGroupInput) (*dtos.UserGroup, error) { + if err := isAdmin(ctx); err != nil { + return nil, backend.WrapError("Unwilling to create a user group", backend.UnauthorizedReadErr(err)) + } + + cleanSlug := SanitizeSlug(i.Slug) + if cleanSlug == "" { + return nil, backend.BadInputErr(errors.New("Unable to create operation. Invalid operation slug"), "Slug must contain english letters or numbers") + } + + err := db.WithTx(context.Background(), func(tx *database.Transactable) { + id, _ := tx.Insert("user_groups", map[string]interface{}{ + "slug": cleanSlug, + "name": i.Name, + }) + AddUsersToGroup(tx, i.UserSlugs, id) + }) + + if err != nil { + return nil, backend.WrapError("Error creating user group", backend.BadInputErr(err, "A user group with this slug already exists; please choose another name")) + } + return &dtos.UserGroup{ + Slug: i.Slug, + Name: i.Name, + }, nil +} + +func ModifyUserGroup(ctx context.Context, db *database.Connection, i ModifyUserGroupInput) (*dtos.UserGroup, error) { + if err := isAdmin(ctx); err != nil { + return nil, backend.WrapError("Unwilling to modify a user group", backend.UnauthorizedReadErr(err)) + } + + if err := i.validateUserGroupInput(); err != nil { + return nil, backend.WrapError("Unable to modify user group", backend.BadInputErr(err, "Unable to modify user group due to bad input")) + } + + userGroup, err := lookupUserGroup(db, i.Slug) + if err != nil { + return nil, backend.WrapError("Unable to modify user group", backend.UnauthorizedWriteErr(err)) + } + + err = db.WithTx(context.Background(), func(tx *database.Transactable) { + if i.Name != "" { + tx.Update(sq.Update("user_groups").Set("name", i.Name).Where(sq.Eq{"id": userGroup.ID})) + } + if len(i.UsersToRemove) > 0 { + interfaceSlice := make([]interface{}, len(i.UsersToRemove)) + questionMarks := "(" + + for i, v := range i.UsersToRemove { + questionMarks += "?, " + interfaceSlice[i] = v + } + + questionMarks = strings.TrimSuffix(questionMarks, ", ") + questionMarks += ")" + + sqlStatement := fmt.Sprintf(`DELETE gm FROM group_user_map gm JOIN users u on gm.user_id = u.id WHERE u.slug in %s;`, questionMarks) + tx.Exec(sq.Expr(sqlStatement, interfaceSlice...)) + } + AddUsersToGroup(tx, i.UsersToAdd, userGroup.ID) + }) + if err != nil { + return nil, backend.WrapError("Error creating user group", backend.BadInputErr(err, "A user group with this name already exists; please choose another name")) + } + + return &dtos.UserGroup{ + Slug: i.Slug, + Name: i.Name, + }, nil +} + +func DeleteUserGroup(ctx context.Context, db *database.Connection, slug string) error { + if err := isAdmin(ctx); err != nil { + return backend.WrapError("Unwilling to delete a user group", backend.UnauthorizedReadErr(err)) + } + userGroup, err := lookupUserGroup(db, slug) + if err != nil { + return backend.WrapError("Unable to delete user group", backend.UnauthorizedWriteErr(err)) + } + + err = db.WithTx(context.Background(), func(tx *database.Transactable) { + tx.Delete(sq.Delete("user_group_operation_permissions").Where(sq.Eq{"group_id": userGroup.ID})) + tx.Update(sq.Update("user_groups").Set("deleted_at", time.Now()).Where(sq.Eq{"slug": slug})) + }) + if err != nil { + return backend.WrapError("Cannot delete user group", backend.DatabaseErr(err)) + } + + return nil +} + +type SlugMap []struct { + UserSlug sql.NullString `db:"user_slug"` + GroupSlug string `db:"group_slug"` + GroupName string `db:"group_name"` + Deleted sql.NullString `db:"deleted"` +} + +// Lists all usergroups for an admin, with pagination +func ListUserGroupsForAdmin(ctx context.Context, db *database.Connection, i ListUserGroupsForAdminInput) (*dtos.PaginationWrapper, error) { + if err := isAdmin(ctx); err != nil { + return nil, backend.WrapError("Unwilling to list user groups", backend.UnauthorizedReadErr(err)) + } + + slugMap, _ := GetSlugMap(db, i) + + paginatedSortedUser, err := SortUsersInToGroups(slugMap, i.Pagination) + + if err != nil { + return nil, backend.WrapError("Unable to list user groups", backend.DatabaseErr(err)) + } + + return paginatedSortedUser, nil +} + +func GetSlugMap(db *database.Connection, i ListUserGroupsForAdminInput) (SlugMap, error) { + sb := sq.Select("user_groups.slug AS group_slug, user_groups.name AS group_name, users.slug AS user_slug, user_groups.deleted_at AS deleted"). + From("group_user_map"). + Join("users ON group_user_map.user_id = users.id"). + RightJoin("user_groups ON group_user_map.group_id = user_groups.id") + + i.AddWhere(&sb) + + if !i.IncludeDeleted { + sb = sb.Where(sq.Eq{"user_groups.deleted_at": nil}) + } + + sb = sb.OrderBy("group_name") + + var slugMap SlugMap + + err := db.Select(&slugMap, sb) + + if err != nil { + return nil, backend.WrapError("unable to get map of user IDs to group IDs from database", backend.DatabaseErr(err)) + } + + return slugMap, nil +} + +func SortUsersInToGroups(slugMap SlugMap, pagination Pagination) (*dtos.PaginationWrapper, error) { + userGroupsDTO := []dtos.UserGroupAdminView{} + tempGroupMap := dtos.UserGroupAdminView{} + + if len(slugMap) == 0 { + return &dtos.PaginationWrapper{ + TotalCount: int64(0), + }, nil + } + + for j := 0; j < len(slugMap); j++ { + firstItem := j == 0 + isLastItem := j == len(slugMap)-1 + otherItem := j > 0 && j < len(slugMap)-1 + hasUserSlug := slugMap[j].UserSlug.Valid + groupWithNoUsers := !hasUserSlug + sameGroupAsPrev := false + if j > 0 { + sameGroupAsPrev = slugMap[j].GroupSlug == slugMap[j-1].GroupSlug + } + diffGroup := !sameGroupAsPrev + + if firstItem && hasUserSlug { + tempGroupMap = dtos.UserGroupAdminView{ + Slug: slugMap[j].GroupSlug, + Name: slugMap[j].GroupName, + UserSlugs: []string{ + slugMap[j].UserSlug.String, + }, + Deleted: slugMap[j].Deleted.Valid, + } + } else if firstItem && groupWithNoUsers { + tempGroupMap = dtos.UserGroupAdminView{ + Slug: slugMap[j].GroupSlug, + Name: slugMap[j].GroupName, + Deleted: slugMap[j].Deleted.Valid, + } + } else if otherItem && sameGroupAsPrev && hasUserSlug { + tempGroupMap.UserSlugs = append(tempGroupMap.UserSlugs, slugMap[j].UserSlug.String) + } else if otherItem && diffGroup && hasUserSlug { + userGroupsDTO = append(userGroupsDTO, tempGroupMap) + tempGroupMap = dtos.UserGroupAdminView{ + Slug: slugMap[j].GroupSlug, + Name: slugMap[j].GroupName, + UserSlugs: []string{ + slugMap[j].UserSlug.String, + }, + Deleted: slugMap[j].Deleted.Valid, + } + } else if otherItem && diffGroup && groupWithNoUsers { + userGroupsDTO = append(userGroupsDTO, tempGroupMap) + tempGroupMap = dtos.UserGroupAdminView{ + Slug: slugMap[j].GroupSlug, + Name: slugMap[j].GroupName, + Deleted: slugMap[j].Deleted.Valid, + } + } else if isLastItem && sameGroupAsPrev && hasUserSlug { + tempGroupMap.UserSlugs = append(tempGroupMap.UserSlugs, slugMap[j].UserSlug.String) + userGroupsDTO = append(userGroupsDTO, tempGroupMap) + } else if isLastItem && diffGroup && hasUserSlug { + userGroupsDTO = append(userGroupsDTO, tempGroupMap) + tempGroupMap = dtos.UserGroupAdminView{ + Slug: slugMap[j].GroupSlug, + Name: slugMap[j].GroupName, + UserSlugs: []string{ + slugMap[j].UserSlug.String, + }, + Deleted: slugMap[j].Deleted.Valid, + } + userGroupsDTO = append(userGroupsDTO, tempGroupMap) + } else if isLastItem && groupWithNoUsers { + userGroupsDTO = append(userGroupsDTO, tempGroupMap) + tempGroupMap = dtos.UserGroupAdminView{ + Slug: slugMap[j].GroupSlug, + Name: slugMap[j].GroupName, + Deleted: slugMap[j].Deleted.Valid, + } + userGroupsDTO = append(userGroupsDTO, tempGroupMap) + } + } + + groupLength := len(userGroupsDTO) + + paginatedData := &dtos.PaginationWrapper{ + Content: userGroupsDTO, + TotalCount: int64(groupLength), + } + return paginatedData, nil +} + +// Lists all user groups for an operation; op admins and sys admins can view +func ListUserGroupsForOperation(ctx context.Context, db *database.Connection, i ListUserGroupsForOperationInput) ([]*dtos.UserGroupOperationRole, error) { + operation, err := lookupOperation(db, i.OperationSlug) + if err := policyRequireWithAdminBypass(ctx, policy.CanListUserGroupsOfOperation{OperationID: operation.ID}); err != nil { + return nil, backend.WrapError("Unwilling to list usergroups", backend.UnauthorizedReadErr(err)) + } + + query := sq.Select("slug", "name", "role"). + From("user_group_operation_permissions"). + LeftJoin("user_groups ON user_group_operation_permissions.group_id = user_groups.id"). + Where(sq.Eq{"operation_id": operation.ID, "user_groups.deleted_at": nil}). + OrderBy("user_group_operation_permissions.created_at ASC") + + i.UserGroupFilter.AddWhere(&query) + + var userGroups []userGroupAndRole + err = db.Select(&userGroups, query) + if err != nil { + return nil, backend.WrapError("Cannot list user groups for operation", backend.DatabaseErr(err)) + } + userGroupsDTO := wrapListUserGroupsForOperationResponse(userGroups) + return userGroupsDTO, nil +} + +func wrapListUserGroupsForOperationResponse(userGroups []userGroupAndRole) []*dtos.UserGroupOperationRole { + userGroupsDTO := make([]*dtos.UserGroupOperationRole, len(userGroups)) + for idx, userGroup := range userGroups { + userGroupsDTO[idx] = &dtos.UserGroupOperationRole{ + UserGroup: dtos.UserGroupAdminView{ + Slug: userGroup.Slug, + Name: userGroup.Name, + }, + Role: userGroup.Role, + } + } + return userGroupsDTO +} + +// lists all user groups that can be added to an operation +// no pagination, because this is used for the search bar +func ListUserGroups(ctx context.Context, db *database.Connection, i ListUserGroupsInput) ([]*dtos.UserGroupAdminView, error) { + operation, err := lookupOperation(db, i.OperationSlug) + if err := policyRequireWithAdminBypass(ctx, policy.CanListUserGroupsOfOperation{OperationID: operation.ID}); err != nil { + return nil, backend.WrapError("Unwilling to list usergroups", backend.UnauthorizedReadErr(err)) + } + + if strings.ContainsAny(i.Query, "%_") || strings.TrimFunc(i.Query, unicode.IsSpace) == "" { + return []*dtos.UserGroupAdminView{}, nil + } + + var userGroups []models.UserGroup + query := sq.Select("slug", "name"). + From("user_groups"). + Where(sq.Like{"name": "%" + strings.ReplaceAll(i.Query, " ", "%") + "%"}). + OrderBy("name"). + Limit(10) + if !i.IncludeDeleted { + query = query.Where(sq.Eq{"deleted_at": nil}) + } + err = db.Select(&userGroups, query) + if err != nil { + return nil, backend.WrapError("Cannot list user groups", backend.DatabaseErr(err)) + } + + userGroupsDTO := []*dtos.UserGroupAdminView{} + for _, userGroup := range userGroups { + if middleware.Policy(ctx).Check(policy.CanReadUser{UserID: userGroup.ID}) { + userGroupsDTO = append(userGroupsDTO, &dtos.UserGroupAdminView{ + Slug: userGroup.Slug, + Name: userGroup.Name, + }) + } + } + return userGroupsDTO, nil +} diff --git a/backend/services/user_groups_test.go b/backend/services/user_groups_test.go new file mode 100644 index 000000000..4945b6cb1 --- /dev/null +++ b/backend/services/user_groups_test.go @@ -0,0 +1,518 @@ +// Copyright 2022, Yahoo Inc. +// Licensed under the terms of the MIT. See LICENSE file in project root for terms. + +package services_test + +import ( + "context" + "database/sql" + "fmt" + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/theparanoids/ashirt-server/backend" + "github.com/theparanoids/ashirt-server/backend/database" + "github.com/theparanoids/ashirt-server/backend/dtos" + "github.com/theparanoids/ashirt-server/backend/models" + "github.com/theparanoids/ashirt-server/backend/services" +) + +type userGroupValidator func(*testing.T, UserOpPermJoinUser, *dtos.UserOperationRole) + +func getUserIDsFromGroup(db *database.Connection, groupSlug string) ([]int64, error) { + var userGroupId int64 + err := db.Get(&userGroupId, sq.Select("id"). + From("user_groups"). + Where(sq.Eq{ + "slug": groupSlug, + })) + if err != nil { + s := fmt.Sprintf("Cannot get user group id for group %q", groupSlug) + return nil, backend.WrapError(s, backend.DatabaseErr(err)) + } + + var userGroupMap []int64 + err = db.Select(&userGroupMap, sq.Select("user_id"). + From("group_user_map"). + Where(sq.Eq{ + "group_id": userGroupId, + })) + if err != nil { + s := fmt.Sprintf("Cannot get user group map for group %q", userGroupId) + return userGroupMap, backend.WrapError(s, backend.DatabaseErr(err)) + } + return userGroupMap, nil +} + +func TestAddUsersToGroup(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + gryffindorUserGroup := UserGroupGryffindor + + usersToAdd := []string{ + UserAlastor.Slug, + UserHagrid.Slug, + } + + err := db.WithTx(context.Background(), func(tx *database.Transactable) { + services.AddUsersToGroup(tx, usersToAdd, gryffindorUserGroup.ID) + }) + + require.NoError(t, err) + + userIDs, err := getUserIDsFromGroup(db, gryffindorUserGroup.Slug) + require.NoError(t, err) + require.Equal(t, 6, len(userIDs)) + for _, userID := range userIDs { + require.Contains(t, []int64{UserHarry.ID, UserRon.ID, UserHermione.ID, UserAlastor.ID, UserHagrid.ID, UserGinny.ID}, userID) + } + }) +} + +func TestCreateUserGroup(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + slug := "testGroup" + userSlugs := []string{ + UserRon.Slug, + UserAlastor.Slug, + UserHagrid.Slug, + } + i := services.CreateUserGroupInput{ + Name: slug, + Slug: slug, + UserSlugs: userSlugs, + } + + nonAdminUser := UserRon + ctx := contextForUser(nonAdminUser, db) + + _, err := services.CreateUserGroup(ctx, db, i) + // verify that non-admin user cannot create user groups + require.Error(t, err) + + adminUser := UserDumbledore + ctx = contextForUser(adminUser, db) + _, err = services.CreateUserGroup(ctx, db, i) + require.NoError(t, err) + + userIDs, err := getUserIDsFromGroup(db, slug) + require.NoError(t, err) + require.Equal(t, len(userSlugs), len(userIDs)) + for _, userID := range userIDs { + require.Contains(t, []int64{UserRon.ID, UserAlastor.ID, UserHagrid.ID}, userID) + } + _, err = services.CreateUserGroup(ctx, db, i) + assert.Error(t, err) + }) +} + +func TestModifyUserGroup(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + nonAdminUser := UserRon + ctx := contextForUser(nonAdminUser, db) + + gryffindorUserGroup := UserGroupGryffindor + newName := "Glyssintor" + usersToAdd := []string{ + UserAlastor.Slug, + UserHagrid.Slug, + } + usersToRemove := []string{ + UserRon.Slug, + UserHermione.Slug, + } + i := services.ModifyUserGroupInput{ + Name: newName, + Slug: gryffindorUserGroup.Slug, + UsersToAdd: usersToAdd, + UsersToRemove: usersToRemove, + } + + _, err := services.ModifyUserGroup(ctx, db, i) + // verify that non-admin user cannot modify a user group + require.Error(t, err) + + adminUser := UserDumbledore + ctx = contextForUser(adminUser, db) + + result, err := services.ModifyUserGroup(ctx, db, i) + require.NoError(t, err) + fullUserGroup := getUserGroupFromSlug(t, db, result.Slug) + require.Equal(t, newName, fullUserGroup.Name) + + userIDs, err := getUserIDsFromGroup(db, gryffindorUserGroup.Slug) + require.NoError(t, err) + require.Equal(t, 4, len(userIDs)) + for _, userID := range userIDs { + require.Contains(t, []int64{UserHarry.ID, UserAlastor.ID, UserHagrid.ID, UserGinny.ID}, userID) + } + }) +} + +func TestDeleteUserGroup(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + nonAdminUser := UserRon + ctx := contextForUser(nonAdminUser, db) + userGroup := UserGroupGryffindor + + err := services.DeleteUserGroup(ctx, db, userGroup.Slug) + // verify that non-admin user cannot delete a user group + require.Error(t, err) + + adminUser := UserDumbledore + ctx = contextForUser(adminUser, db) + + err = services.DeleteUserGroup(ctx, db, userGroup.Slug) + require.NoError(t, err) + + userIDs, err := getUserIDsFromGroup(db, userGroup.Slug) + require.NoError(t, err) + // 4 users in UserGroupGryffindor + require.Equal(t, 4, len(userIDs)) + }) +} + +func TestListUserGroupsForAdmin(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + nonAdminUser := UserRon + ctx := contextForUser(nonAdminUser, db) + + i := services.ListUserGroupsForAdminInput{ + Pagination: services.Pagination{ + TotalCount: 4, + PageSize: 10, + Page: 1, + }, + IncludeDeleted: false, + } + + _, err := services.ListUserGroupsForAdmin(ctx, db, i) + // verify that non-admin user cannot list user groups + require.Error(t, err) + + adminUser := UserDumbledore + ctx = contextForUser(adminUser, db) + + _, err = services.ListUserGroupsForAdmin(ctx, db, i) + require.NoError(t, err) + }) +} + +func TestGetSlugMap(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + i := services.ListUserGroupsForAdminInput{ + Pagination: services.Pagination{ + TotalCount: 4, + PageSize: 10, + Page: 1, + }, + IncludeDeleted: true, + } + + slugMap, err := services.GetSlugMap(db, i) + require.NoError(t, err) + require.Equal(t, 12, len(slugMap)) + for _, slugMapEntry := range slugMap { + userName := slugMapEntry.UserSlug.String + if userName != "" { + require.Contains(t, []string{UserHarry.Slug, UserGinny.Slug, UserRon.Slug, UserHermione.Slug, UserCedric.Slug, UserCho.Slug, UserFleur.Slug, UserViktor.Slug, UserSnape.Slug, UserLucius.Slug, UserDraco.Slug}, userName) + } + if slugMapEntry.Deleted.Valid == true { + require.Equal(t, UserGroupOtherHouse.Slug, slugMapEntry.GroupSlug) + } + } + + // test for non-deleted user groups + i = services.ListUserGroupsForAdminInput{ + Pagination: services.Pagination{ + TotalCount: 4, + PageSize: 10, + Page: 1, + }, + IncludeDeleted: false, + } + + slugMap, err = services.GetSlugMap(db, i) + require.NoError(t, err) + require.Equal(t, 11, len(slugMap)) + }) +} + +func TestSortUsersInToGroups(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + slugMap := services.SlugMap{ + { + UserSlug: sql.NullString{ + String: UserHarry.Slug, + Valid: true, + }, + GroupSlug: UserGroupGryffindor.Slug, + GroupName: UserGroupGryffindor.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserRon.Slug, + Valid: true, + }, + GroupSlug: UserGroupGryffindor.Slug, + GroupName: UserGroupGryffindor.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserGinny.Slug, + Valid: true, + }, + GroupSlug: UserGroupGryffindor.Slug, + GroupName: UserGroupGryffindor.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserHermione.Slug, + Valid: true, + }, + GroupSlug: UserGroupGryffindor.Slug, + GroupName: UserGroupGryffindor.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserCedric.Slug, + Valid: true, + }, + GroupSlug: UserGroupHufflepuff.Slug, + GroupName: UserGroupHufflepuff.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserFleur.Slug, + Valid: true, + }, + GroupSlug: UserGroupHufflepuff.Slug, + GroupName: UserGroupHufflepuff.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + // Includes groups without a user, we need to return those groups as well + { + UserSlug: sql.NullString{ + String: "", + Valid: false, + }, + GroupSlug: UserGroupOtherHouse.Slug, + GroupName: UserGroupOtherHouse.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserViktor.Slug, + Valid: true, + }, + GroupSlug: UserGroupRavenclaw.Slug, + GroupName: UserGroupRavenclaw.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserCho.Slug, + Valid: true, + }, + GroupSlug: UserGroupRavenclaw.Slug, + GroupName: UserGroupRavenclaw.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserDraco.Slug, + Valid: true, + }, + GroupSlug: UserGroupSlytherin.Slug, + GroupName: UserGroupSlytherin.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserSnape.Slug, + Valid: true, + }, + GroupSlug: UserGroupSlytherin.Slug, + GroupName: UserGroupSlytherin.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + { + UserSlug: sql.NullString{ + String: UserLucius.Slug, + Valid: true, + }, + GroupSlug: UserGroupSlytherin.Slug, + GroupName: UserGroupSlytherin.Name, + Deleted: sql.NullString{ + String: "", + Valid: false, + }, + }, + } + + p := services.Pagination{ + PageSize: 10, + Page: 1, + TotalCount: 1, + } + + result, err := services.SortUsersInToGroups(slugMap, p) + require.NoError(t, err) + var content = result.Content.([]dtos.UserGroupAdminView) + require.Equal(t, int64(5), result.TotalCount) + + require.Equal(t, UserGroupGryffindor.Name, content[0].Name) + require.Equal(t, UserGroupGryffindor.Slug, content[0].Slug) + require.Equal(t, false, content[0].Deleted) + for _, userSlug := range content[0].UserSlugs { + require.Contains(t, []string{UserHarry.Slug, UserGinny.Slug, UserRon.Slug, UserHermione.Slug}, userSlug) + } + + require.Equal(t, UserGroupHufflepuff.Name, content[1].Name) + require.Equal(t, UserGroupHufflepuff.Slug, content[1].Slug) + require.Equal(t, false, content[1].Deleted) + for _, userSlug := range content[1].UserSlugs { + require.Contains(t, []string{UserFleur.Slug, UserCedric.Slug}, userSlug) + } + + require.Equal(t, UserGroupOtherHouse.Name, content[2].Name) + require.Equal(t, UserGroupOtherHouse.Slug, content[2].Slug) + require.Equal(t, false, content[2].Deleted) + for _, userSlug := range content[2].UserSlugs { + require.Contains(t, []string{UserViktor.Slug, UserCho.Slug}, userSlug) + } + + require.Equal(t, UserGroupRavenclaw.Name, content[3].Name) + require.Equal(t, UserGroupRavenclaw.Slug, content[3].Slug) + require.Equal(t, false, content[3].Deleted) + for _, userSlug := range content[3].UserSlugs { + require.Contains(t, []string{UserViktor.Slug, UserCho.Slug}, userSlug) + } + + require.Equal(t, UserGroupSlytherin.Name, content[4].Name) + require.Equal(t, UserGroupSlytherin.Name, content[4].Slug) + require.Equal(t, false, content[4].Deleted) + for _, userSlug := range content[4].UserSlugs { + require.Contains(t, []string{UserDraco.Slug, UserSnape.Slug, UserLucius.Slug}, userSlug) + } + + // if len(slugMap) == 0 + result, err = services.SortUsersInToGroups(services.SlugMap{}, p) + require.Equal(t, int64(0), result.TotalCount) + + require.NoError(t, err) + }) +} + +func TestListUserGroupsForOperation(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + ctx := contextForUser(UserRon, db) + + masterOp := OpSorcerersStone + allUserGroupOpRoles := getUserGroupsWithRoleForOperationByOperationID(t, db, masterOp.ID) + require.NotEqual(t, len(allUserGroupOpRoles), 0, "Some user groups should be attached to this operation") + + input := services.ListUserGroupsForOperationInput{ + OperationSlug: masterOp.Slug, + } + + content, err := services.ListUserGroupsForOperation(ctx, db, input) + // Ron is not an operation admin, so he should not be able to list user groups + require.Error(t, err) + + ctx = contextForUser(UserHarry, db) + content, err = services.ListUserGroupsForOperation(ctx, db, input) + require.NoError(t, err) + + require.Equal(t, len(content), len(allUserGroupOpRoles)) + validateUserGroupSets(t, content, allUserGroupOpRoles) + }) +} + +func TestListUserGroups(t *testing.T) { + RunResettableDBTest(t, func(db *database.Connection, _ TestSeedData) { + testListUserGroupsCase(t, db, "gryf", true, []models.UserGroup{UserGroupGryffindor}) + testListUserGroupsCase(t, db, "ff", true, []models.UserGroup{UserGroupGryffindor, UserGroupHufflepuff}) + testListUserGroupsCase(t, db, "l", true, []models.UserGroup{UserGroupHufflepuff, UserGroupRavenclaw, UserGroupSlytherin}) + testListUserGroupsCase(t, db, "", true, []models.UserGroup{}) + testListUserGroupsCase(t, db, " ", true, []models.UserGroup{}) + testListUserGroupsCase(t, db, "%", true, []models.UserGroup{}) + testListUserGroupsCase(t, db, "*", true, []models.UserGroup{}) + testListUserGroupsCase(t, db, "___", true, []models.UserGroup{}) + + // test for deleted user filtering + testListUserGroupsCase(t, db, UserGroupOtherHouse.Name, true, []models.UserGroup{UserGroupOtherHouse}) + testListUserGroupsCase(t, db, UserTomRiddle.LastName, false, []models.UserGroup{}) + }) +} + +func testListUserGroupsCase(t *testing.T, db *database.Connection, query string, includeDeleted bool, expectedUserGroups []models.UserGroup) { + ctx := contextForUser(UserDumbledore, db) + + userGroups, err := services.ListUserGroups(ctx, db, services.ListUserGroupsInput{Query: query, IncludeDeleted: includeDeleted}) + require.NoError(t, err) + + require.Equal(t, len(expectedUserGroups), len(userGroups), "Expected %d users for query '%s' but got %d", len(expectedUserGroups), query, len(userGroups)) + + for i := range expectedUserGroups { + require.Equal(t, expectedUserGroups[i].Slug, userGroups[i].Slug) + require.Equal(t, expectedUserGroups[i].Name, userGroups[i].Name) + } +} + +func validateUserGroupSets(t *testing.T, dtoSet []*dtos.UserGroupOperationRole, dbSet []UserGroupOpPermJoinUser) { + var expected *UserGroupOpPermJoinUser = nil + + for _, dtoItem := range dtoSet { + expected = nil + for _, dbItem := range dbSet { + if dbItem.Slug == dtoItem.UserGroup.Slug { + expected = &dbItem + break + } + } + require.NotNil(t, expected, "Result should have matching value") + require.Equal(t, expected.Slug, dtoItem.UserGroup.Slug) + require.Equal(t, expected.Name, dtoItem.UserGroup.Name) + require.Equal(t, expected.Role, dtoItem.Role) + } +} diff --git a/frontend/src/components/checkbox_complex/check.svg b/frontend/src/components/checkbox_complex/check.svg new file mode 100644 index 000000000..b58ca7bb5 --- /dev/null +++ b/frontend/src/components/checkbox_complex/check.svg @@ -0,0 +1,3 @@ + diff --git a/frontend/src/components/checkbox_complex/index.tsx b/frontend/src/components/checkbox_complex/index.tsx new file mode 100644 index 000000000..9759d5cc3 --- /dev/null +++ b/frontend/src/components/checkbox_complex/index.tsx @@ -0,0 +1,27 @@ +// Copyright 2023, Yahoo Inc. +// Licensed under the terms of the MIT. See LICENSE file in project root for terms. + +import * as React from 'react' +import classnames from 'classnames/bind' +const cx = classnames.bind(require('./stylesheet')) + +export default (props: { + className?: string, + value?: boolean, + label?: string, + title?: string, + disabled?: boolean, + onChange?: (...args: any[]) => void, +}) => ( + +) diff --git a/frontend/src/components/checkbox_complex/stylesheet.styl b/frontend/src/components/checkbox_complex/stylesheet.styl new file mode 100644 index 000000000..159a3d2ee --- /dev/null +++ b/frontend/src/components/checkbox_complex/stylesheet.styl @@ -0,0 +1,46 @@ +@import '~src/vars' + +.root + display: block + position: relative + height: 16px + padding-left: 26px + box-sizing: border-box + line-height: @height + font-size: $label-text-size + cursor: pointer + + input + position: absolute + top: 0 + left: 0 + opacity: 0 + cursor: inherit + +.indicator + position: absolute + top: 0 + left: 0 + width: 16px + height: @width + background: $lighter-background linear-gradient(180deg, rgba(#fff,.05), rgba(#fff,0)) + box-shadow: 0 0 0 1px $darker-background + border-radius: 3px + + &:before + content: "" + display: block + height: 100% + +.root input + &:checked + .indicator + background-color: $primary + + &:disabled + .indicator + background-color: $background + + &:checked + .indicator:before + background-image: url('./check.svg') + + &:focus + .indicator + box-shadow: 0 0 1px 1px $primary diff --git a/frontend/src/components/operation_badges_modal/index.tsx b/frontend/src/components/operation_badges_modal/index.tsx index 428a81115..7780d3902 100644 --- a/frontend/src/components/operation_badges_modal/index.tsx +++ b/frontend/src/components/operation_badges_modal/index.tsx @@ -16,11 +16,11 @@ export default (props: { }) => { const evidenceNameMap = { - imageCount: 'Images', - codeblockCount: 'Codeblocks', - recordingCount: 'Recordings', - eventCount: 'Events', - harCount: 'HAR files', + imageCount: 'Image', + codeblockCount: 'Codeblock', + recordingCount: 'Recording', + eventCount: 'Event', + harCount: 'HAR file', } type ObjectKey = keyof typeof evidenceNameMap; const evidencePresent = Object.values(props.evidenceCount).reduce((prev, curr) => prev + curr, 0) > 0 @@ -46,13 +46,20 @@ export default (props: {
{evidenceNameMap[ebc[0] as ObjectKey].toUpperCase()}
-{upperCaseLabel}
+{userSlug}
) + return <>{userList}> + })} + + }> + +Group has been created successfully!
+ +Group has been modified successfully!
+ +{`${user.firstName} ${user.lastName}`} | +
+ |
+