diff --git a/changelog/unreleased/pr-19547.toml b/changelog/unreleased/pr-19547.toml new file mode 100644 index 000000000000..b334a6bc50bb --- /dev/null +++ b/changelog/unreleased/pr-19547.toml @@ -0,0 +1,5 @@ +type = "a" +message = "Added entity groups feature." + +issues = ["graylog-plugin-enterprise#7385"] +pulls = ["19547", "graylog-plugin-enterprise#7585"] diff --git a/graylog2-server/src/main/java/org/graylog2/commands/Server.java b/graylog2-server/src/main/java/org/graylog2/commands/Server.java index 875b47250531..82ff6fa6f45f 100644 --- a/graylog2-server/src/main/java/org/graylog2/commands/Server.java +++ b/graylog2-server/src/main/java/org/graylog2/commands/Server.java @@ -84,6 +84,7 @@ import org.graylog2.database.entities.ScopedEntitiesModule; import org.graylog2.datatiering.DataTieringModule; import org.graylog2.decorators.DecoratorBindings; +import org.graylog2.entitygroups.EntityGroupModule; import org.graylog2.featureflag.FeatureFlags; import org.graylog2.indexer.FieldTypeManagementModule; import org.graylog2.indexer.IndexerBindings; @@ -215,7 +216,8 @@ protected List getCommandBindings(FeatureFlags featureFlags) { new DataTieringModule(), new DatanodeMigrationBindings(), new CaModule(), - new TelemetryModule() + new TelemetryModule(), + new EntityGroupModule(featureFlags) ); modules.add(new FieldTypeManagementModule()); diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupAuditEventTypes.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupAuditEventTypes.java new file mode 100644 index 000000000000..0c23f766e9ed --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupAuditEventTypes.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups; + +import com.google.common.collect.ImmutableSet; +import org.graylog2.audit.PluginAuditEventTypes; + +import java.util.Set; + +public class EntityGroupAuditEventTypes implements PluginAuditEventTypes { + private static final String NAMESPACE = "entity_groups"; + private static final String PREFIX = NAMESPACE + ":entity_group"; + + public static final String ENTITY_GROUP_CREATE = PREFIX + ":create"; + public static final String ENTITY_GROUP_UPDATE = PREFIX + ":update"; + public static final String ENTITY_GROUP_ADD_ENTITY = PREFIX + ":addEntity"; + public static final String ENTITY_GROUP_DELETE = PREFIX + ":delete"; + + private static final Set EVENT_TYPES = ImmutableSet.builder() + .add(ENTITY_GROUP_CREATE) + .add(ENTITY_GROUP_UPDATE) + .add(ENTITY_GROUP_ADD_ENTITY) + .add(ENTITY_GROUP_DELETE) + .build(); + + @Override + public Set auditEventTypes() { + return EVENT_TYPES; + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupModule.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupModule.java new file mode 100644 index 000000000000..087589758900 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupModule.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups; + +import org.graylog2.entitygroups.contentpacks.EntityGroupFacade; +import org.graylog2.entitygroups.rest.EntityGroupResource; +import org.graylog2.featureflag.FeatureFlags; +import org.graylog2.plugin.PluginModule; +import org.graylog2.shared.utilities.StringUtils; + +public class EntityGroupModule extends PluginModule { + private static final String FEATURE_FLAG = "entity_groups"; + + private final FeatureFlags featureFlags; + + public EntityGroupModule(FeatureFlags featureFlags) { + this.featureFlags = featureFlags; + } + + @Override + protected void configure() { + if (featureFlags.getAll().keySet().stream() + .map(StringUtils::toLowerCase) + .anyMatch(s -> s.equals(FEATURE_FLAG)) && featureFlags.isOn(FEATURE_FLAG)) { + addSystemRestResource(EntityGroupResource.class); + addAuditEventTypes(EntityGroupAuditEventTypes.class); + addEntityFacade(EntityGroupFacade.TYPE_V1, EntityGroupFacade.class); + } + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupService.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupService.java new file mode 100644 index 000000000000..d972687d6f49 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/EntityGroupService.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups; + +import org.graylog2.entitygroups.model.EntityGroup; +import org.graylog2.entitygroups.model.DBEntityGroupService; +import org.graylog2.database.PaginatedList; +import org.graylog2.rest.models.SortOrder; + +import jakarta.inject.Inject; + +import jakarta.ws.rs.NotFoundException; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class EntityGroupService { + private final DBEntityGroupService dbEntityGroupService; + + @Inject + public EntityGroupService(DBEntityGroupService dbEntityGroupService) { + this.dbEntityGroupService = dbEntityGroupService; + } + + public PaginatedList findPaginated(String query, int page, int perPage, SortOrder order, + String sortByField, Predicate filter) { + + return dbEntityGroupService.findPaginated(query, page, perPage, order.toBsonSort(sortByField), filter); + } + + public PaginatedList findPaginatedForEntity(String type, String entityId, int page, int perPage, SortOrder order, + String sortByField, Predicate filter) { + return dbEntityGroupService.findPaginatedForEntity(type, entityId, page, perPage, order.toBsonSort(sortByField), filter); + } + + public Optional getByName(String groupName) { + return dbEntityGroupService.getByName(groupName); + } + + public Stream streamAllForEntity(String type, String entityId) { + return dbEntityGroupService.streamAllForEntity(type, entityId); + } + + public Map> getAllForEntities(String type, Collection entities) { + return dbEntityGroupService.getAllForEntities(type, entities).asMap(); + } + + public EntityGroup create(EntityGroup group) { + return dbEntityGroupService.save(group); + } + + public EntityGroup update(String id, EntityGroup group) { + final EntityGroup saved = dbEntityGroupService.update(group.toBuilder().id(id).build()); + if (saved == null) { + throw new NotFoundException("Unable to find mutable entity group to update"); + } + return saved; + } + + public EntityGroup addEntityToGroup(String groupId, String type, String entityId) { + return dbEntityGroupService.addEntityToGroup(groupId, type, entityId); + } + + public long delete(String id) { + return dbEntityGroupService.delete(id); + } + + public EntityGroup requireEntityGroup(String id) { + return dbEntityGroupService.get(id) + .orElseThrow(() -> new IllegalArgumentException("Unable to find entity group to update")); + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/contentpacks/EntityGroupFacade.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/contentpacks/EntityGroupFacade.java new file mode 100644 index 000000000000..29811c82004e --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/contentpacks/EntityGroupFacade.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.contentpacks; + +import org.graylog2.contentpacks.EntityDescriptorIds; +import org.graylog2.contentpacks.facades.EntityFacade; +import org.graylog2.contentpacks.model.ModelType; +import org.graylog2.contentpacks.model.entities.Entity; +import org.graylog2.contentpacks.model.entities.EntityDescriptor; +import org.graylog2.contentpacks.model.entities.EntityExcerpt; +import org.graylog2.contentpacks.model.entities.NativeEntity; +import org.graylog2.contentpacks.model.entities.NativeEntityDescriptor; +import org.graylog2.contentpacks.model.entities.references.ValueReference; +import org.graylog2.entitygroups.model.EntityGroup; +import org.graylog2.plugin.indexer.searches.timeranges.InvalidRangeParametersException; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +// TODO: Implement methods +public class EntityGroupFacade implements EntityFacade { + public static final ModelType TYPE_V1 = ModelType.of("entity_group", "1"); + + @Override + public Optional exportEntity(EntityDescriptor entityDescriptor, EntityDescriptorIds entityDescriptorIds) { + return Optional.empty(); + } + + @Override + public NativeEntity createNativeEntity(Entity entity, Map parameters, Map nativeEntities, String username) throws InvalidRangeParametersException { + return null; + } + + @Override + public Optional> loadNativeEntity(NativeEntityDescriptor nativeEntityDescriptor) { + return Optional.empty(); + } + + @Override + public void delete(EntityGroup nativeEntity) { + + } + + @Override + public EntityExcerpt createExcerpt(EntityGroup nativeEntity) { + return null; + } + + @Override + public Set listEntityExcerpts() { + return Set.of(); + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/contentpacks/entities/EntityGroupEntity.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/contentpacks/entities/EntityGroupEntity.java new file mode 100644 index 000000000000..e2c4d2165cb2 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/contentpacks/entities/EntityGroupEntity.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.contentpacks.entities; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.auto.value.AutoValue; +import com.google.common.graph.MutableGraph; +import org.graylog2.contentpacks.NativeEntityConverter; +import org.graylog2.contentpacks.model.entities.Entity; +import org.graylog2.contentpacks.model.entities.EntityDescriptor; +import org.graylog2.contentpacks.model.entities.EntityV1; +import org.graylog2.contentpacks.model.entities.ScopedContentPackEntity; +import org.graylog2.contentpacks.model.entities.references.ValueReference; +import org.graylog2.entitygroups.model.EntityGroup; + +import java.util.List; +import java.util.Map; + +import static org.graylog2.entitygroups.model.EntityGroup.FIELD_ENTITIES; +import static org.graylog2.entitygroups.model.EntityGroup.FIELD_NAME; + +@JsonAutoDetect +@AutoValue +@JsonDeserialize(builder = EntityGroupEntity.Builder.class) +public abstract class EntityGroupEntity extends ScopedContentPackEntity implements NativeEntityConverter { + @JsonProperty(FIELD_NAME) + public abstract String name(); + + @JsonProperty(FIELD_ENTITIES) + public abstract Map> entities(); + + public static Builder builder() { + return Builder.create(); + } + + public abstract Builder toBuilder(); + + @AutoValue.Builder + @JsonIgnoreProperties(ignoreUnknown = true) + public abstract static class Builder extends ScopedContentPackEntity.AbstractBuilder { + @JsonProperty(FIELD_NAME) + public abstract Builder name(String name); + + @JsonProperty(FIELD_ENTITIES) + public abstract Builder entities(Map> entities); + + public abstract EntityGroupEntity build(); + + @JsonCreator + public static Builder create() { + return new AutoValue_EntityGroupEntity.Builder(); + } + } + + @Override + public EntityGroup toNativeEntity(Map parameters, + Map nativeEntities) { + // TODO: Need to convert content pack IDs to DB object IDs for the entities map here. + return null; + } + + @Override + public void resolveForInstallation(EntityV1 entity, + Map parameters, + Map entities, + MutableGraph graph) { + // TODO: Need to flag all entity dependencies in the entities map so that they get installed first. + // TODO: They will need to exist in the DB with IDs we can reference once this `toNativeEntity` is called. + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/model/DBEntityGroupService.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/model/DBEntityGroupService.java new file mode 100644 index 000000000000..d5a259de4575 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/model/DBEntityGroupService.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.model; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Multimap; +import com.google.common.collect.MultimapBuilder; +import com.mongodb.BasicDBObject; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Collation; +import com.mongodb.client.model.CollationStrength; +import com.mongodb.client.model.FindOneAndUpdateOptions; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.ReturnDocument; +import org.bson.conversions.Bson; +import org.graylog2.database.MongoCollections; +import org.graylog2.database.PaginatedList; +import org.graylog2.database.entities.EntityScopeService; +import org.graylog2.database.pagination.MongoPaginationHelper; +import org.graylog2.database.utils.MongoUtils; +import org.graylog2.database.utils.ScopedEntityMongoUtils; +import org.graylog2.search.SearchQuery; +import org.graylog2.search.SearchQueryField; +import org.graylog2.search.SearchQueryParser; + +import jakarta.inject.Inject; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static com.mongodb.client.model.Filters.and; +import static com.mongodb.client.model.Filters.eq; +import static com.mongodb.client.model.Filters.exists; +import static com.mongodb.client.model.Filters.in; +import static com.mongodb.client.model.Updates.addToSet; + + +public class DBEntityGroupService { + public static final String COLLECTION_NAME = "entity_groups"; + + private static final ImmutableMap ALLOWED_FIELDS = ImmutableMap.builder() + .put(EntityGroup.FIELD_NAME, SearchQueryField.create(EntityGroup.FIELD_NAME)) + .build(); + + private final MongoCollection collection; + private final MongoUtils mongoUtils; + private final ScopedEntityMongoUtils scopedEntityMongoUtils; + private final MongoPaginationHelper paginationHelper; + private final SearchQueryParser searchQueryParser; + + @Inject + public DBEntityGroupService(MongoCollections mongoCollections, + EntityScopeService entityScopeService) { + this.collection = mongoCollections.collection(COLLECTION_NAME, EntityGroup.class); + this.mongoUtils = mongoCollections.utils(collection); + this.scopedEntityMongoUtils = mongoCollections.scopedEntityUtils(collection, entityScopeService); + this.paginationHelper = mongoCollections.paginationHelper(collection); + this.searchQueryParser = new SearchQueryParser(EntityGroup.FIELD_ENTITIES, ALLOWED_FIELDS); + + final IndexOptions caseInsensitiveOptions = new IndexOptions() + .collation(Collation.builder().locale("en").collationStrength(CollationStrength.SECONDARY).build()) + .unique(true); + collection.createIndex(new BasicDBObject(EntityGroup.FIELD_NAME, 1), caseInsensitiveOptions); + + final IndexOptions entityTypeOptions = new IndexOptions() + .collation(Collation.builder().locale("en").collationStrength(CollationStrength.SECONDARY).build()) + .unique(false) + .sparse(true); + // Add wildcard index on the entities map keys so that indices are created for any entity types that are added. + collection.createIndex(new BasicDBObject(EntityGroup.FIELD_ENTITIES + ".*", 1), entityTypeOptions); + } + + public Optional get(String id) { + return mongoUtils.getById(id); + } + + public PaginatedList findPaginated(String query, int page, int perPage, Bson sort, Predicate filter) { + final SearchQuery searchQuery = searchQueryParser.parse(query); + return filter == null ? + paginationHelper.filter(searchQuery.toBson()).sort(sort).perPage(perPage).page(page) : + paginationHelper.filter(searchQuery.toBson()).sort(sort).perPage(perPage).page(page, filter); + } + + public PaginatedList findPaginatedForEntity(String type, String entityId, int page, int perPage, Bson sort, + Predicate filter) { + final Bson query = and( + exists(typeField(type)), + in(typeField(type), entityId) + ); + + return filter == null ? + paginationHelper.filter(query).sort(sort).perPage(perPage).page(page) : + paginationHelper.filter(query).sort(sort).perPage(perPage).page(page, filter); + } + + public EntityGroup save(EntityGroup entityGroup) { + final String newId = scopedEntityMongoUtils.create(entityGroup); + return entityGroup.toBuilder().id(newId).build(); + } + + public EntityGroup update(EntityGroup entityGroup) { + return scopedEntityMongoUtils.update(entityGroup); + } + + public EntityGroup addEntityToGroup(String groupId, String type, String entityId) { + return collection.findOneAndUpdate(MongoUtils.idEq(groupId), + addToSet(typeField(type), entityId), + new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER)); + } + + public Optional getByName(String name) { + final Bson query = eq(EntityGroup.FIELD_NAME, name); + + return Optional.ofNullable(collection.find(query).first()); + } + + public Stream streamAllForEntity(String type, String entityId) { + final Bson query = and( + exists(typeField(type)), + in(typeField(type), entityId) + ); + return MongoUtils.stream(collection.find(query)); + } + + public Multimap getAllForEntities(String type, Collection entityIds) { + final Bson query = and( + exists(typeField(type)), + in(typeField(type), entityIds) + ); + final Multimap entityToGroupsMap = MultimapBuilder.hashKeys().hashSetValues().build(); + try (final Stream stream = MongoUtils.stream(collection.find(query))) { + stream.forEach(group -> { + final Set ids = group.entities().get(type); + for (String entityId : ids) { + if (entityIds.contains(entityId)) { + entityToGroupsMap.put(entityId, group); + } + } + }); + } + + return entityToGroupsMap; + } + + public long delete(String id) { + return collection.deleteOne(MongoUtils.idEq(id)).getDeletedCount(); + } + + private String typeField(String type) { + return EntityGroup.FIELD_ENTITIES + "." + type; + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/model/EntityGroup.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/model/EntityGroup.java new file mode 100644 index 000000000000..77c8e5c958b3 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/model/EntityGroup.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.model; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.auto.value.AutoValue; +import com.google.common.graph.MutableGraph; +import org.graylog.autovalue.WithBeanGetter; +import org.graylog2.contentpacks.EntityDescriptorIds; +import org.graylog2.contentpacks.model.entities.EntityDescriptor; +import org.graylog2.entitygroups.contentpacks.entities.EntityGroupEntity; +import org.graylog2.contentpacks.ContentPackable; +import org.graylog2.database.entities.ScopedEntity; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@AutoValue +@JsonAutoDetect +@JsonDeserialize(builder = EntityGroup.Builder.class) +@WithBeanGetter +public abstract class EntityGroup extends ScopedEntity implements ContentPackable { + public static final String FIELD_NAME = "name"; + public static final String FIELD_ENTITIES = "entities"; + + @JsonProperty(FIELD_NAME) + public abstract String name(); + + @JsonProperty(FIELD_ENTITIES) + public abstract Map> entities(); + + public static Builder builder() { + return Builder.create(); + } + + public abstract Builder toBuilder(); + + @AutoValue.Builder + @JsonIgnoreProperties(ignoreUnknown = true) + public abstract static class Builder extends AbstractBuilder { + @JsonProperty(FIELD_NAME) + public abstract Builder name(String name); + + @JsonProperty(FIELD_ENTITIES) + public abstract Builder entities(Map> entities); + + public abstract EntityGroup build(); + + @JsonCreator + public static Builder create() { + return new AutoValue_EntityGroup.Builder(); + } + } + + public EntityGroup addEntity(String type, String entityId) { + final Map> entities = entities() != null ? new HashMap<>(entities()) : new HashMap<>(); + final Set entityIds = entities.get(type) != null ? new HashSet<>(entities.get(type)) : new HashSet<>(); + + entityIds.add(entityId); + entities.put(type, entityIds); + return this.toBuilder().entities(entities).build(); + } + + @Override + public EntityGroupEntity toContentPackEntity(EntityDescriptorIds entityDescriptorIds) { + // TODO: Resolve all native entities referenced in the entities map and link content pack IDs. + // TODO: Should we export all entities referenced in this group as dependencies when a group is added to a content pack? + return null; + } + + @Override + public void resolveNativeEntity(EntityDescriptor entityDescriptor, MutableGraph mutableGraph) { + // TODO: Resolve any linkages to entities in the entities map. + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/BulkEntityGroupRequest.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/BulkEntityGroupRequest.java new file mode 100644 index 000000000000..e780516c0eb6 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/BulkEntityGroupRequest.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record BulkEntityGroupRequest( + @JsonProperty(FIELD_ENTITY_TYPE) + String type, + @JsonProperty(FIELD_ENTITY_IDS) + List entityIds +) { + public static final String FIELD_ENTITY_TYPE = "entity_type"; + public static final String FIELD_ENTITY_IDS = "entity_ids"; +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/BulkEntityGroupResponse.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/BulkEntityGroupResponse.java new file mode 100644 index 000000000000..0b24aaab154f --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/BulkEntityGroupResponse.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.graylog2.entitygroups.model.EntityGroup; + +import java.util.Collection; +import java.util.Map; + +public record BulkEntityGroupResponse(@JsonProperty(EntityGroup.FIELD_ENTITIES) + Map> entityGroups) { +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupPermissions.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupPermissions.java new file mode 100644 index 000000000000..a7b521a631df --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupPermissions.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.rest; + +public class EntityGroupPermissions { + public static final String ENTITY_GROUP_READ = "entity_group:read"; + public static final String ENTITY_GROUP_CREATE = "entity_group:create"; + public static final String ENTITY_GROUP_EDIT = "entity_group:edit"; + public static final String ENTITY_GROUP_DELETE = "entity_group:delete"; +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupRequest.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupRequest.java new file mode 100644 index 000000000000..9125cc29d180 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupRequest.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.graylog2.entitygroups.model.EntityGroup; + +import javax.annotation.Nullable; + +import java.util.Map; +import java.util.Set; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static org.graylog2.entitygroups.model.EntityGroup.FIELD_ENTITIES; +import static org.graylog2.entitygroups.model.EntityGroup.FIELD_NAME; + +public record EntityGroupRequest( + @JsonProperty(FIELD_NAME) + String name, + @Nullable + @JsonProperty(FIELD_ENTITIES) + Map> entities +) { + public EntityGroup toEntityGroup() { + return EntityGroup.builder() + .name(name()) + .entities(firstNonNull(entities(), Map.of())) + .build(); + } +} diff --git a/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupResource.java b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupResource.java new file mode 100644 index 000000000000..9241f73c9695 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/entitygroups/rest/EntityGroupResource.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.entitygroups.rest; + +import com.mongodb.DuplicateKeyException; +import com.mongodb.MongoWriteException; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.apache.shiro.authz.annotation.RequiresPermissions; + +import org.graylog2.audit.jersey.AuditEvent; +import org.graylog2.audit.jersey.NoAuditEvent; +import org.graylog2.entitygroups.EntityGroupAuditEventTypes; +import org.graylog2.entitygroups.EntityGroupService; +import org.graylog2.entitygroups.model.EntityGroup; +import org.graylog2.database.PaginatedList; +import org.graylog2.plugin.rest.PluginRestResource; +import org.graylog2.rest.models.SortOrder; +import org.graylog2.shared.rest.resources.RestResource; + +import jakarta.inject.Inject; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.graylog2.shared.utilities.StringUtils; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; + +import static org.graylog2.entitygroups.rest.EntityGroupPermissions.ENTITY_GROUP_CREATE; +import static org.graylog2.entitygroups.rest.EntityGroupPermissions.ENTITY_GROUP_DELETE; +import static org.graylog2.entitygroups.rest.EntityGroupPermissions.ENTITY_GROUP_EDIT; +import static org.graylog2.entitygroups.rest.EntityGroupPermissions.ENTITY_GROUP_READ; +import static org.graylog2.shared.utilities.ExceptionUtils.getRootCauseMessage; + +@Api(value = "EntityGroups", description = "Manage Entity Groups") +@Path("/entity_groups") +@RequiresAuthentication +@Produces(MediaType.APPLICATION_JSON) +public class EntityGroupResource extends RestResource implements PluginRestResource { + + private final EntityGroupService entityGroupService; + + @Inject + public EntityGroupResource(EntityGroupService entityGroupService) { + this.entityGroupService = entityGroupService; + } + + @GET + @ApiOperation(value = "Get a list of entity groups") + public PaginatedList listGroups(@ApiParam(name = "page") @QueryParam("page") @DefaultValue("1") int page, + @ApiParam(name = "per_page") @QueryParam("per_page") @DefaultValue("15") int perPage, + @ApiParam(name = "query") @QueryParam("query") @DefaultValue("") String query, + @ApiParam(name = "sort", + value = "The field to sort the result on", + allowableValues = "name") + @DefaultValue(EntityGroup.FIELD_NAME) + @QueryParam("sort") String sort, + @ApiParam(name = "direction", value = "The sort direction", allowableValues = "asc,desc") + @DefaultValue("asc") @QueryParam("direction") SortOrder order) { + final Predicate filter = entityGroup -> isPermitted(ENTITY_GROUP_READ, entityGroup.id()); + return entityGroupService.findPaginated(query, page, perPage, order, sort, filter); + } + + @GET + @Path("/{id}") + @ApiOperation(value = "Get a single entity group") + @ApiResponses(@ApiResponse(code = 404, message = "No such entity group")) + public Response get(@ApiParam(name = "id", required = true) @PathParam("id") String id) { + if (!isPermitted(ENTITY_GROUP_READ, id)) { + throw new ForbiddenException("Not allowed to read group id " + id); + } + try { + return Response.ok().entity(entityGroupService.requireEntityGroup(id)).build(); + } catch (IllegalArgumentException e) { + throw new BadRequestException(getRootCauseMessage(e), e); + } + } + + @GET + @Path("/get_for_entity") + @ApiOperation(value = "Get a list of entity groups for an entity") + public PaginatedList listGroupsForEntity(@ApiParam(name = "page") @QueryParam("page") @DefaultValue("1") int page, + @ApiParam(name = "per_page") @QueryParam("per_page") @DefaultValue("15") int perPage, + @ApiParam(name = "entity_type", required = true) @QueryParam("entity_type") String entityType, + @ApiParam(name = "entity_id", required = true) @QueryParam("entity_id") String entityId, + @ApiParam(name = "sort", + value = "The field to sort the result on", + allowableValues = "name") + @DefaultValue(EntityGroup.FIELD_NAME) + @QueryParam("sort") String sort, + @ApiParam(name = "direction", value = "The sort direction", allowableValues = "asc,desc") + @DefaultValue("asc") @QueryParam("direction") SortOrder order) { + final Predicate filter = entityGroup -> isPermitted(ENTITY_GROUP_READ, entityGroup.id()); + return entityGroupService.findPaginatedForEntity(entityType, entityId, page, perPage, order, sort, filter); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @ApiOperation("Create a new entity group") + @ApiResponses(@ApiResponse(code = 400, message = "An entity group already exists with id or name")) + @RequiresPermissions(ENTITY_GROUP_CREATE) + @AuditEvent(type = EntityGroupAuditEventTypes.ENTITY_GROUP_CREATE) + public Response create(@ApiParam(name = "JSON Body") EntityGroupRequest request) { + try { + return Response.ok().entity(entityGroupService.create(request.toEntityGroup())).build(); + } catch (DuplicateKeyException | MongoWriteException e) { + throw new BadRequestException(StringUtils.f("Entity group '%s' already exists", request.name())); + } + } + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @ApiOperation("Update an entity group") + @ApiResponses({ + @ApiResponse(code = 400, message = "An entity group already exists with id or name"), + @ApiResponse(code = 404, message = "No such entity group") + }) + @AuditEvent(type = EntityGroupAuditEventTypes.ENTITY_GROUP_UPDATE) + public Response update(@ApiParam(name = "id", required = true) @PathParam("id") String id, + @ApiParam(name = "JSON Body") EntityGroupRequest request) { + if (!isPermitted(ENTITY_GROUP_EDIT, id)) { + throw new ForbiddenException("Not allowed to edit group id " + id); + } + try { + return Response.ok().entity(entityGroupService.update(id, request.toEntityGroup())).build(); + } catch (IllegalArgumentException e) { + throw new NotFoundException(getRootCauseMessage(e), e); + } catch (DuplicateKeyException | MongoWriteException e) { + throw new BadRequestException(StringUtils.f("Entity group '%s' already exists", request.name())); + } + } + + @PUT + @Path("/{group_id}/add_entity") + @ApiOperation("Add an entity to an entity group") + @ApiResponses(@ApiResponse(code = 404, message = "No such entity group")) + @AuditEvent(type = EntityGroupAuditEventTypes.ENTITY_GROUP_ADD_ENTITY) + public Response addEntityToGroup(@ApiParam(name = "group_id", required = true) @PathParam("group_id") String groupId, + @ApiParam(name = "entity_type", required = true) @QueryParam("entity_type") String entityType, + @ApiParam(name = "entity_id", required = true) @QueryParam("entity_id") String entityId) { + if (!isPermitted(ENTITY_GROUP_EDIT, groupId)) { + throw new ForbiddenException("Not allowed to edit group id " + groupId); + } + + try { + return Response.ok().entity(entityGroupService.addEntityToGroup(groupId, entityType, entityId)).build(); + } catch (IllegalArgumentException e) { + throw new NotFoundException(getRootCauseMessage(e), e); + } + } + + @PUT + @Path("/get_for_entities") + @ApiOperation("Get a list of entity groups for a list of entities") + @NoAuditEvent("Read resource - doesn't change any data") + public BulkEntityGroupResponse getAllForEntity(@ApiParam(name = "JSON Body") BulkEntityGroupRequest request) { + final Map> permittedGroups = new HashMap<>(); + entityGroupService.getAllForEntities(request.type(), request.entityIds()).entrySet().stream().forEach(entry -> { + permittedGroups.put(entry.getKey(), entry.getValue().stream() + .filter(group -> isPermitted(ENTITY_GROUP_READ, group.id())) + .toList()); + }); + + return new BulkEntityGroupResponse(permittedGroups); + } + + @DELETE + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + @ApiOperation("Delete an entity group") + @AuditEvent(type = EntityGroupAuditEventTypes.ENTITY_GROUP_DELETE) + public void delete(@ApiParam(name = "id", required = true) @PathParam("id") String id) { + if (!isPermitted(ENTITY_GROUP_DELETE, id)) { + throw new ForbiddenException("Not allowed to delete group id " + id); + } + entityGroupService.delete(id); + } +} diff --git a/graylog2-server/src/test/java/org/graylog2/audit/AuditCoverageTest.java b/graylog2-server/src/test/java/org/graylog2/audit/AuditCoverageTest.java index a945c4af9ac8..fe009a2eaa75 100644 --- a/graylog2-server/src/test/java/org/graylog2/audit/AuditCoverageTest.java +++ b/graylog2-server/src/test/java/org/graylog2/audit/AuditCoverageTest.java @@ -28,6 +28,7 @@ import org.graylog.security.certutil.audit.CaAuditEventTypes; import org.graylog2.audit.jersey.AuditEvent; import org.graylog2.audit.jersey.NoAuditEvent; +import org.graylog2.entitygroups.EntityGroupAuditEventTypes; import org.junit.Test; import org.reflections.Reflections; import org.reflections.scanners.MethodAnnotationsScanner; @@ -61,6 +62,7 @@ public void testAuditCoverage() throws Exception { .addAll(new SecurityAuditEventTypes().auditEventTypes()) .addAll(new IntegrationsAuditEventTypes().auditEventTypes()) .addAll(new CaAuditEventTypes().auditEventTypes()) + .addAll(new EntityGroupAuditEventTypes().auditEventTypes()) .build(); final Reflections reflections = new Reflections(configurationBuilder); diff --git a/graylog2-web-interface/src/components/entity-groups/EntityGroupsList.tsx b/graylog2-web-interface/src/components/entity-groups/EntityGroupsList.tsx new file mode 100644 index 000000000000..e3e3cb71da35 --- /dev/null +++ b/graylog2-web-interface/src/components/entity-groups/EntityGroupsList.tsx @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import styled from 'styled-components'; + +import { IconButton } from 'components/common'; +import { Button, BootstrapModalConfirm } from 'components/bootstrap'; +import type { EntityGroupsListResponse } from 'components/entity-groups/Types'; +import { useCreateEntityGroup, useUpdateEntityGroup, useDeleteEntityGroup } from 'components/entity-groups/hooks/useEntityGroups'; + +const DataRow = styled.div` + width: 100%; + height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + + .default-button { + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + + &:hover .default-button { + opacity: 1; + } +`; + +const StyledInput = styled.input` + color: #fff; + background-color: #303030; + padding: 6px 12px; + border: 1px solid #525252; + + &:focus { + border: 1px solid #5082bc; + box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(80 130 188 / 40%); + } +`; + +const StyledButton = styled(Button)` + justify-content: flex-end; +`; + +const CancelButton = styled(StyledButton)` + margin-right: 5px; +`; + +const StyledIconButton = styled(IconButton)` + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + float: right; + font-weight: bold; + line-height: 1; + opacity: 0.5; + + &:hover { + background-color: transparent; + opacity: 0.7; + } +`; + +type Props = { + entityGroups: EntityGroupsListResponse[]; + showAddEntityGroup: boolean; + setShowAddEntityGroup: (boolean) => void; +} + +const EntityGroupsList = ({ entityGroups, showAddEntityGroup, setShowAddEntityGroup }: Props) => { + const [editId, setEditId] = React.useState(''); + const [editValue, setEditValue] = React.useState(''); + const [newEntityGroupTagValue, setNewEntityGroupTagValue] = React.useState(''); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [entityGroupToDelete, setEntityGroupToDelete] = React.useState({ id: '', value: '' }); + + const { createEntityGroup } = useCreateEntityGroup(); + const { updateEntityGroup } = useUpdateEntityGroup(); + const { deleteEntityGroup } = useDeleteEntityGroup(); + + const onAddEntityGroup = async () => { + await createEntityGroup({ name: newEntityGroupTagValue }); + + setNewEntityGroupTagValue(''); + setShowAddEntityGroup(false); + }; + + const resetAddValues = () => { + setNewEntityGroupTagValue(''); + setShowAddEntityGroup(false); + }; + + const handleAddKeyDown = (e: React.KeyboardEvent) => { + if (!newEntityGroupTagValue) return; + + if (e.key === 'Enter') { + onAddEntityGroup(); + } + + if (e.key === 'Escape') { + resetAddValues(); + } + }; + + const onEditEntityGroup = async () => { + await updateEntityGroup({ id: editId, requestObj: { name: editValue } }); + + setEditId(''); + setEditValue(''); + }; + + const resetEditValues = () => { + setEditId(''); + setEditValue(''); + }; + + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onEditEntityGroup(); + } + + if (e.key === 'Escape') { + resetEditValues(); + } + }; + + const onDeleteEntityGroup = async () => { + await deleteEntityGroup({ entityGroupId: entityGroupToDelete.id }); + + setShowDeleteModal(false); + setEntityGroupToDelete({ id: '', value: '' }); + }; + + return ( + <> + {showAddEntityGroup && ( + +
+ setNewEntityGroupTagValue(e.target.value)} + onKeyDown={handleAddKeyDown} /> +
+
+ resetAddValues()}> + Cancel + + + Add + +
+
+ )} + {entityGroups.map((entityGroup) => { + const isCurrentlyEditing = entityGroup.id === editId; + + return ( + + {isCurrentlyEditing ? ( + <> +
+ setEditValue(e.target.value)} + onKeyDown={handleEditKeyDown} /> +
+
+ resetEditValues()}> + Cancel + + + Save + +
+ + ) : ( + <> +
+
{entityGroup.name}
+
+
+ { + setEntityGroupToDelete({ id: entityGroup.id, value: entityGroup.name }); + setShowDeleteModal(true); + }} /> + { + setEditId(entityGroup.id); + setEditValue(entityGroup.name); + }} /> +
+ + )} + onDeleteEntityGroup()} + onCancel={() => setShowDeleteModal(false)}> +
You are about to delete this tag: {entityGroupToDelete.value}
+
+
+ ); + })} + + ); +}; + +export default EntityGroupsList; diff --git a/graylog2-web-interface/src/components/entity-groups/Types.ts b/graylog2-web-interface/src/components/entity-groups/Types.ts new file mode 100644 index 000000000000..f138d570fce2 --- /dev/null +++ b/graylog2-web-interface/src/components/entity-groups/Types.ts @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +export type EntityGroupsListResponse = { + entities: any, + name?: string, + id: string, +}; diff --git a/graylog2-web-interface/src/components/entity-groups/hooks/useEntityGroups.ts b/graylog2-web-interface/src/components/entity-groups/hooks/useEntityGroups.ts new file mode 100644 index 000000000000..f02d9c18edd0 --- /dev/null +++ b/graylog2-web-interface/src/components/entity-groups/hooks/useEntityGroups.ts @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; + +import UserNotification from 'util/UserNotification'; +import { qualifyUrl } from 'util/URLUtils'; +import fetch from 'logic/rest/FetchProvider'; +import type { EntityGroupsListResponse } from 'components/entity-groups/Types'; +import type FetchError from 'logic/errors/FetchError'; + +const fetchEntityGroups = async () => fetch('GET', qualifyUrl('/entity_groups')); + +const createEntityGroup = async (requestObj) => { + const requestBody = requestObj; + + return fetch('POST', qualifyUrl('/entity_groups'), requestBody); +}; + +const updateEntityGroup = async ({ id, requestObj }: {id: string, requestObj: { entities?: any, name: string }}) => { + const requestBody = requestObj; + + return fetch('PUT', qualifyUrl(`/entity_groups/${id}`), requestBody); +}; + +const deleteEntityGroup = async ({ entityGroupId }: { entityGroupId: string }) => fetch('DELETE', qualifyUrl(`/entity_groups/${entityGroupId}`)); + +export const useGetEntityGroups = () => { + const { data, isInitialLoading } = useQuery( + ['get-entity-groups'], + fetchEntityGroups, + { + onError: (errorThrown) => { + UserNotification.error(`Loading entity groups failed with status: ${errorThrown}`, + 'Could not load entity groups.'); + }, + }, + ); + + return ({ + data: data, + isInitialLoading, + }); +}; + +export const useCreateEntityGroup = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isLoading } = useMutation( + createEntityGroup, + { + onSuccess: () => { + UserNotification.success('New entity group added successfully'); + queryClient.invalidateQueries(['get-entity-groups']); + }, + onError: (error: Error) => UserNotification.error(error.message), + }, + ); + + return { + createEntityGroup: mutateAsync, + creatingEntityGroup: isLoading, + }; +}; + +export const useUpdateEntityGroup = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isLoading } = useMutation( + updateEntityGroup, + { + onSuccess: () => { + UserNotification.success('Entity group updated successfully'); + queryClient.invalidateQueries(['get-entity-groups']); + }, + onError: (error: Error) => UserNotification.error(error.message), + }, + ); + + return { + updateEntityGroup: mutateAsync, + updatingEntityGroup: isLoading, + }; +}; + +export const useDeleteEntityGroup = () => { + const queryClient = useQueryClient(); + + const { mutateAsync, isLoading } = useMutation( + deleteEntityGroup, + { + onSuccess: () => { + UserNotification.success('Entity group deleted successfully'); + queryClient.invalidateQueries(['get-entity-groups']); + }, + onError: (error: Error) => UserNotification.error(error.message), + }, + ); + + return { + deleteEntityGroup: mutateAsync, + deletingEntityGroup: isLoading, + }; +}; diff --git a/graylog2-web-interface/src/components/navigation/bindings.ts b/graylog2-web-interface/src/components/navigation/bindings.ts index afd3d088d087..a47d671592ef 100644 --- a/graylog2-web-interface/src/components/navigation/bindings.ts +++ b/graylog2-web-interface/src/components/navigation/bindings.ts @@ -63,6 +63,7 @@ const navigationBindings: PluginExports = { { path: Routes.SYSTEM.LOOKUPTABLES.OVERVIEW, description: 'Lookup Tables', permissions: ['lookuptables:read'] }, { path: Routes.SYSTEM.PIPELINES.OVERVIEW, description: 'Pipelines', permissions: ['pipeline:read', 'pipeline_connection:read'] }, { path: Routes.SYSTEM.SIDECARS.OVERVIEW, description: 'Sidecars', permissions: ['sidecars:read'] }, + { path: Routes.SYSTEM.ENTITYGROUPS.OVERVIEW, description: 'Entity Groups' }, ], AppConfig.isCloud() && !AppConfig.isFeatureEnabled('cloud_inputs') ? [Routes.SYSTEM.INPUTS] : [], ), diff --git a/graylog2-web-interface/src/pages/EntityGroupsPage.tsx b/graylog2-web-interface/src/pages/EntityGroupsPage.tsx new file mode 100644 index 000000000000..dcf712c7b198 --- /dev/null +++ b/graylog2-web-interface/src/pages/EntityGroupsPage.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; + +import { DocumentTitle, PageHeader, Spinner } from 'components/common'; +import { Row, Col, Button } from 'components/bootstrap'; +import EntityGroupsList from 'components/entity-groups/EntityGroupsList'; +import { useGetEntityGroups } from 'components/entity-groups/hooks/useEntityGroups'; + +const EntityGroupsPage = () => { + const { data: entityGroupsList, isInitialLoading } = useGetEntityGroups(); + const [showAddEntityGroup, setShowAddEntityGroup] = React.useState(false); + + return ( + + setShowAddEntityGroup(true)}>Create a new entity group + )}> + + Manage the tags/categories of your content. + + + + + {isInitialLoading ? ( + + ) : ( + setShowAddEntityGroup(value)} /> + )} + + + + ); +}; + +export default EntityGroupsPage; diff --git a/graylog2-web-interface/src/pages/index.jsx b/graylog2-web-interface/src/pages/index.jsx index f589f92239f8..ddb7e91dbd4c 100644 --- a/graylog2-web-interface/src/pages/index.jsx +++ b/graylog2-web-interface/src/pages/index.jsx @@ -41,6 +41,7 @@ const EditEventNotificationPage = loadAsync(() => import('./EditEventNotificatio const EditContentPackPage = loadAsync(() => import('pages/EditContentPackPage')); const EditExtractorsPage = loadAsync(() => import('./EditExtractorsPage')); const EnterprisePage = loadAsync(() => import('./EnterprisePage')); +const EntityGroupsPage = loadAsync(() => import('pages/EntityGroupsPage')); const EventDefinitionsPage = loadAsync(() => import('./EventDefinitionsPage')); const EventNotificationsPage = loadAsync(() => import('./EventNotificationsPage')); const EventsPage = loadAsync(() => import('./EventsPage')); @@ -138,6 +139,7 @@ export { EditContentPackPage, EditExtractorsPage, EnterprisePage, + EntityGroupsPage, EventDefinitionsPage, EventNotificationsPage, EventsPage, diff --git a/graylog2-web-interface/src/routing/AppRouter.tsx b/graylog2-web-interface/src/routing/AppRouter.tsx index cfc068100bab..f2282b2ad90a 100644 --- a/graylog2-web-interface/src/routing/AppRouter.tsx +++ b/graylog2-web-interface/src/routing/AppRouter.tsx @@ -44,6 +44,7 @@ import { EditContentPackPage, EditExtractorsPage, EnterprisePage, + EntityGroupsPage, EventDefinitionsPage, EventNotificationsPage, EventsPage, @@ -318,6 +319,7 @@ const AppRouter = () => { { path: RoutePaths.SYSTEM.SIDECARS.EDIT_CONFIGURATION(':configurationId'), element: }, { path: RoutePaths.SYSTEM.SIDECARS.NEW_COLLECTOR, element: }, { path: RoutePaths.SYSTEM.SIDECARS.EDIT_COLLECTOR(':collectorId'), element: }, + { path: RoutePaths.SYSTEM.ENTITYGROUPS.OVERVIEW, element: }, { path: RoutePaths.KEYBOARD_SHORTCUTS, element: }, { path: RoutePaths.SYSTEM.INDICES.FIELD_TYPE_PROFILES.OVERVIEW, element: }, { path: RoutePaths.SYSTEM.INDICES.FIELD_TYPE_PROFILES.edit(':profileId'), element: }, diff --git a/graylog2-web-interface/src/routing/Routes.ts b/graylog2-web-interface/src/routing/Routes.ts index 300d0e077d80..b78f933395ce 100644 --- a/graylog2-web-interface/src/routing/Routes.ts +++ b/graylog2-web-interface/src/routing/Routes.ts @@ -206,6 +206,9 @@ const Routes = { NEW_COLLECTOR: '/system/sidecars/collector/new', EDIT_COLLECTOR: (collectorId: string) => `/system/sidecars/collector/edit/${collectorId}`, }, + ENTITYGROUPS: { + OVERVIEW: '/system/entitygroups', + }, }, VIEWS: { LIST: viewsPath,