From ec76bdb19fdb68ddab05e5e1f30c009f9ce9afba Mon Sep 17 00:00:00 2001 From: Romuald Lemesle Date: Mon, 22 Jan 2024 17:03:45 +0100 Subject: [PATCH] [backend/frontend] Add Assets & AssetGroups (#481) --- .drone.yml | 1 + openex-api/pom.xml | 25 +- .../migration/V2_68__Assets_Asset_Groups.java | 85 +++++ .../V2_69__Modify_Inject_async_ids.java | 24 ++ .../src/main/java/io/openex/rest/HomeApi.java | 2 +- .../rest/asset/endpoint/EndpointApi.java | 61 ++++ .../asset/endpoint/form/EndpointInput.java | 33 ++ .../io/openex/rest/asset/form/AssetInput.java | 30 ++ .../rest/asset_group/AssetGroupApi.java | 76 +++++ .../asset_group/form/AssetGroupInput.java | 27 ++ .../form/UpdateAssetsOnAssetGroupInput.java | 16 + .../io/openex/rest/contract/ContractApi.java | 45 +++ .../runner/InitAdminCommandLineRunner.java | 15 +- .../io/openex/service/ContractService.java | 83 +++-- .../java/io/openex/service/UserService.java | 49 +-- .../src/main/resources/application.properties | 14 + .../io/openex/helper/InjectHelperTest.java | 9 + .../io/openex/injects/InjectCrudTest.java | 4 + .../io/openex/rest/AssetGroupApiTest.java | 183 +++++++++++ .../java/io/openex/rest/EndpointApiTest.java | 164 ++++++++++ .../java/io/openex/rest/utils/JsonUtils.java | 47 +++ .../rest/utils/WithMockObserverUser.java | 14 + ...ockObserverUserSecurityContextFactory.java | 73 +++++ .../rest/utils/WithMockPlannerUser.java | 14 + ...MockPlannerUserSecurityContextFactory.java | 73 +++++ .../service/AssetEndpointServiceTest.java | 135 ++++++++ .../openex/service/AssetGroupServiceTest.java | 111 +++++++ openex-collectors | 2 +- .../io/openex/contract/ContractConfig.java | 21 +- .../java/io/openex/contract/ContractDef.java | 25 +- .../contract/fields/ContractElement.java | 1 + .../openex/service/AssetEndpointService.java | 58 ++++ .../io/openex/service/AssetGroupService.java | 61 ++++ .../java/io/openex/service/AssetService.java | 25 ++ openex-front/package.json | 1 + openex-front/src/actions/Contract.ts | 11 + openex-front/src/actions/Schema.js | 13 +- .../actions/assetgroups/assetgroup-action.ts | 40 +++ .../assetgroups/assetgroup-helper.d.ts | 6 + .../actions/assetgroups/assetgroup-schema.ts | 8 + .../src/actions/assets/asset-helper.d.ts | 6 + .../src/actions/assets/asset-schema.ts | 10 + .../src/actions/assets/endpoint-actions.ts | 27 ++ openex-front/src/actions/helper.d.ts | 1 + .../{lesson.d.ts => lesson-helper.d.ts} | 0 openex-front/src/admin/Index.tsx | 12 +- .../src/admin/components/assets/Index.tsx | 31 ++ .../assets/assetgroups/AssetGroup.d.ts | 6 + .../assetgroups/AssetGroupAddAssets.tsx | 194 +++++++++++ .../assets/assetgroups/AssetGroupCreation.tsx | 71 ++++ .../assets/assetgroups/AssetGroupForm.tsx | 106 ++++++ .../assetgroups/AssetGroupManagement.tsx | 266 +++++++++++++++ .../assets/assetgroups/AssetGroupPopover.tsx | 148 +++++++++ .../assets/assetgroups/AssetGroups.tsx | 276 ++++++++++++++++ .../components/assets/endpoints/Endpoint.d.ts | 5 + .../assets/endpoints/EndpointCreation.tsx | 99 ++++++ .../assets/endpoints/EndpointForm.tsx | 257 +++++++++++++++ .../assets/endpoints/EndpointPopover.tsx | 151 +++++++++ .../components/assets/endpoints/Endpoints.tsx | 305 ++++++++++++++++++ .../exercises/injects/InjectDefinition.js | 32 +- .../exercises/injects/InjectForm.js | 4 +- .../exercises/injects/InjectIcon.js | 55 ++-- .../src/admin/components/lessons/Index.tsx | 2 +- .../components/lessons/LessonsTemplate.tsx | 2 +- .../src/admin/components/nav/TopBar.tsx | 2 + .../admin/components/nav/TopMenuAssets.tsx | 48 +++ openex-front/src/components/TagsFilter.js | 3 +- .../src/components/common/ButtonCreate.tsx | 36 +++ .../src/components/common/DialogDelete.tsx | 43 +++ .../src/components/common/SortHeadersList.tsx | 75 +++++ .../src/components/field/TagField.tsx | 160 +++++++++ openex-front/src/constants/ActionTypes.js | 4 + openex-front/src/reducers/App.js | 4 + openex-front/src/reducers/Referential.js | 2 + openex-front/src/root.tsx | 2 +- .../src/static/images/contracts/airbus.png | Bin 9894 -> 0 bytes openex-front/src/utils/Localization.js | 27 ++ openex-front/src/utils/api-types.d.ts | 124 ++++++- openex-front/yarn.lock | 10 + openex-injectors | 2 +- openex-model/pom.xml | 5 + .../annotation/Ipv4OrIpv6Constraint.java | 24 ++ .../java/io/openex/database/model/Asset.java | 89 +++++ .../io/openex/database/model/AssetGroup.java | 69 ++++ .../database/model/AssetGroupAsset.java | 30 ++ .../io/openex/database/model/Endpoint.java | 51 +++ .../io/openex/database/model/Execution.java | 8 +- .../openex/database/model/ExecutionTrace.java | 38 +-- .../openex/database/model/InjectStatus.java | 26 +- .../java/io/openex/database/model/Tag.java | 138 ++++---- .../repository/AssetGroupRepository.java | 22 ++ .../database/repository/AssetRepository.java | 17 + .../repository/EndpointRepository.java | 21 ++ .../repository/InjectStatusRepository.java | 2 +- .../openex/validator/Ipv4OrIpv6Validator.java | 18 ++ pom.xml | 5 +- 96 files changed, 4585 insertions(+), 271 deletions(-) create mode 100644 openex-api/src/main/java/io/openex/migration/V2_68__Assets_Asset_Groups.java create mode 100644 openex-api/src/main/java/io/openex/migration/V2_69__Modify_Inject_async_ids.java create mode 100644 openex-api/src/main/java/io/openex/rest/asset/endpoint/EndpointApi.java create mode 100644 openex-api/src/main/java/io/openex/rest/asset/endpoint/form/EndpointInput.java create mode 100644 openex-api/src/main/java/io/openex/rest/asset/form/AssetInput.java create mode 100644 openex-api/src/main/java/io/openex/rest/asset_group/AssetGroupApi.java create mode 100644 openex-api/src/main/java/io/openex/rest/asset_group/form/AssetGroupInput.java create mode 100644 openex-api/src/main/java/io/openex/rest/asset_group/form/UpdateAssetsOnAssetGroupInput.java create mode 100644 openex-api/src/main/java/io/openex/rest/contract/ContractApi.java create mode 100644 openex-api/src/test/java/io/openex/rest/AssetGroupApiTest.java create mode 100644 openex-api/src/test/java/io/openex/rest/EndpointApiTest.java create mode 100644 openex-api/src/test/java/io/openex/rest/utils/JsonUtils.java create mode 100644 openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUser.java create mode 100644 openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUserSecurityContextFactory.java create mode 100644 openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUser.java create mode 100644 openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUserSecurityContextFactory.java create mode 100644 openex-api/src/test/java/io/openex/service/AssetEndpointServiceTest.java create mode 100644 openex-api/src/test/java/io/openex/service/AssetGroupServiceTest.java create mode 100644 openex-framework/src/main/java/io/openex/service/AssetEndpointService.java create mode 100644 openex-framework/src/main/java/io/openex/service/AssetGroupService.java create mode 100644 openex-framework/src/main/java/io/openex/service/AssetService.java create mode 100644 openex-front/src/actions/Contract.ts create mode 100644 openex-front/src/actions/assetgroups/assetgroup-action.ts create mode 100644 openex-front/src/actions/assetgroups/assetgroup-helper.d.ts create mode 100644 openex-front/src/actions/assetgroups/assetgroup-schema.ts create mode 100644 openex-front/src/actions/assets/asset-helper.d.ts create mode 100644 openex-front/src/actions/assets/asset-schema.ts create mode 100644 openex-front/src/actions/assets/endpoint-actions.ts rename openex-front/src/actions/lessons/{lesson.d.ts => lesson-helper.d.ts} (100%) create mode 100644 openex-front/src/admin/components/assets/Index.tsx create mode 100644 openex-front/src/admin/components/assets/assetgroups/AssetGroup.d.ts create mode 100644 openex-front/src/admin/components/assets/assetgroups/AssetGroupAddAssets.tsx create mode 100644 openex-front/src/admin/components/assets/assetgroups/AssetGroupCreation.tsx create mode 100644 openex-front/src/admin/components/assets/assetgroups/AssetGroupForm.tsx create mode 100644 openex-front/src/admin/components/assets/assetgroups/AssetGroupManagement.tsx create mode 100644 openex-front/src/admin/components/assets/assetgroups/AssetGroupPopover.tsx create mode 100644 openex-front/src/admin/components/assets/assetgroups/AssetGroups.tsx create mode 100644 openex-front/src/admin/components/assets/endpoints/Endpoint.d.ts create mode 100644 openex-front/src/admin/components/assets/endpoints/EndpointCreation.tsx create mode 100644 openex-front/src/admin/components/assets/endpoints/EndpointForm.tsx create mode 100644 openex-front/src/admin/components/assets/endpoints/EndpointPopover.tsx create mode 100644 openex-front/src/admin/components/assets/endpoints/Endpoints.tsx create mode 100644 openex-front/src/admin/components/nav/TopMenuAssets.tsx create mode 100644 openex-front/src/components/common/ButtonCreate.tsx create mode 100644 openex-front/src/components/common/DialogDelete.tsx create mode 100644 openex-front/src/components/common/SortHeadersList.tsx create mode 100644 openex-front/src/components/field/TagField.tsx delete mode 100644 openex-front/src/static/images/contracts/airbus.png create mode 100644 openex-model/src/main/java/io/openex/annotation/Ipv4OrIpv6Constraint.java create mode 100644 openex-model/src/main/java/io/openex/database/model/Asset.java create mode 100644 openex-model/src/main/java/io/openex/database/model/AssetGroup.java create mode 100644 openex-model/src/main/java/io/openex/database/model/AssetGroupAsset.java create mode 100644 openex-model/src/main/java/io/openex/database/model/Endpoint.java create mode 100644 openex-model/src/main/java/io/openex/database/repository/AssetGroupRepository.java create mode 100644 openex-model/src/main/java/io/openex/database/repository/AssetRepository.java create mode 100644 openex-model/src/main/java/io/openex/database/repository/EndpointRepository.java create mode 100644 openex-model/src/main/java/io/openex/validator/Ipv4OrIpv6Validator.java diff --git a/.drone.yml b/.drone.yml index e437d2ff58..989bbb4639 100644 --- a/.drone.yml +++ b/.drone.yml @@ -51,6 +51,7 @@ services: --- kind: pipeline +type: docker name: openex-tests-master trigger: diff --git a/openex-api/pom.xml b/openex-api/pom.xml index 37292cf032..6839b03759 100644 --- a/openex-api/pom.xml +++ b/openex-api/pom.xml @@ -17,12 +17,10 @@ 1.70 4.4 1.5 - 1.8.0 9.0.3 4.1.1 - 1.7.0 + 2.3.0 1.4 - 0.8.11 @@ -49,11 +47,21 @@ openex-injector-http 3.5.0-SNAPSHOT + + io.openex + openex-injector-caldera + 3.5.0-SNAPSHOT + io.openex openex-collector-users 3.5.0-SNAPSHOT + + io.openex + openex-collector-caldera + 3.5.0-SNAPSHOT + @@ -107,12 +115,12 @@ org.springdoc - springdoc-openapi-ui + springdoc-openapi-starter-webmvc-ui ${springdoc.version} org.springdoc - springdoc-openapi-webflux-ui + springdoc-openapi-starter-webflux-ui ${springdoc.version} @@ -189,6 +197,7 @@ opensaml-saml-impl ${opensaml.version} + org.springframework.boot spring-boot-starter-test @@ -200,6 +209,11 @@ + + org.springframework.security + spring-security-test + test + com.h2database h2 @@ -272,6 +286,7 @@ + http://localhost:8080/api-docs swagger.json . diff --git a/openex-api/src/main/java/io/openex/migration/V2_68__Assets_Asset_Groups.java b/openex-api/src/main/java/io/openex/migration/V2_68__Assets_Asset_Groups.java new file mode 100644 index 0000000000..5d675a22b7 --- /dev/null +++ b/openex-api/src/main/java/io/openex/migration/V2_68__Assets_Asset_Groups.java @@ -0,0 +1,85 @@ +package io.openex.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.Statement; + +@Component +public class V2_68__Assets_Asset_Groups extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + Connection connection = context.getConnection(); + Statement select = connection.createStatement(); + // Add extension + select.execute(""" + CREATE EXTENSION IF NOT EXISTS hstore; + """); + // Create table asset + select.execute(""" + CREATE TABLE IF NOT EXISTS assets ( + asset_id varchar(255) not null constraint assets_pkey primary key, + asset_type varchar(255) not null, + asset_sources hstore, + asset_blobs hstore, + asset_name varchar(255) not null, + asset_description text, + asset_last_seen timestamp, + asset_created_at timestamp not null default now(), + asset_updated_at timestamp not null default now() + ); + CREATE INDEX IF NOT EXISTS idx_assets on assets (asset_id); + """); + // Add column for endpoint type + select.execute(""" + ALTER TABLE assets ADD COLUMN endpoint_ips text[]; + ALTER TABLE assets ADD COLUMN endpoint_hostname varchar(255); + ALTER TABLE assets ADD COLUMN endpoint_platform varchar(255) not null; + ALTER TABLE assets ADD COLUMN endpoint_mac_addresses text[]; + """); + // Add association table between asset and tag + select.execute(""" + CREATE TABLE assets_tags ( + asset_id varchar(255) not null constraint asset_id_fk references assets, + tag_id varchar(255) not null constraint tag_id_fk references tags, + constraint assets_tags_pkey primary key (asset_id, tag_id) + ); + CREATE INDEX idx_assets_tags_asset on assets_tags (asset_id); + CREATE INDEX idx_assets_tags_tag on assets_tags (tag_id); + """); + // Create table asset groups + select.execute(""" + CREATE TABLE IF NOT EXISTS asset_groups ( + asset_group_id varchar(255) not null constraint asset_groups_pkey primary key, + asset_group_name varchar(255) not null, + asset_group_description text, + asset_group_created_at timestamp not null default now(), + asset_group_updated_at timestamp not null default now() + ); + CREATE INDEX IF NOT EXISTS idx_asset_groups on asset_groups (asset_group_id); + """); + // Add association table between asset group and tag + select.execute(""" + CREATE TABLE asset_groups_tags ( + asset_group_id varchar(255) not null constraint asset_group_id_fk references asset_groups on delete cascade, + tag_id varchar(255) not null constraint tag_id_fk references tags on delete cascade, + constraint asset_groups_tags_pkey primary key (asset_group_id, tag_id) + ); + CREATE INDEX idx_asset_groups_tags_asset_group on asset_groups_tags (asset_group_id); + CREATE INDEX idx_asset_groups_tags_tag on asset_groups_tags (tag_id); + """); + // Add association table between asset and asset groups + select.execute(""" + CREATE TABLE IF NOT EXISTS asset_groups_assets ( + asset_group_id varchar(255) not null constraint asset_group_id_fk references asset_groups on delete cascade, + asset_id varchar(255) not null constraint asset_id_fk references assets on delete cascade, + constraint asset_groups_assets_pkey primary key (asset_group_id, asset_id) + ); + CREATE INDEX IF NOT EXISTS idx_asset_groups_assets_asset_group on asset_groups_assets (asset_group_id); + CREATE INDEX IF NOT EXISTS idx_asset_groups_assets_asset on asset_groups_assets (asset_id); + """); + } +} diff --git a/openex-api/src/main/java/io/openex/migration/V2_69__Modify_Inject_async_ids.java b/openex-api/src/main/java/io/openex/migration/V2_69__Modify_Inject_async_ids.java new file mode 100644 index 0000000000..f96c110e76 --- /dev/null +++ b/openex-api/src/main/java/io/openex/migration/V2_69__Modify_Inject_async_ids.java @@ -0,0 +1,24 @@ +package io.openex.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.Statement; + +@Component +public class V2_69__Modify_Inject_async_ids extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + Connection connection = context.getConnection(); + Statement select = connection.createStatement(); + // Modify inject async ids property + select.execute(""" + ALTER TABLE injects_statuses ADD COLUMN status_async_ids text[]; + UPDATE injects_statuses SET status_async_ids = ARRAY[status_async_id]; + ALTER TABLE injects_statuses DROP COLUMN status_async_id; + """); + } +} diff --git a/openex-api/src/main/java/io/openex/rest/HomeApi.java b/openex-api/src/main/java/io/openex/rest/HomeApi.java index 34274b12a5..b7ba699fc0 100644 --- a/openex-api/src/main/java/io/openex/rest/HomeApi.java +++ b/openex-api/src/main/java/io/openex/rest/HomeApi.java @@ -31,7 +31,7 @@ private static String readResourceAsString(Resource resource) { @Value("${server.servlet.context-path}") private String contextPath; - @GetMapping(path = {"/", "/{path:^(?!api$|login$|logout$|oauth2$|saml2$|static$).*$}/**"}, produces = MediaType.TEXT_HTML_VALUE) + @GetMapping(path = {"/", "/{path:^(?!api$|login$|logout$|oauth2$|saml2$|static$|swagger-ui$).*$}/**"}, produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity home() { ClassPathResource classPathResource = new ClassPathResource("/build/index.html"); String index = readResourceAsString(classPathResource); diff --git a/openex-api/src/main/java/io/openex/rest/asset/endpoint/EndpointApi.java b/openex-api/src/main/java/io/openex/rest/asset/endpoint/EndpointApi.java new file mode 100644 index 0000000000..841262a0f7 --- /dev/null +++ b/openex-api/src/main/java/io/openex/rest/asset/endpoint/EndpointApi.java @@ -0,0 +1,61 @@ +package io.openex.rest.asset.endpoint; + +import io.openex.database.model.Endpoint; +import io.openex.database.repository.TagRepository; +import io.openex.rest.asset.endpoint.form.EndpointInput; +import io.openex.service.AssetEndpointService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static io.openex.database.model.User.ROLE_ADMIN; +import static io.openex.helper.StreamHelper.fromIterable; + +@RequiredArgsConstructor +@RestController +public class EndpointApi { + + public static final String ENDPOINT_URI = "/api/endpoints"; + + private final AssetEndpointService assetEndpointService; + private final TagRepository tagRepository; + + @PostMapping(ENDPOINT_URI) + @Secured(ROLE_ADMIN) + public Endpoint createEndpoint(@Valid @RequestBody final EndpointInput input) { + Endpoint endpoint = new Endpoint(); + endpoint.setUpdateAttributes(input); + endpoint.setPlatform(input.getPlatform()); + endpoint.setTags(fromIterable(this.tagRepository.findAllById(input.getTagIds()))); + return this.assetEndpointService.createEndpoint(endpoint); + } + + @GetMapping(ENDPOINT_URI) + @PreAuthorize("isObserver()") + public List endpoints() { + return this.assetEndpointService.endpoints(); + } + + @PutMapping(ENDPOINT_URI + "/{endpointId}") + @Secured(ROLE_ADMIN) + public Endpoint updateEndpoint( + @PathVariable @NotBlank final String endpointId, + @Valid @RequestBody final EndpointInput input) { + Endpoint endpoint = this.assetEndpointService.endpoint(endpointId); + endpoint.setUpdateAttributes(input); + endpoint.setPlatform(input.getPlatform()); + endpoint.setTags(fromIterable(this.tagRepository.findAllById(input.getTagIds()))); + return this.assetEndpointService.updateEndpoint(endpoint); + } + + @DeleteMapping(ENDPOINT_URI + "/{endpointId}") + @Secured(ROLE_ADMIN) + public void deleteEndpoint(@PathVariable @NotBlank final String endpointId) { + this.assetEndpointService.deleteEndpoint(endpointId); + } +} diff --git a/openex-api/src/main/java/io/openex/rest/asset/endpoint/form/EndpointInput.java b/openex-api/src/main/java/io/openex/rest/asset/endpoint/form/EndpointInput.java new file mode 100644 index 0000000000..424c1f46a1 --- /dev/null +++ b/openex-api/src/main/java/io/openex/rest/asset/endpoint/form/EndpointInput.java @@ -0,0 +1,33 @@ +package io.openex.rest.asset.endpoint.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.openex.database.model.Endpoint; +import io.openex.rest.asset.form.AssetInput; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import static io.openex.config.AppConfig.MANDATORY_MESSAGE; + +@EqualsAndHashCode(callSuper = true) +@Data +public class EndpointInput extends AssetInput { + + @NotEmpty(message = MANDATORY_MESSAGE) + @Size(min = 1, message = MANDATORY_MESSAGE) + @JsonProperty("endpoint_ips") + private String[] ips; + + @JsonProperty("endpoint_hostname") + private String hostname; + + @NotNull(message = MANDATORY_MESSAGE) + @JsonProperty("endpoint_platform") + private Endpoint.PLATFORM_TYPE platform; + + @JsonProperty("endpoint_mac_adresses") + private String[] macAdresses; + +} diff --git a/openex-api/src/main/java/io/openex/rest/asset/form/AssetInput.java b/openex-api/src/main/java/io/openex/rest/asset/form/AssetInput.java new file mode 100644 index 0000000000..9ecab94ea0 --- /dev/null +++ b/openex-api/src/main/java/io/openex/rest/asset/form/AssetInput.java @@ -0,0 +1,30 @@ +package io.openex.rest.asset.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import static io.openex.config.AppConfig.MANDATORY_MESSAGE; + +@Data +public abstract class AssetInput { + + @NotBlank(message = MANDATORY_MESSAGE) + @JsonProperty("asset_name") + private String name; + + @JsonProperty("asset_description") + private String description; + + @JsonProperty("asset_last_seen") + private Instant lastSeen; + + @JsonProperty("asset_tags") + private List tagIds = new ArrayList<>(); + +} diff --git a/openex-api/src/main/java/io/openex/rest/asset_group/AssetGroupApi.java b/openex-api/src/main/java/io/openex/rest/asset_group/AssetGroupApi.java new file mode 100644 index 0000000000..1930881327 --- /dev/null +++ b/openex-api/src/main/java/io/openex/rest/asset_group/AssetGroupApi.java @@ -0,0 +1,76 @@ +package io.openex.rest.asset_group; + +import io.openex.database.model.AssetGroup; +import io.openex.database.repository.TagRepository; +import io.openex.rest.asset_group.form.AssetGroupInput; +import io.openex.rest.asset_group.form.UpdateAssetsOnAssetGroupInput; +import io.openex.service.AssetGroupService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static io.openex.database.model.User.ROLE_ADMIN; +import static io.openex.helper.StreamHelper.fromIterable; + +@RestController +@RequiredArgsConstructor +public class AssetGroupApi { + + public static final String ASSET_GROUP_URI = "/api/asset_groups"; + + private final AssetGroupService assetGroupService; + private final TagRepository tagRepository; + + @PostMapping(ASSET_GROUP_URI) + @Secured(ROLE_ADMIN) + public AssetGroup createAssetGroup(@Valid @RequestBody final AssetGroupInput input) { + AssetGroup assetGroup = new AssetGroup(); + assetGroup.setUpdateAttributes(input); + assetGroup.setTags(fromIterable(this.tagRepository.findAllById(input.getTagIds()))); + return this.assetGroupService.createAssetGroup(assetGroup); + } + + @GetMapping(ASSET_GROUP_URI) + @PreAuthorize("isObserver()") + public List assetGroups() { + return this.assetGroupService.assetGroups(); + } + + @GetMapping(ASSET_GROUP_URI + "/{assetGroupId}") + @PreAuthorize("isObserver()") + public AssetGroup assetGroup(@PathVariable @NotBlank final String assetGroupId) { + return this.assetGroupService.assetGroup(assetGroupId); + } + + @PutMapping(ASSET_GROUP_URI + "/{assetGroupId}") + @Secured(ROLE_ADMIN) + public AssetGroup updateAssetGroup( + @PathVariable @NotBlank final String assetGroupId, + @Valid @RequestBody final AssetGroupInput input) { + AssetGroup assetGroup = this.assetGroupService.assetGroup(assetGroupId); + assetGroup.setUpdateAttributes(input); + assetGroup.setTags(fromIterable(this.tagRepository.findAllById(input.getTagIds()))); + return this.assetGroupService.updateAssetGroup(assetGroup); + } + + @PutMapping(ASSET_GROUP_URI + "/{assetGroupId}/assets") + @Secured(ROLE_ADMIN) + public AssetGroup updateAssetsOnAssetGroup( + @PathVariable @NotBlank final String assetGroupId, + @Valid @RequestBody final UpdateAssetsOnAssetGroupInput input) { + AssetGroup assetGroup = this.assetGroupService.assetGroup(assetGroupId); + return this.assetGroupService.updateAssetsOnAssetGroup(assetGroup, input.getAssetIds()); + } + + @DeleteMapping(ASSET_GROUP_URI + "/{assetGroupId}") + @Secured(ROLE_ADMIN) + public void deleteAssetGroup(@PathVariable @NotBlank final String assetGroupId) { + this.assetGroupService.deleteAssetGroup(assetGroupId); + } + +} diff --git a/openex-api/src/main/java/io/openex/rest/asset_group/form/AssetGroupInput.java b/openex-api/src/main/java/io/openex/rest/asset_group/form/AssetGroupInput.java new file mode 100644 index 0000000000..0d76fc0bf0 --- /dev/null +++ b/openex-api/src/main/java/io/openex/rest/asset_group/form/AssetGroupInput.java @@ -0,0 +1,27 @@ +package io.openex.rest.asset_group.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +import jakarta.validation.constraints.NotBlank; + +import java.util.ArrayList; +import java.util.List; + +import static io.openex.config.AppConfig.MANDATORY_MESSAGE; + +@Getter +@Setter +public class AssetGroupInput { + + @NotBlank(message = MANDATORY_MESSAGE) + @JsonProperty("asset_group_name") + private String name; + + @JsonProperty("asset_group_description") + private String description; + + @JsonProperty("asset_group_tags") + private List tagIds = new ArrayList<>(); +} diff --git a/openex-api/src/main/java/io/openex/rest/asset_group/form/UpdateAssetsOnAssetGroupInput.java b/openex-api/src/main/java/io/openex/rest/asset_group/form/UpdateAssetsOnAssetGroupInput.java new file mode 100644 index 0000000000..9eae8dba70 --- /dev/null +++ b/openex-api/src/main/java/io/openex/rest/asset_group/form/UpdateAssetsOnAssetGroupInput.java @@ -0,0 +1,16 @@ +package io.openex.rest.asset_group.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class UpdateAssetsOnAssetGroupInput { + + @JsonProperty("asset_group_assets") + private List assetIds; + +} diff --git a/openex-api/src/main/java/io/openex/rest/contract/ContractApi.java b/openex-api/src/main/java/io/openex/rest/contract/ContractApi.java new file mode 100644 index 0000000000..b1c9fc2371 --- /dev/null +++ b/openex-api/src/main/java/io/openex/rest/contract/ContractApi.java @@ -0,0 +1,45 @@ +package io.openex.rest.contract; + +import io.openex.contract.ContractConfig; +import io.openex.rest.helper.RestBehavior; +import io.openex.service.ContractService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.IOUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.io.InputStream; +import java.util.*; + +@RequiredArgsConstructor +@RestController +@Slf4j +public class ContractApi extends RestBehavior { + + public static final String CONTRACT_URI = "/api/contracts"; + + private final ContractService contractService; + + @GetMapping(value = CONTRACT_URI + "/images") + public @ResponseBody Map contractIcon() { + List contractTypes = this.contractService.getContractConfigs(); + Map map = new HashMap<>(); + contractTypes.forEach((contract -> { + try { + String fileName = contract.getIcon(); + InputStream in = getClass().getResourceAsStream(fileName); + assert in != null; + byte[] fileContent; + fileContent = IOUtils.toByteArray(in); + String encodedString = Base64.getEncoder().encodeToString(fileContent); + map.put(contract.getType(), encodedString); + } catch (Exception e) { + log.debug("Logo not found for contract : " + contract.getType()); + } + })); + return map; + } + +} diff --git a/openex-api/src/main/java/io/openex/runner/InitAdminCommandLineRunner.java b/openex-api/src/main/java/io/openex/runner/InitAdminCommandLineRunner.java index 25c88f2a31..b4300e7a0c 100644 --- a/openex-api/src/main/java/io/openex/runner/InitAdminCommandLineRunner.java +++ b/openex-api/src/main/java/io/openex/runner/InitAdminCommandLineRunner.java @@ -18,8 +18,7 @@ import static io.openex.database.model.Token.ADMIN_TOKEN_UUID; import static io.openex.database.model.User.*; -import static org.apache.logging.log4j.util.Strings.isBlank; -import static org.apache.logging.log4j.util.Strings.isNotBlank; +import static org.springframework.util.StringUtils.hasText; @Component public class InitAdminCommandLineRunner implements CommandLineRunner { @@ -64,12 +63,12 @@ private String encodedPassword() { } private User createUser() { - if (isBlank(this.adminEmail)) { + if (!hasText(this.adminEmail)) { throw new IllegalArgumentException("Config properties 'openex.admin.email' cannot be null"); } else if (!EmailValidator.getInstance().isValid(this.adminEmail)) { throw new IllegalArgumentException("Config properties 'openex.admin.email' should be a valid email address"); } - if (isBlank(this.adminPassword)) { + if (!hasText(this.adminPassword)) { throw new IllegalArgumentException("Config properties 'openex.admin.password' cannot be null"); } @@ -78,13 +77,13 @@ private User createUser() { } private User updateUser(@NotNull final User user) { - if (isNotBlank(this.adminEmail)) { + if (hasText(this.adminEmail)) { if (!EmailValidator.getInstance().isValid(this.adminEmail)) { throw new IllegalArgumentException("Config properties 'openex.admin.email' should be a valid email address"); } user.setEmail(this.adminEmail); } - if (isNotBlank(this.adminPassword)) { + if (hasText(this.adminPassword)) { user.setPassword(encodedPassword()); } @@ -94,7 +93,7 @@ private User updateUser(@NotNull final User user) { // -- TOKEN -- private void createToken(@NotNull final User user) { - if (isBlank(this.adminToken)) { + if (!hasText(this.adminToken)) { throw new IllegalArgumentException("Config properties 'openex.admin.token' cannot be null"); } try { @@ -107,7 +106,7 @@ private void createToken(@NotNull final User user) { } private void updateToken(@NotNull final Token token) { - if (isNotBlank(this.adminToken)) { + if (hasText(this.adminToken)) { try { UUID.fromString(this.adminToken); } catch (IllegalArgumentException e) { diff --git a/openex-api/src/main/java/io/openex/service/ContractService.java b/openex-api/src/main/java/io/openex/service/ContractService.java index 146cebb5bc..a159516bb3 100644 --- a/openex-api/src/main/java/io/openex/service/ContractService.java +++ b/openex-api/src/main/java/io/openex/service/ContractService.java @@ -1,6 +1,7 @@ package io.openex.service; import io.openex.contract.Contract; +import io.openex.contract.ContractConfig; import io.openex.contract.Contractor; import io.openex.database.model.Inject; import lombok.Getter; @@ -8,9 +9,8 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import jakarta.validation.constraints.NotNull; +import java.util.*; import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; @@ -19,36 +19,49 @@ @Service public class ContractService { - private static final Logger LOGGER = Logger.getLogger(ContractService.class.getName()); - @Getter - private final Map contracts = new HashMap<>(); - private List baseContracts; - - @Autowired - public void setBaseContracts(List baseContracts) { - this.baseContracts = baseContracts; - } - - // Build the contracts every minute - @Scheduled(fixedDelay = 60000, initialDelay = 0) - public void buildContracts() { - baseContracts.stream().parallel().forEach(helper -> { - try { - Map contractInstances = helper.contracts() - .stream().collect(Collectors.toMap(Contract::getId, Function.identity())); - contracts.putAll(contractInstances); - } catch (Exception e) { - LOGGER.log(Level.SEVERE, e.getMessage(), e); - } - }); - } - - public String getContractType(String contractId) { - Contract contractInstance = contracts.get(contractId); - return contractInstance != null ? contractInstance.getConfig().getType() : null; - } - - public Contract resolveContract(Inject inject) { - return contracts.get(inject.getContract()); - } + private static final Logger LOGGER = Logger.getLogger(ContractService.class.getName()); + @Getter + private final Map contracts = new HashMap<>(); + private List baseContracts; + + @Autowired + public void setBaseContracts(List baseContracts) { + this.baseContracts = baseContracts; + } + + // Build the contracts every minute + @Scheduled(fixedDelay = 60000, initialDelay = 0) + public void buildContracts() { + this.baseContracts.stream().parallel().forEach(helper -> { + try { + Map contractInstances = helper.contracts() + .stream() + .collect(Collectors.toMap(Contract::getId, Function.identity())); + this.contracts.putAll(contractInstances); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, e.getMessage(), e); + } + }); + } + + public Contract contract(@NotNull final String contractId) { + return this.contracts.get(contractId); + } + + public List getContractConfigs() { + return this.contracts.values() + .stream() + .map(Contract::getConfig) + .distinct() + .toList(); + } + + public String getContractType(@NotNull final String contractId) { + Contract contractInstance = this.contracts.get(contractId); + return contractInstance != null ? contractInstance.getConfig().getType() : null; + } + + public Contract resolveContract(Inject inject) { + return this.contracts.get(inject.getContract()); + } } diff --git a/openex-api/src/main/java/io/openex/service/UserService.java b/openex-api/src/main/java/io/openex/service/UserService.java index a9d25bdbdc..eb260b2d65 100644 --- a/openex-api/src/main/java/io/openex/service/UserService.java +++ b/openex-api/src/main/java/io/openex/service/UserService.java @@ -8,6 +8,7 @@ import io.openex.database.specification.GroupSpecification; import io.openex.rest.user.form.user.CreateUserInput; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContext; @@ -18,6 +19,7 @@ import org.springframework.util.StringUtils; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -70,28 +72,9 @@ public boolean isUserPasswordValid(User user, String password) { } public void createUserSession(User user) { - List roles = new ArrayList<>(); - roles.add(new SimpleGrantedAuthority(ROLE_USER)); - if (user.isAdmin()) { - roles.add(new SimpleGrantedAuthority(ROLE_ADMIN)); - } + Authentication authentication = buildAuthenticationToken(user); SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(new PreAuthenticatedAuthenticationToken(new OpenexPrincipal() { - @Override - public String getId() { - return user.getId(); - } - - @Override - public Collection getAuthorities() { - return roles; - } - - @Override - public boolean isAdmin() { - return user.isAdmin(); - } - }, "", roles)); + context.setAuthentication(authentication); SecurityContextHolder.setContext(context); } @@ -134,4 +117,28 @@ public User user(@NotBlank final String userId) { } // endregion + + public static PreAuthenticatedAuthenticationToken buildAuthenticationToken(@NotNull final User user) { + List roles = new ArrayList<>(); + roles.add(new SimpleGrantedAuthority(ROLE_USER)); + if (user.isAdmin()) { + roles.add(new SimpleGrantedAuthority(ROLE_ADMIN)); + } + return new PreAuthenticatedAuthenticationToken(new OpenexPrincipal() { + @Override + public String getId() { + return user.getId(); + } + + @Override + public Collection getAuthorities() { + return roles; + } + + @Override + public boolean isAdmin() { + return user.isAdmin(); + } + }, "", roles); + } } diff --git a/openex-api/src/main/resources/application.properties b/openex-api/src/main/resources/application.properties index 5abaf332cd..94cab018d4 100644 --- a/openex-api/src/main/resources/application.properties +++ b/openex-api/src/main/resources/application.properties @@ -171,5 +171,19 @@ lade.password= # Injector Http config http.enable=true +# Injector Caldera config +injector.caldera.enable=false +injector.caldera.id= +injector.caldera.collector-ids= +injector.caldera.url= +injector.caldera.api-key= + # Collectors +## Collector user collector.users.enable=false + +## Collector Caldera +collector.caldera.enable=false +collector.caldera.id= +collector.caldera.url= +collector.caldera.api-key= diff --git a/openex-api/src/test/java/io/openex/helper/InjectHelperTest.java b/openex-api/src/test/java/io/openex/helper/InjectHelperTest.java index 91abb15520..5f4c628081 100644 --- a/openex-api/src/test/java/io/openex/helper/InjectHelperTest.java +++ b/openex-api/src/test/java/io/openex/helper/InjectHelperTest.java @@ -3,6 +3,7 @@ import io.openex.database.model.*; import io.openex.database.repository.*; import io.openex.execution.ExecutableInject; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -40,6 +41,7 @@ public class InjectHelperTest { @Autowired private UserRepository userRepository; + @Disabled @DisplayName("Retrieve simple inject to run") @Test void injectsToRunTest() { @@ -88,6 +90,13 @@ void injectsToRunTest() { assertEquals(1, executableInject.getTeamSize()); assertEquals(1, executableInject.getUsers().size()); assertEquals(USER_EMAIL, executableInject.getUsers().get(0).getUser().getEmail()); + + // -- CLEAN - + this.exerciseRepository.delete(exercise); + this.teamRepository.delete(team); + this.userRepository.delete(user); + this.exerciseTeamUserRepository.delete(exerciseTeamUser); + this.injectRepository.delete(inject); } } diff --git a/openex-api/src/test/java/io/openex/injects/InjectCrudTest.java b/openex-api/src/test/java/io/openex/injects/InjectCrudTest.java index e6cdae23fc..ad3bac0998 100644 --- a/openex-api/src/test/java/io/openex/injects/InjectCrudTest.java +++ b/openex-api/src/test/java/io/openex/injects/InjectCrudTest.java @@ -71,6 +71,10 @@ void createInjectSuccess() { // -- EXECUTE -- Inject injectCreated = this.injectRepository.save(inject); assertNotNull(injectCreated); + + // -- CLEAN -- + this.exerciseRepository.delete(exercise); + this.injectRepository.delete(inject); } } diff --git a/openex-api/src/test/java/io/openex/rest/AssetGroupApiTest.java b/openex-api/src/test/java/io/openex/rest/AssetGroupApiTest.java new file mode 100644 index 0000000000..b6f8999a70 --- /dev/null +++ b/openex-api/src/test/java/io/openex/rest/AssetGroupApiTest.java @@ -0,0 +1,183 @@ +package io.openex.rest; + +import com.fasterxml.jackson.core.type.TypeReference; +import io.openex.database.model.AssetGroup; +import io.openex.database.repository.AssetGroupRepository; +import io.openex.database.repository.EndpointRepository; +import io.openex.rest.asset_group.form.AssetGroupInput; +import io.openex.rest.utils.WithMockObserverUser; +import io.openex.rest.utils.WithMockPlannerUser; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static io.openex.rest.asset_group.AssetGroupApi.ASSET_GROUP_URI; +import static io.openex.rest.utils.JsonUtils.asJsonString; +import static io.openex.rest.utils.JsonUtils.asStringJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@TestMethodOrder(OrderAnnotation.class) +@TestInstance(PER_CLASS) +public class AssetGroupApiTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private AssetGroupRepository assetGroupRepository; + @Autowired + private EndpointRepository endpointRepository; + + static String ASSET_GROUP_ID; + + @AfterAll + public void teardown() { + this.assetGroupRepository.deleteAll(); + this.endpointRepository.deleteAll(); + } + + @DisplayName("Create asset group succeed") + @Test + @Order(1) + @WithMockUser(roles = {"ADMIN"}) + void createAssetGroupTest() throws Exception { + // -- PREPARE -- + AssetGroupInput assetGroupInput = new AssetGroupInput(); + String name = "Zone"; + assetGroupInput.setName(name); + + // -- EXECUTE -- + String response = this.mvc + .perform(post(ASSET_GROUP_URI) + .content(asJsonString(assetGroupInput)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + AssetGroup assetGroupResponse = asStringJson(response, AssetGroup.class); + ASSET_GROUP_ID = assetGroupResponse.getId(); + + // -- ASSERT -- + assertEquals(name, assetGroupResponse.getName()); + } + + @DisplayName("Retrieve asset groups succeed") + @Test + @Order(2) + @WithMockObserverUser + void retrieveAssetGroupsTest() throws Exception { + // -- EXECUTE -- + String response = this.mvc + .perform(get(ASSET_GROUP_URI).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + List assetGroupsResponse = asStringJson(response, new TypeReference<>() { + }); + + // -- ASSERT -- + assertEquals(1, assetGroupsResponse.size()); + } + + @DisplayName("Retrieve asset group succeed") + @Test + @Order(3) + @WithMockObserverUser + void retrieveAssetGroupTest() throws Exception { + // -- EXECUTE -- + String response = this.mvc + .perform(get(ASSET_GROUP_URI + "/" + ASSET_GROUP_ID).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + AssetGroup assetGroupResponse = asStringJson(response, new TypeReference<>() { + }); + + // -- ASSERT -- + assertNotNull(assetGroupResponse); + } + + @DisplayName("Update asset group succeed") + @Test + @Order(4) + @WithMockUser(roles = {"ADMIN"}) + void updateGroupAssetTest() throws Exception { + // -- PREPARE -- + AssetGroup assetGroupReponse = getFirstAssetGroup(); + AssetGroupInput assetGroupInput = new AssetGroupInput(); + String name = "Change zone name"; + assetGroupInput.setName(name); + + // -- EXECUTE -- + String response = this.mvc + .perform(put(ASSET_GROUP_URI + "/" + assetGroupReponse.getId()) + .content(asJsonString(assetGroupInput)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + assetGroupReponse = asStringJson(response, AssetGroup.class); + + // -- ASSERT -- + assertEquals(name, assetGroupReponse.getName()); + } + + @DisplayName("Delete asset group failed") + @Test + @Order(5) + @WithMockPlannerUser + void deleteAssetGroupFailedTest() throws Exception { + // -- PREPARE -- + AssetGroup assetGroupReponse = getFirstAssetGroup(); + + // -- EXECUTE & ASSERT -- + this.mvc.perform(delete(ASSET_GROUP_URI + "/" + assetGroupReponse.getId()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is4xxClientError()); + } + + @DisplayName("Delete asset group succeed") + @Test + @Order(6) + @WithMockUser(roles = {"ADMIN"}) + void deleteAssetGroupSucceedTest() throws Exception { + // -- PREPARE -- + AssetGroup assetGroupReponse = getFirstAssetGroup(); + + // -- EXECUTE -- + this.mvc.perform(delete(ASSET_GROUP_URI + "/" + assetGroupReponse.getId()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + assertEquals(0, this.assetGroupRepository.count()); + } + + // -- PRIVATE -- + + private AssetGroup getFirstAssetGroup() { + return this.assetGroupRepository.findAll().iterator().next(); + } + +} diff --git a/openex-api/src/test/java/io/openex/rest/EndpointApiTest.java b/openex-api/src/test/java/io/openex/rest/EndpointApiTest.java new file mode 100644 index 0000000000..6b7eded963 --- /dev/null +++ b/openex-api/src/test/java/io/openex/rest/EndpointApiTest.java @@ -0,0 +1,164 @@ +package io.openex.rest; + +import com.fasterxml.jackson.core.type.TypeReference; +import io.openex.database.model.Endpoint; +import io.openex.database.repository.EndpointRepository; +import io.openex.rest.asset.endpoint.form.EndpointInput; +import io.openex.rest.utils.WithMockObserverUser; +import io.openex.rest.utils.WithMockPlannerUser; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static io.openex.database.model.Endpoint.PLATFORM_TYPE.Linux; +import static io.openex.rest.asset.endpoint.EndpointApi.ENDPOINT_URI; +import static io.openex.rest.utils.JsonUtils.asJsonString; +import static io.openex.rest.utils.JsonUtils.asStringJson; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@TestMethodOrder(OrderAnnotation.class) +@TestInstance(PER_CLASS) +public class EndpointApiTest { + + @Autowired + private MockMvc mvc; + + @Autowired + private EndpointRepository endpointRepository; + + @AfterAll + void teardown() { + this.endpointRepository.deleteAll(); + } + + @DisplayName("Create endpoint succeed") + @Test + @Order(1) + @WithMockUser(roles = {"ADMIN"}) + void createEndpointTest() throws Exception { + // -- PREPARE -- + EndpointInput endpointInput = new EndpointInput(); + String name = "Personal PC"; + endpointInput.setName(name); + endpointInput.setIps(new String[]{"127.0.0.1"}); + endpointInput.setHostname("hostname"); + endpointInput.setPlatform(Linux); + + // -- EXECUTE -- + String response = this.mvc + .perform(post(ENDPOINT_URI) + .content(asJsonString(endpointInput)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + Endpoint endpointResponse = asStringJson(response, Endpoint.class); + + // -- ASSERT -- + assertEquals(name, endpointResponse.getName()); + } + + @DisplayName("Retrieve endpoint succeed") + @Test + @Order(2) + @WithMockObserverUser + void retrieveEndpointTest() throws Exception { + // -- EXECUTE -- + String response = this.mvc + .perform(get(ENDPOINT_URI).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + List endpoints = asStringJson(response, new TypeReference<>() { + }); + + // -- ASSERT -- + assertEquals(1, endpoints.size()); + } + + @DisplayName("Update endpoint succeed") + @Test + @Order(3) + @WithMockUser(roles = {"ADMIN"}) + void updateEndpointTest() throws Exception { + // -- PREPARE -- + Endpoint endpointResponse = getFirstEndpoint(); + EndpointInput endpointInput = new EndpointInput(); + String name = "Professional PC"; + endpointInput.setName(name); + endpointInput.setIps(endpointResponse.getIps()); + endpointInput.setHostname(endpointResponse.getHostname()); + endpointInput.setPlatform(endpointResponse.getPlatform()); + + // -- EXECUTE -- + String response = this.mvc + .perform(put(ENDPOINT_URI + "/" + endpointResponse.getId()) + .content(asJsonString(endpointInput)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()) + .andReturn() + .getResponse() + .getContentAsString(); + endpointResponse = asStringJson(response, Endpoint.class); + + // -- ASSERT -- + assertEquals(name, endpointResponse.getName()); + } + + + @DisplayName("Delete endpoint failed") + @Test + @Order(3) + @WithMockPlannerUser + void deleteEndpointFailedTest() throws Exception { + // -- PREPARE -- + Endpoint endpointResponse = getFirstEndpoint(); + + // -- EXECUTE & ASSERT -- + this.mvc.perform(delete(ENDPOINT_URI + "/" + endpointResponse.getId()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is4xxClientError()); + } + + @DisplayName("Delete endpoint succeed") + @Test + @Order(5) + @WithMockUser(roles = {"ADMIN"}) + void deleteEndpointSucceedTest() throws Exception { + // -- PREPARE -- + Endpoint endpointResponse = getFirstEndpoint(); + + // -- EXECUTE -- + this.mvc.perform(delete(ENDPOINT_URI + "/" + endpointResponse.getId()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()); + + // -- ASSERT -- + assertEquals(0, this.endpointRepository.count()); + } + + // -- PRIVATE -- + + private Endpoint getFirstEndpoint() { + return this.endpointRepository.findAll().iterator().next(); + } + +} diff --git a/openex-api/src/test/java/io/openex/rest/utils/JsonUtils.java b/openex-api/src/test/java/io/openex/rest/utils/JsonUtils.java new file mode 100644 index 0000000000..d91298acd3 --- /dev/null +++ b/openex-api/src/test/java/io/openex/rest/utils/JsonUtils.java @@ -0,0 +1,47 @@ +package io.openex.rest.utils; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class JsonUtils { + + private static ObjectMapper mapper; + + private static ObjectMapper getMapper() { + if (mapper != null) { + return mapper; + } + mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + return mapper; + } + + public static String asJsonString(@NotNull final Object obj) { + try { + return getMapper().writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static T asStringJson(@NotBlank final String obj, @NotNull final Class clazz) { + try { + return getMapper().readValue(obj, clazz); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static T asStringJson(@NotBlank final String obj, @NotNull final TypeReference typeReference) { + try { + return getMapper().readValue(obj, typeReference); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUser.java b/openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUser.java new file mode 100644 index 0000000000..d5a0159b30 --- /dev/null +++ b/openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUser.java @@ -0,0 +1,14 @@ +package io.openex.rest.utils; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import static io.openex.rest.utils.WithMockObserverUserSecurityContextFactory.MOCK_USER_OBSERVER_EMAIL; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockObserverUserSecurityContextFactory.class) +public @interface WithMockObserverUser { + String email() default MOCK_USER_OBSERVER_EMAIL; +} diff --git a/openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUserSecurityContextFactory.java b/openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUserSecurityContextFactory.java new file mode 100644 index 0000000000..2272685496 --- /dev/null +++ b/openex-api/src/test/java/io/openex/rest/utils/WithMockObserverUserSecurityContextFactory.java @@ -0,0 +1,73 @@ +package io.openex.rest.utils; + +import io.openex.database.model.Grant; +import io.openex.database.model.Group; +import io.openex.database.model.User; +import io.openex.database.repository.GrantRepository; +import io.openex.database.repository.GroupRepository; +import io.openex.database.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.util.List; + +import static io.openex.database.model.Grant.GRANT_TYPE.OBSERVER; +import static io.openex.service.UserService.buildAuthenticationToken; + +@Component +public class WithMockObserverUserSecurityContextFactory implements WithSecurityContextFactory { + + public static final String MOCK_USER_OBSERVER_EMAIL = "observer@opencti.io"; + @Autowired + private GrantRepository grantRepository; + @Autowired + private GroupRepository groupRepository; + @Autowired + private UserRepository userRepository; + + @Override + public SecurityContext createSecurityContext(WithMockObserverUser customUser) { + User user = this.userRepository.findByEmail(customUser.email()).orElseThrow(); + Authentication authentication = buildAuthenticationToken(user); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + return context; + } + + @PostConstruct + private void postConstruct() { + this.createObserverMockUser(); + } + + @PreDestroy + public void preDestroy() { + this.userRepository.deleteById(this.userRepository.findByEmail(MOCK_USER_OBSERVER_EMAIL).orElseThrow().getId()); + } + + private void createObserverMockUser() { + if (this.userRepository.findByEmail(MOCK_USER_OBSERVER_EMAIL).isPresent()) { + return; + } + + // Create group + Group group = new Group(); + group.setName("Observer group"); + group = this.groupRepository.save(group); + // Create grant + Grant grant = new Grant(); + grant.setName(OBSERVER); + grant.setGroup(group); + this.grantRepository.save(grant); + // Create user + User user = new User(); + user.setGroups(List.of(group)); + user.setEmail(MOCK_USER_OBSERVER_EMAIL); + this.userRepository.save(user); + } +} diff --git a/openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUser.java b/openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUser.java new file mode 100644 index 0000000000..85db0f29a5 --- /dev/null +++ b/openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUser.java @@ -0,0 +1,14 @@ +package io.openex.rest.utils; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import static io.openex.rest.utils.WithMockPlannerUserSecurityContextFactory.MOCK_USER_PLANNER_EMAIL; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockPlannerUserSecurityContextFactory.class) +public @interface WithMockPlannerUser { + String email() default MOCK_USER_PLANNER_EMAIL; +} diff --git a/openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUserSecurityContextFactory.java b/openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUserSecurityContextFactory.java new file mode 100644 index 0000000000..2bf116c179 --- /dev/null +++ b/openex-api/src/test/java/io/openex/rest/utils/WithMockPlannerUserSecurityContextFactory.java @@ -0,0 +1,73 @@ +package io.openex.rest.utils; + +import io.openex.database.model.Grant; +import io.openex.database.model.Group; +import io.openex.database.model.User; +import io.openex.database.repository.GrantRepository; +import io.openex.database.repository.GroupRepository; +import io.openex.database.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.util.List; + +import static io.openex.database.model.Grant.GRANT_TYPE.PLANNER; +import static io.openex.service.UserService.buildAuthenticationToken; + +@Component +public class WithMockPlannerUserSecurityContextFactory implements WithSecurityContextFactory { + + public static final String MOCK_USER_PLANNER_EMAIL = "planner@opencti.io"; + @Autowired + private GrantRepository grantRepository; + @Autowired + private GroupRepository groupRepository; + @Autowired + private UserRepository userRepository; + + @Override + public SecurityContext createSecurityContext(WithMockPlannerUser customUser) { + User user = this.userRepository.findByEmail(customUser.email()).orElseThrow(); + Authentication authentication = buildAuthenticationToken(user); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + return context; + } + + @PostConstruct + private void postConstruct() { + this.createPlannerMockUser(); + } + + @PreDestroy + public void preDestroy() { + this.userRepository.deleteById(this.userRepository.findByEmail(MOCK_USER_PLANNER_EMAIL).orElseThrow().getId()); + } + + private void createPlannerMockUser() { + if (this.userRepository.findByEmail(MOCK_USER_PLANNER_EMAIL).isPresent()) { + return; + } + + // Create group + Group group = new Group(); + group.setName("Planner group"); + group = this.groupRepository.save(group); + // Create grant + Grant grant = new Grant(); + grant.setName(PLANNER); + grant.setGroup(group); + this.grantRepository.save(grant); + // Create user + User user = new User(); + user.setGroups(List.of(group)); + user.setEmail(MOCK_USER_PLANNER_EMAIL); + this.userRepository.save(user); + } +} diff --git a/openex-api/src/test/java/io/openex/service/AssetEndpointServiceTest.java b/openex-api/src/test/java/io/openex/service/AssetEndpointServiceTest.java new file mode 100644 index 0000000000..aab2eb4378 --- /dev/null +++ b/openex-api/src/test/java/io/openex/service/AssetEndpointServiceTest.java @@ -0,0 +1,135 @@ +package io.openex.service; + +import io.openex.database.model.Asset; +import io.openex.database.model.Endpoint; +import io.openex.database.model.Tag; +import io.openex.database.repository.TagRepository; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.TransactionSystemException; + +import java.util.HashMap; +import java.util.List; +import java.util.NoSuchElementException; + +import static io.openex.database.model.Endpoint.PLATFORM_TYPE.Linux; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AssetEndpointServiceTest { + + @Autowired + private AssetEndpointService assetEndpointService; + @Autowired + private AssetService assetService; + @Autowired + private TagRepository tagRepository; + + static String ENDPOINT_ID; + + @DisplayName("Create endpoint failed") + @Test + @Order(1) + void createEndpointFailedTest() { + // -- PREPARE -- + Endpoint endpoint = new Endpoint(); + String name = "Personal PC"; + endpoint.setName(name); + endpoint.setIps(new String[]{"wrong ip"}); + endpoint.setHostname("hostname"); + endpoint.setPlatform(Linux); + endpoint.setHostname("hostname"); + endpoint.setPlatform(Linux); + + // -- EXECUTE -- + assertThrows(TransactionSystemException.class, () -> this.assetEndpointService.createEndpoint(endpoint)); + } + + @DisplayName("Create endpoint succeed") + @Test + @Order(2) + void createEndpointSucceedTest() { + // -- PREPARE -- + Endpoint endpoint = new Endpoint(); + String name = "Personal PC"; + endpoint.setName(name); + endpoint.setIps(new String[]{"127.0.0.1"}); + endpoint.setHostname("hostname"); + endpoint.setPlatform(Linux); + endpoint.setHostname("hostname"); + endpoint.setPlatform(Linux); + endpoint.setSources(new HashMap<>(){{ + put("Manual", "manual"); + put("Caldera", "caldera-id"); + }}); + + // -- EXECUTE -- + Endpoint endpointCreated = this.assetEndpointService.createEndpoint(endpoint); + ENDPOINT_ID = endpointCreated.getId(); + assertNotNull(endpointCreated); + assertNotNull(endpointCreated.getId()); + assertNotNull(endpointCreated.getCreatedAt()); + assertNotNull(endpointCreated.getUpdatedAt()); + assertEquals(name, endpointCreated.getName()); + } + + @DisplayName("Retrieve endpoint") + @Test + @Order(3) + void retrieveEndpointTest() { + List assets = this.assetService.assets(List.of("Endpoint")); + assertTrue(assets.stream().map(Asset::getId).toList().contains(ENDPOINT_ID)); + + Endpoint endpoint = this.assetEndpointService.endpoint(ENDPOINT_ID); + assertNotNull(endpoint); + + List endpoints = this.assetEndpointService.endpoints(); + assertNotNull(endpoints); + assertTrue(endpoints.stream().map(Endpoint::getId).toList().contains(ENDPOINT_ID)); + } + + @DisplayName("Update endpoint") + @Test + @Order(4) + void updateEndpointTest() { + // -- PREPARE -- + Endpoint endpoint = this.assetEndpointService.endpoint(ENDPOINT_ID); + String value = "Professional PC"; + endpoint.setName(value); + + // -- EXECUTE -- + Endpoint endpointUpdated = this.assetEndpointService.updateEndpoint(endpoint); + assertNotNull(endpoint); + assertEquals(value, endpointUpdated.getName()); + } + + @DisplayName("Update endpoint with tag") + @Test + @Order(5) + void updateEndpointWithTagTest() { + // -- PREPARE -- + Tag tag = new Tag(); + String tagName = "endpoint"; + tag.setName(tagName); + tag.setColor("blue"); + Tag tagCreated = this.tagRepository.save(tag); + Endpoint endpoint = this.assetEndpointService.endpoint(ENDPOINT_ID); + endpoint.setTags(List.of(tagCreated)); + + // -- EXECUTE -- + Endpoint endpointUpdated = this.assetEndpointService.updateEndpoint(endpoint); + assertNotNull(endpointUpdated); + assertEquals(tagName, endpointUpdated.getTags().get(0).getName()); + } + + @DisplayName("Delete endpoint") + @Test + @Order(6) + void deleteEndpointTest() { + this.assetEndpointService.deleteEndpoint(ENDPOINT_ID); + assertThrows(NoSuchElementException.class, () -> this.assetEndpointService.endpoint(ENDPOINT_ID)); + } + +} diff --git a/openex-api/src/test/java/io/openex/service/AssetGroupServiceTest.java b/openex-api/src/test/java/io/openex/service/AssetGroupServiceTest.java new file mode 100644 index 0000000000..db487de814 --- /dev/null +++ b/openex-api/src/test/java/io/openex/service/AssetGroupServiceTest.java @@ -0,0 +1,111 @@ +package io.openex.service; + +import io.openex.database.model.AssetGroup; +import io.openex.database.model.Endpoint; +import io.openex.database.model.Tag; +import io.openex.database.repository.TagRepository; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.NoSuchElementException; + +import static io.openex.database.model.Endpoint.PLATFORM_TYPE.Linux; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class AssetGroupServiceTest { + + @Autowired + private AssetGroupService assetGroupService; + @Autowired + private AssetEndpointService assetEndpointService; + @Autowired + private TagRepository tagRepository; + + static String ASSET_GROUP_ID; + + @DisplayName("Create asset group") + @Test + @Order(1) + void createAssetGroupTest() { + // -- PREPARE -- + AssetGroup assetGroup = new AssetGroup(); + String name = "Personal network"; + assetGroup.setName(name); + + // -- EXECUTE -- + AssetGroup assetGroupCreated = this.assetGroupService.createAssetGroup(assetGroup); + ASSET_GROUP_ID = assetGroupCreated.getId(); + assertNotNull(assetGroupCreated); + assertNotNull(assetGroupCreated.getId()); + assertNotNull(assetGroupCreated.getCreatedAt()); + assertNotNull(assetGroupCreated.getUpdatedAt()); + assertEquals(name, assetGroupCreated.getName()); + } + + @DisplayName("Retrieve asset group") + @Test + @Order(2) + void retrieveAssetGroupTest() { + AssetGroup assetGroup = this.assetGroupService.assetGroup(ASSET_GROUP_ID); + assertNotNull(assetGroup); + + List assetGroups = this.assetGroupService.assetGroups(); + assertNotNull(assetGroups); + assertEquals(ASSET_GROUP_ID, assetGroups.get(0).getId()); + } + + @DisplayName("Update asset group") + @Test + @Order(3) + void updateAssetGroupTest() { + // -- PREPARE -- + Endpoint endpoint = new Endpoint(); + String name = "Personal PC"; + endpoint.setName(name); + endpoint.setIps(new String[]{"127.0.0.1"}); + endpoint.setHostname("hostname"); + endpoint.setPlatform(Linux); + Endpoint endpointCreated = this.assetEndpointService.createEndpoint(endpoint); + + AssetGroup assetGroup = this.assetGroupService.assetGroup(ASSET_GROUP_ID); + String value = "Professional network"; + assetGroup.setName(value); + assetGroup.setAssets(List.of(endpointCreated)); + + // -- EXECUTE -- + AssetGroup assetGroupUpdated = this.assetGroupService.updateAssetGroup(assetGroup); + assertNotNull(assetGroupUpdated); + assertEquals(value, assetGroupUpdated.getName()); + } + + @DisplayName("Update asset group with tag") + @Test + @Order(4) + void updateAssetGroupWithTagTest() { + // -- PREPARE -- + Tag tag = new Tag(); + String tagName = "endpoint"; + tag.setName(tagName); + tag.setColor("blue"); + Tag tagCreated = this.tagRepository.save(tag); + AssetGroup assetGroup = this.assetGroupService.assetGroup(ASSET_GROUP_ID); + assetGroup.setTags(List.of(tagCreated)); + + // -- EXECUTE -- + AssetGroup assetGroupUpdated = this.assetGroupService.updateAssetGroup(assetGroup); + assertNotNull(assetGroupUpdated); + assertEquals(tagName, assetGroupUpdated.getTags().get(0).getName()); + } + + @DisplayName("Delete asset group") + @Test + @Order(5) + void deleteAssetGroupTest() { + this.assetGroupService.deleteAssetGroup(ASSET_GROUP_ID); + assertThrows(NoSuchElementException.class, () -> this.assetGroupService.assetGroup(ASSET_GROUP_ID)); + } +} diff --git a/openex-collectors b/openex-collectors index 31780bcaad..129dde9be7 160000 --- a/openex-collectors +++ b/openex-collectors @@ -1 +1 @@ -Subproject commit 31780bcaad0329b6cfd691fc1d7f15b11189074a +Subproject commit 129dde9be7a04a9703c2d43bbf70c8782ae5f711 diff --git a/openex-framework/src/main/java/io/openex/contract/ContractConfig.java b/openex-framework/src/main/java/io/openex/contract/ContractConfig.java index bf9df92d92..3538bf7010 100644 --- a/openex-framework/src/main/java/io/openex/contract/ContractConfig.java +++ b/openex-framework/src/main/java/io/openex/contract/ContractConfig.java @@ -1,9 +1,11 @@ package io.openex.contract; import io.openex.helper.SupportedLanguage; +import lombok.Getter; import java.util.Map; +@Getter public class ContractConfig { private final String type; private final boolean expose; @@ -19,23 +21,4 @@ public ContractConfig(String type, Map label, String this.label = label; } - public String getType() { - return type; - } - - public boolean isExpose() { - return expose; - } - - public String getColor() { - return color; - } - - public String getIcon() { - return icon; - } - - public Map getLabel() { - return label; - } } diff --git a/openex-framework/src/main/java/io/openex/contract/ContractDef.java b/openex-framework/src/main/java/io/openex/contract/ContractDef.java index 2dfaffe3a5..85703083db 100644 --- a/openex-framework/src/main/java/io/openex/contract/ContractDef.java +++ b/openex-framework/src/main/java/io/openex/contract/ContractDef.java @@ -1,9 +1,9 @@ package io.openex.contract; import io.openex.contract.fields.ContractElement; -import io.openex.contract.fields.ContractText; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; public class ContractDef { @@ -14,32 +14,37 @@ private ContractDef() { //private constructor } + public static ContractDef contractBuilder() { + return new ContractDef(); + } + public ContractDef addFields(List fields) { this.fields.addAll(fields); return this; } - public static ContractDef contractBuilder() { - return new ContractDef(); - } - public ContractDef mandatory(ContractElement element) { - fields.add(element); + this.fields.add(element); return this; } - public ContractDef mandatory(String key, String label) { - fields.add(new ContractText(key, label)); + public ContractDef mandatoryGroup(ContractElement... elements) { + List keys = Arrays.stream(elements).map(ContractElement::getKey).toList(); + for (ContractElement element : elements) { + element.setMandatory(false); + element.setMandatoryGroups(keys); + this.fields.add(element); + } return this; } public ContractDef optional(ContractElement element) { element.setMandatory(false); - fields.add(element); + this.fields.add(element); return this; } public List build() { - return fields; + return this.fields; } } diff --git a/openex-framework/src/main/java/io/openex/contract/fields/ContractElement.java b/openex-framework/src/main/java/io/openex/contract/fields/ContractElement.java index f3634f8ea7..a7c68069e5 100644 --- a/openex-framework/src/main/java/io/openex/contract/fields/ContractElement.java +++ b/openex-framework/src/main/java/io/openex/contract/fields/ContractElement.java @@ -17,6 +17,7 @@ public abstract class ContractElement { private String label; private boolean mandatory = true; + private List mandatoryGroups; private List linkedFields = new ArrayList<>(); diff --git a/openex-framework/src/main/java/io/openex/service/AssetEndpointService.java b/openex-framework/src/main/java/io/openex/service/AssetEndpointService.java new file mode 100644 index 0000000000..c50006eb83 --- /dev/null +++ b/openex-framework/src/main/java/io/openex/service/AssetEndpointService.java @@ -0,0 +1,58 @@ +package io.openex.service; + +import io.openex.database.model.Endpoint; +import io.openex.database.repository.EndpointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; + +import static io.openex.helper.StreamHelper.fromIterable; +import static java.time.Instant.now; + +@RequiredArgsConstructor +@Service +public class AssetEndpointService { + + private final EndpointRepository endpointRepository; + + public Endpoint createEndpoint(@NotNull final Endpoint endpoint) { + return this.endpointRepository.save(endpoint); + } + + public Iterable createEndpoints(@NotNull final List endpoints) { + return this.endpointRepository.saveAll(endpoints); + } + + public Endpoint endpoint(@NotBlank final String endpointId) { + return this.endpointRepository.findById(endpointId).orElseThrow(); + } + + public Optional findBySource( + @NotBlank final String sourceKey, + @NotBlank final String sourceValue) { + return this.endpointRepository.findBySource(sourceKey, sourceValue); + } + + public List endpoints() { + return fromIterable(this.endpointRepository.findAll()); + } + + public Endpoint updateEndpoint(@NotNull final Endpoint endpoint) { + endpoint.setUpdatedAt(now()); + return this.endpointRepository.save(endpoint); + } + + public Iterable updateEndpoints(@NotNull final List endpoints) { + endpoints.forEach((e) -> e.setUpdatedAt(now())); + return this.endpointRepository.saveAll(endpoints); + } + + public void deleteEndpoint(@NotBlank final String endpointId) { + this.endpointRepository.deleteById(endpointId); + } + +} diff --git a/openex-framework/src/main/java/io/openex/service/AssetGroupService.java b/openex-framework/src/main/java/io/openex/service/AssetGroupService.java new file mode 100644 index 0000000000..9121aac9ba --- /dev/null +++ b/openex-framework/src/main/java/io/openex/service/AssetGroupService.java @@ -0,0 +1,61 @@ +package io.openex.service; + +import io.openex.database.model.Asset; +import io.openex.database.model.AssetGroup; +import io.openex.database.repository.AssetGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +import static io.openex.helper.StreamHelper.fromIterable; +import static java.time.Instant.now; + +@RequiredArgsConstructor +@Service +public class AssetGroupService { + + private final AssetGroupRepository assetGroupRepository; + private final AssetService assetService; + + // -- ASSET GROUP -- + + public AssetGroup createAssetGroup(@NotNull final AssetGroup assetGroup) { + return this.assetGroupRepository.save(assetGroup); + } + + public List assetGroups() { + return fromIterable(this.assetGroupRepository.findAll()); + } + + public AssetGroup assetGroup(@NotBlank final String assetGroupId) { + return this.assetGroupRepository.findById(assetGroupId).orElseThrow(); + } + + public AssetGroup updateAssetGroup(@NotNull final AssetGroup assetGroup) { + assetGroup.setUpdatedAt(now()); + return this.assetGroupRepository.save(assetGroup); + } + + public AssetGroup updateAssetsOnAssetGroup( + @NotNull final AssetGroup assetGroup, + @NotNull final List assetIds) { + Iterable assets = this.assetService.assetFromIds(assetIds); + assetGroup.setAssets(fromIterable(assets)); + assetGroup.setUpdatedAt(now()); + return this.assetGroupRepository.save(assetGroup); + } + + public void deleteAssetGroup(@NotBlank final String assetGroupId) { + this.assetGroupRepository.deleteById(assetGroupId); + } + + // -- ASSET -- + + public List assetsFromAssetGroup(@NotBlank final String assetGroupId) { + return this.assetGroupRepository.assetsFromAssetGroup(assetGroupId); + } + +} diff --git a/openex-framework/src/main/java/io/openex/service/AssetService.java b/openex-framework/src/main/java/io/openex/service/AssetService.java new file mode 100644 index 0000000000..5338794479 --- /dev/null +++ b/openex-framework/src/main/java/io/openex/service/AssetService.java @@ -0,0 +1,25 @@ +package io.openex.service; + +import io.openex.database.model.Asset; +import io.openex.database.repository.AssetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +@RequiredArgsConstructor +@Service +public class AssetService { + + private final AssetRepository assetRepository; + + public List assets(@NotNull final List types) { + return this.assetRepository.findByType(types); + } + + public Iterable assetFromIds(@NotNull final List assetIds) { + return this.assetRepository.findAllById(assetIds); + } + +} diff --git a/openex-front/package.json b/openex-front/package.json index 7f22087996..64c7c31f78 100644 --- a/openex-front/package.json +++ b/openex-front/package.json @@ -71,6 +71,7 @@ "@testing-library/react": "14.1.2", "@types/node": "20.11.5", "@types/react": "18.2.48", + "@types/react-csv": "1.1.10", "@types/react-dom": "18.2.18", "@types/seamless-immutable": "7.1.19", "@typescript-eslint/eslint-plugin": "6.19.0", diff --git a/openex-front/src/actions/Contract.ts b/openex-front/src/actions/Contract.ts new file mode 100644 index 0000000000..4519a1c206 --- /dev/null +++ b/openex-front/src/actions/Contract.ts @@ -0,0 +1,11 @@ +import { Dispatch } from 'redux'; +import { simpleCall } from '../utils/Action'; +import * as Constants from '../constants/ActionTypes'; + +const contractImages = () => async (dispatch: Dispatch) => { + const uri = '/api/contracts/images'; + const ref = simpleCall(uri); + return ref.then((data) => dispatch({ type: Constants.CONTRACT_IMAGES, payload: data })); +}; + +export default contractImages; diff --git a/openex-front/src/actions/Schema.js b/openex-front/src/actions/Schema.js index 7711b2f16a..92a299b168 100644 --- a/openex-front/src/actions/Schema.js +++ b/openex-front/src/actions/Schema.js @@ -242,6 +242,7 @@ const maps = (key, state) => state.referential.entities[key].asMutable({ deep: t const entities = (key, state) => Object.values(maps(key, state)); const entity = (id, key, state) => state.referential.entities[key][id]?.asMutable({ deep: true }); const me = (state) => state.referential.entities.users[R.path(['logged', 'user'], state.app)]; +const contractImages = (state) => state.app.contractImages; export const storeHelper = (state) => ({ logged: () => state.app.logged, @@ -295,7 +296,7 @@ export const storeHelper = (state) => ({ ), getExerciseUserLessonsAnswers: (exerciseId, userId) => entities('lessonsanswers', state).filter( (l) => l.lessons_answer_exercise === exerciseId - && l.lessons_answer_user === userId, + && l.lessons_answer_user === userId, ), getExerciseReports: (exerciseId) => entities('reports', state).filter((l) => l.report_exercise === exerciseId), // report @@ -335,7 +336,7 @@ export const storeHelper = (state) => ({ entities('inject_types', state) .map((t) => ({ hasTeams: - t.fields.filter((f) => f.key === 'teams').length > 0, + t.fields.filter((f) => f.key === 'teams').length > 0, ...t, })) .filter((t) => !t.hasTeams) @@ -407,4 +408,12 @@ export const storeHelper = (state) => ({ getLessonsTemplateCategoryQuestions: (id) => entities('lessonstemplatequestions', state).filter( (c) => c.lessons_template_question_category === id, ), + // assets + getEndpoints: () => entities('endpoints', state), + getEndpointsMap: () => maps('endpoints', state), + // asset groups + getAssetGroups: () => entities('asset_groups', state), + getAssetGroup: (id) => entity(id, 'asset_groups', state), + // contracts + getContractImages: () => contractImages(state), }); diff --git a/openex-front/src/actions/assetgroups/assetgroup-action.ts b/openex-front/src/actions/assetgroups/assetgroup-action.ts new file mode 100644 index 0000000000..150fbc696a --- /dev/null +++ b/openex-front/src/actions/assetgroups/assetgroup-action.ts @@ -0,0 +1,40 @@ +import { Dispatch } from 'redux'; +import type { AssetGroup, AssetGroupInput, UpdateAssetsOnAssetGroupInput } from '../../utils/api-types'; +import { delReferential, getReferential, postReferential, putReferential } from '../../utils/Action'; +import { arrayOfAssetGroups, assetGroup } from './assetgroup-schema'; + +const ASSET_GROUP_URI = '/api/asset_groups'; + +export const addAssetGroup = (data: AssetGroupInput) => (dispatch: Dispatch) => { + return postReferential(assetGroup, ASSET_GROUP_URI, data)(dispatch); +}; + +export const updateAssetGroup = ( + assetGroupId: AssetGroup['asset_group_id'], + data: AssetGroupInput, +) => (dispatch: Dispatch) => { + const uri = `${ASSET_GROUP_URI}/${assetGroupId}`; + return putReferential(assetGroup, uri, data)(dispatch); +}; + +export const updateAssetsOnAssetGroup = ( + assetGroupId: AssetGroup['asset_group_id'], + data: UpdateAssetsOnAssetGroupInput, +) => (dispatch: Dispatch) => { + const uri = `${ASSET_GROUP_URI}/${assetGroupId}/assets`; + return putReferential(assetGroup, uri, data)(dispatch); +}; + +export const deleteAssetGroup = (assetGroupId: AssetGroup['asset_group_id']) => (dispatch: Dispatch) => { + const uri = `${ASSET_GROUP_URI}/${assetGroupId}`; + return delReferential(uri, assetGroup.key, assetGroupId)(dispatch); +}; + +export const fetchAssetGroups = () => (dispatch: Dispatch) => { + return getReferential(arrayOfAssetGroups, ASSET_GROUP_URI)(dispatch); +}; + +export const fetchAssetGroup = (assetGroupId: AssetGroup['asset_group_id']) => (dispatch: Dispatch) => { + const uri = `${ASSET_GROUP_URI}/${assetGroupId}`; + return getReferential(assetGroup, uri)(dispatch); +}; diff --git a/openex-front/src/actions/assetgroups/assetgroup-helper.d.ts b/openex-front/src/actions/assetgroups/assetgroup-helper.d.ts new file mode 100644 index 0000000000..4a898c6503 --- /dev/null +++ b/openex-front/src/actions/assetgroups/assetgroup-helper.d.ts @@ -0,0 +1,6 @@ +import type { AssetGroup } from '../../utils/api-types'; + +export interface AssetGroupsHelper { + getAssetGroups: () => [AssetGroup]; + getAssetGroup: (assetGroupId: string) => AssetGroup; +} diff --git a/openex-front/src/actions/assetgroups/assetgroup-schema.ts b/openex-front/src/actions/assetgroups/assetgroup-schema.ts new file mode 100644 index 0000000000..4fee5e9af9 --- /dev/null +++ b/openex-front/src/actions/assetgroups/assetgroup-schema.ts @@ -0,0 +1,8 @@ +import { schema } from 'normalizr'; + +export const assetGroup = new schema.Entity( + 'asset_groups', + {}, + { idAttribute: 'asset_group_id' }, +); +export const arrayOfAssetGroups = new schema.Array(assetGroup); diff --git a/openex-front/src/actions/assets/asset-helper.d.ts b/openex-front/src/actions/assets/asset-helper.d.ts new file mode 100644 index 0000000000..682c59656f --- /dev/null +++ b/openex-front/src/actions/assets/asset-helper.d.ts @@ -0,0 +1,6 @@ +import type { EndpointStore } from '../../admin/components/assets/endpoints/Endpoint'; + +export interface EndpointsHelper { + getEndpoints: () => [EndpointStore]; + getEndpointsMap: () => Record; +} diff --git a/openex-front/src/actions/assets/asset-schema.ts b/openex-front/src/actions/assets/asset-schema.ts new file mode 100644 index 0000000000..65d7fc1194 --- /dev/null +++ b/openex-front/src/actions/assets/asset-schema.ts @@ -0,0 +1,10 @@ +import { schema } from 'normalizr'; + +// Endpoint + +export const endpoint = new schema.Entity( + 'endpoints', + {}, + { idAttribute: 'asset_id' }, +); +export const arrayOfEndpoints = new schema.Array(endpoint); diff --git a/openex-front/src/actions/assets/endpoint-actions.ts b/openex-front/src/actions/assets/endpoint-actions.ts new file mode 100644 index 0000000000..0536ebf5ec --- /dev/null +++ b/openex-front/src/actions/assets/endpoint-actions.ts @@ -0,0 +1,27 @@ +import { Dispatch } from 'redux'; +import { delReferential, getReferential, postReferential, putReferential } from '../../utils/Action'; +import type { Endpoint, EndpointInput } from '../../utils/api-types'; +import { arrayOfEndpoints, endpoint } from './asset-schema'; + +const ENDPOINT_URI = '/api/endpoints'; + +export const addEndpoint = (data: EndpointInput) => (dispatch: Dispatch) => { + return postReferential(endpoint, ENDPOINT_URI, data)(dispatch); +}; + +export const updateEndpoint = ( + assetId: Endpoint['asset_id'], + data: EndpointInput, +) => (dispatch: Dispatch) => { + const uri = `${ENDPOINT_URI}/${assetId}`; + return putReferential(endpoint, uri, data)(dispatch); +}; + +export const deleteEndpoint = (assetId: Endpoint['asset_id']) => (dispatch: Dispatch) => { + const uri = `${ENDPOINT_URI}/${assetId}`; + return delReferential(uri, endpoint.key, assetId)(dispatch); +}; + +export const fetchEndpoints = () => (dispatch: Dispatch) => { + return getReferential(arrayOfEndpoints, ENDPOINT_URI)(dispatch); +}; diff --git a/openex-front/src/actions/helper.d.ts b/openex-front/src/actions/helper.d.ts index d3ec4ce37e..6abc939258 100644 --- a/openex-front/src/actions/helper.d.ts +++ b/openex-front/src/actions/helper.d.ts @@ -14,6 +14,7 @@ export interface OrganizationsHelper { } export interface TagsHelper { + getTags: () => Tag[]; getTagsMap: () => Record; } diff --git a/openex-front/src/actions/lessons/lesson.d.ts b/openex-front/src/actions/lessons/lesson-helper.d.ts similarity index 100% rename from openex-front/src/actions/lessons/lesson.d.ts rename to openex-front/src/actions/lessons/lesson-helper.d.ts diff --git a/openex-front/src/admin/Index.tsx b/openex-front/src/admin/Index.tsx index 84e8f67423..9ecf0148ec 100644 --- a/openex-front/src/admin/Index.tsx +++ b/openex-front/src/admin/Index.tsx @@ -1,6 +1,6 @@ -import React, { Suspense, lazy } from 'react'; +import React, { lazy, Suspense, useEffect } from 'react'; import { Route, Routes, useNavigate } from 'react-router-dom'; -import { useTheme, makeStyles } from '@mui/styles'; +import { makeStyles, useTheme } from '@mui/styles'; import { Box } from '@mui/material'; import TopBar from './components/nav/TopBar'; import LeftBar from './components/nav/LeftBar'; @@ -11,11 +11,14 @@ import { useHelper } from '../store'; import type { Theme } from '../components/Theme'; import type { LoggedHelper } from '../actions/helper'; import Loader from '../components/Loader'; +import contractImages from '../actions/Contract'; +import { useAppDispatch } from '../utils/hooks'; const IndexExercise = lazy(() => import('./components/exercises/Index')); const Dashboard = lazy(() => import('./components/Dashboard')); const IndexProfile = lazy(() => import('./components/profile/Index')); const Exercises = lazy(() => import('./components/exercises/Exercises')); +const Assets = lazy(() => import('./components/assets/Index')); const Persons = lazy(() => import('./components/persons/Index')); const Organizations = lazy(() => import('./components/organizations/Organizations')); const Medias = lazy(() => import('./components/medias/Index')); @@ -48,6 +51,10 @@ const Index = () => { overflowX: 'hidden', }; useDataLoader(); + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch(contractImages()); + }, []); return ( <> { + diff --git a/openex-front/src/admin/components/assets/Index.tsx b/openex-front/src/admin/components/assets/Index.tsx new file mode 100644 index 0000000000..e0eea047aa --- /dev/null +++ b/openex-front/src/admin/components/assets/Index.tsx @@ -0,0 +1,31 @@ +import React, { Suspense, lazy } from 'react'; +import { makeStyles } from '@mui/styles'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { errorWrapper } from '../../../components/Error'; +import Loader from '../../../components/Loader'; + +const Endpoints = lazy(() => import('./endpoints/Endpoints')); +const AssetGroups = lazy(() => import('./assetgroups/AssetGroups')); + +const useStyles = makeStyles(() => ({ + root: { + flexGrow: 1, + }, +})); + +const Index = () => { + const classes = useStyles(); + return ( +
+ }> + + } /> + + + + +
+ ); +}; + +export default Index; diff --git a/openex-front/src/admin/components/assets/assetgroups/AssetGroup.d.ts b/openex-front/src/admin/components/assets/assetgroups/AssetGroup.d.ts new file mode 100644 index 0000000000..5deaa3873d --- /dev/null +++ b/openex-front/src/admin/components/assets/assetgroups/AssetGroup.d.ts @@ -0,0 +1,6 @@ +import type { AssetGroup } from '../../../../utils/api-types'; + +export type AssetGroupStore = Omit & { + asset_group_assets: string[] | undefined; + asset_group_tags: string[] | undefined; +}; diff --git a/openex-front/src/admin/components/assets/assetgroups/AssetGroupAddAssets.tsx b/openex-front/src/admin/components/assets/assetgroups/AssetGroupAddAssets.tsx new file mode 100644 index 0000000000..97b488032b --- /dev/null +++ b/openex-front/src/admin/components/assets/assetgroups/AssetGroupAddAssets.tsx @@ -0,0 +1,194 @@ +import React, { FunctionComponent, useState } from 'react'; +import { PersonOutlined } from '@mui/icons-material'; +import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Grid, List, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import * as R from 'ramda'; +import { useAppDispatch } from '../../../../utils/hooks'; +import { useHelper } from '../../../../store'; +import type { EndpointsHelper } from '../../../../actions/assets/asset-helper'; +import useDataLoader from '../../../../utils/ServerSideEvent'; +import { fetchEndpoints } from '../../../../actions/assets/endpoint-actions'; +import Transition from '../../../../components/common/Transition'; +import { useFormatter } from '../../../../components/i18n'; +import { updateAssetsOnAssetGroup } from '../../../../actions/assetgroups/assetgroup-action'; +import SearchFilter from '../../../../components/SearchFilter'; +import TagsFilter from '../../../../components/TagsFilter'; +import { truncate } from '../../../../utils/String'; +import ItemTags from '../../../../components/ItemTags'; +import { Option } from '../../../../utils/Option'; +import type { EndpointStore } from '../endpoints/Endpoint'; +import EndpointCreation from '../endpoints/EndpointCreation'; +import ButtonCreate from '../../../../components/common/ButtonCreate'; + +const useStyles = makeStyles(() => ({ + box: { + width: '100%', + minHeight: '100%', + padding: 20, + border: '1px dashed rgba(255, 255, 255, 0.3)', + }, + chip: { + margin: '0 10px 10px 0', + }, +})); + +interface Props { + assetGroupId: string; + assetGroupAssetIds: string[]; +} + +const AssetGroupAddAssets: FunctionComponent = ({ + assetGroupId, + assetGroupAssetIds, +}) => { + // Standard hooks + const classes = useStyles(); + const dispatch = useAppDispatch(); + const { t } = useFormatter(); + + // Fetching data + const { endpointsMap } = useHelper((helper: EndpointsHelper) => ({ + endpointsMap: helper.getEndpointsMap(), + })); + useDataLoader(() => { + dispatch(fetchEndpoints()); + }); + + const endpoints: [EndpointStore] = R.values(endpointsMap); + + const [endpointIds, setEndpointIds] = useState([]); + + const addEndpoint = (endpointId: string) => { + setEndpointIds([...endpointIds, endpointId]); + }; + const removeEndpoint = (endpointId: string) => { + setEndpointIds(endpointIds.filter((id) => id !== endpointId)); + }; + + // Filter + const [keyword, setKeyword] = useState(''); + const handleSearch = (value: string) => { + setKeyword(value); + }; + + const [tags, setTags] = useState([]); + const handleAddTag = (value: Option) => { + if (value) { + setTags(R.uniq(R.append(value, tags))); + } + }; + const handleRemoveTag = (value: string) => { + setTags(tags.filter((n) => n.id !== value)); + }; + + // Dialog + const [open, setOpen] = useState(false); + + const handleClose = () => { + setOpen(false); + setKeyword(''); + setEndpointIds([]); + }; + + const onSubmit = () => { + dispatch(updateAssetsOnAssetGroup(assetGroupId, { + asset_group_assets: R.uniq([...assetGroupAssetIds, ...endpointIds]), + })).then(() => handleClose()); + }; + + return ( + <> + setOpen(true)} /> + + {t('Add assets in this asset group')} + + + + + + + + + + + + + {endpoints.map((endpoint) => { + const disabled = endpointIds.includes(endpoint.asset_id) + || assetGroupAssetIds.includes(endpoint.asset_id); + return ( + addEndpoint(endpoint.asset_id)} + > + + + + + + + ); + })} + addEndpoint(result)} + /> + + + + + {endpointIds.map((endpointId) => { + const endpoint: EndpointStore = endpointsMap[endpointId]; + return ( + removeEndpoint(endpointId)} + label={truncate(endpoint.asset_name, 22)} + classes={{ root: classes.chip }} + /> + ); + })} + + + + + + + + + + + ); +}; + +export default AssetGroupAddAssets; diff --git a/openex-front/src/admin/components/assets/assetgroups/AssetGroupCreation.tsx b/openex-front/src/admin/components/assets/assetgroups/AssetGroupCreation.tsx new file mode 100644 index 0000000000..5f5c1a0470 --- /dev/null +++ b/openex-front/src/admin/components/assets/assetgroups/AssetGroupCreation.tsx @@ -0,0 +1,71 @@ +import React, { FunctionComponent, useState } from 'react'; +import { ListItemButton, ListItemIcon, ListItemText, Theme } from '@mui/material'; +import { ControlPointOutlined } from '@mui/icons-material'; +import { makeStyles } from '@mui/styles'; + +import { useFormatter } from '../../../../components/i18n'; +import { useAppDispatch } from '../../../../utils/hooks'; +import type { AssetGroupInput } from '../../../../utils/api-types'; +import Drawer from '../../../../components/common/Drawer'; +import { addAssetGroup } from '../../../../actions/assetgroups/assetgroup-action'; +import AssetGroupForm from './AssetGroupForm'; +import ButtonCreate from '../../../../components/common/ButtonCreate'; + +const useStyles = makeStyles((theme: Theme) => ({ + text: { + fontSize: theme.typography.h2.fontSize, + color: theme.palette.primary.main, + fontWeight: theme.typography.h2.fontWeight, + }, +})); + +interface Props { + inline?: boolean; +} + +const AssetGroupCreation: FunctionComponent = ({ inline }) => { + // Standard hooks + const classes = useStyles(); + const [open, setOpen] = useState(false); + const { t } = useFormatter(); + + const dispatch = useAppDispatch(); + const onSubmit = (data: AssetGroupInput) => { + dispatch(addAssetGroup(data)); + setOpen(false); + }; + + return ( +
+ {inline ? ( + setOpen(true)} + color="primary" + > + + + + + + ) : ( + setOpen(true)} /> + )} + setOpen(false)} + title={t('Create a new asset group')} + > + setOpen(false)} + /> + +
+ ); +}; + +export default AssetGroupCreation; diff --git a/openex-front/src/admin/components/assets/assetgroups/AssetGroupForm.tsx b/openex-front/src/admin/components/assets/assetgroups/AssetGroupForm.tsx new file mode 100644 index 0000000000..443283b8da --- /dev/null +++ b/openex-front/src/admin/components/assets/assetgroups/AssetGroupForm.tsx @@ -0,0 +1,106 @@ +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; +import { Button, TextField } from '@mui/material'; +import React from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useFormatter } from '../../../../components/i18n'; +import type { AssetGroupInput } from '../../../../utils/api-types'; +import { zodImplement } from '../../../../utils/Zod'; +import TagField from '../../../../components/field/TagField'; + +interface Props { + onSubmit: SubmitHandler; + handleClose: () => void; + editing?: boolean; + initialValues?: AssetGroupInput; +} + +const AssetGroupForm: React.FC = ({ + onSubmit, + handleClose, + editing, + initialValues = { + asset_group_name: '', + asset_group_description: '', + asset_group_tags: [], + }, +}) => { + // Standard hooks + const { t } = useFormatter(); + + const { + register, + control, + handleSubmit, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + mode: 'onTouched', + resolver: zodResolver( + zodImplement().with({ + asset_group_name: z.string().min(1, { message: t('Should not be empty') }), + asset_group_description: z.string().optional(), + asset_group_tags: z.string().array().optional(), + }), + ), + defaultValues: initialValues, + }); + + return ( +
+ + + + ( + + )} + /> + +
+ + +
+ + ); +}; + +export default AssetGroupForm; diff --git a/openex-front/src/admin/components/assets/assetgroups/AssetGroupManagement.tsx b/openex-front/src/admin/components/assets/assetgroups/AssetGroupManagement.tsx new file mode 100644 index 0000000000..14554825e4 --- /dev/null +++ b/openex-front/src/admin/components/assets/assetgroups/AssetGroupManagement.tsx @@ -0,0 +1,266 @@ +import { IconButton, List, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText, Typography } from '@mui/material'; +import { CloseRounded, ComputerOutlined } from '@mui/icons-material'; +import * as R from 'ramda'; +import React, { CSSProperties, FunctionComponent, useState } from 'react'; +import { makeStyles } from '@mui/styles'; +import type { Theme } from '../../../../components/Theme'; +import TagsFilter from '../../../../components/TagsFilter'; +import SearchFilter from '../../../../components/SearchFilter'; +import { Option } from '../../../../utils/Option'; +import ItemTags from '../../../../components/ItemTags'; +import SortHeadersList, { Header } from '../../../../components/common/SortHeadersList'; +import AssetGroupAddAssets from './AssetGroupAddAssets'; +import { useHelper } from '../../../../store'; +import type { UsersHelper } from '../../../../actions/helper'; +import useDataLoader from '../../../../utils/ServerSideEvent'; +import { fetchEndpoints } from '../../../../actions/assets/endpoint-actions'; +import { useAppDispatch } from '../../../../utils/hooks'; +import type { EndpointsHelper } from '../../../../actions/assets/asset-helper'; +import type { EndpointStore } from '../endpoints/Endpoint'; +import EndpointPopover from '../endpoints/EndpointPopover'; +import { fetchAssetGroup } from '../../../../actions/assetgroups/assetgroup-action'; +import type { AssetGroupsHelper } from '../../../../actions/assetgroups/assetgroup-helper'; + +const useStyles = makeStyles((theme: Theme) => ({ + // Drawer Header + header: { + backgroundColor: theme.palette.background.nav, + padding: '20px 20px 20px 60px', + }, + closeButton: { + position: 'absolute', + top: 12, + left: 5, + color: 'inherit', + }, + title: { + float: 'left', + }, + parameters: { + float: 'right', + marginTop: -8, + }, + tags: { + float: 'right', + }, + search: { + float: 'right', + width: 200, + marginRight: 20, + }, + + container: { + marginTop: 10, + }, + itemHead: { + textTransform: 'uppercase', + cursor: 'pointer', + }, + item: { + height: 50, + }, + bodyItem: { + fontSize: 13, + float: 'left', + height: 20, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +})); + +const inlineStylesHeaders: Record = { + iconSort: { + position: 'absolute', + margin: '0 0 0 5px', + padding: 0, + top: '0px', + }, + asset_name: { + float: 'left', + width: '50%', + fontSize: 12, + fontWeight: '700', + }, + asset_tags: { + float: 'left', + width: '50%', + fontSize: 12, + fontWeight: '700', + }, +}; + +const inlineStyles = { + asset_name: { + width: '50%', + }, + asset_tags: { + width: '50%', + }, +}; + +interface Props { + assetGroupId: string; + handleClose: () => void; +} + +const AssetGroupManagement: FunctionComponent = ({ + assetGroupId, + handleClose, +}) => { + // Standard hooks + const classes = useStyles(); + const dispatch = useAppDispatch(); + + // Fetching data + const { assetGroup, endpointsMap, userAdmin } = useHelper((helper: AssetGroupsHelper & EndpointsHelper & UsersHelper) => ({ + assetGroup: helper.getAssetGroup(assetGroupId), + endpointsMap: helper.getEndpointsMap(), + userAdmin: helper.getMe()?.user_admin ?? false, + })); + useDataLoader(() => { + dispatch(fetchAssetGroup(assetGroupId)); + dispatch(fetchEndpoints()); + }); + + // Filter + const [keyword, setKeyword] = useState(''); + const handleSearch = (value: string) => { + setKeyword(value); + }; + + const [tags, setTags] = useState([]); + const handleAddTag = (value: Option) => { + if (value) { + setTags(R.uniq(R.append(value, tags))); + } + }; + const handleRemoveTag = (value: string) => { + setTags(tags.filter((n) => n.id !== value)); + }; + + // Headers + + const headers: Header[] = [ + { field: 'asset_name', label: 'Name', isSortable: true }, + { field: 'asset_tags', label: 'Tags', isSortable: true }, + ]; + + return ( + <> +
+ + + + + {assetGroup.asset_group_name} + +
+
+ +
+
+ +
+
+
+
+ + + + +   + + + + +
+ } + /> +   + + {assetGroup.asset_group_assets?.map((assetId: string) => { + const endpoint: EndpointStore = endpointsMap[assetId]; + return ( + + + + + +
+ {endpoint.asset_name} +
+
+ +
+ + } + /> + + {userAdmin + ? () + :   + } + +
+ ); + })} + + {userAdmin + && () + } + + ); +}; + +export default AssetGroupManagement; diff --git a/openex-front/src/admin/components/assets/assetgroups/AssetGroupPopover.tsx b/openex-front/src/admin/components/assets/assetgroups/AssetGroupPopover.tsx new file mode 100644 index 0000000000..1489adf8aa --- /dev/null +++ b/openex-front/src/admin/components/assets/assetgroups/AssetGroupPopover.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import { Drawer as MuiDrawer, IconButton, Menu, MenuItem } from '@mui/material'; +import { MoreVert } from '@mui/icons-material'; + +import { makeStyles } from '@mui/styles'; +import { useFormatter } from '../../../../components/i18n'; +import type { AssetGroupInput } from '../../../../utils/api-types'; +import { useAppDispatch } from '../../../../utils/hooks'; +import Drawer from '../../../../components/common/Drawer'; +import DialogDelete from '../../../../components/common/DialogDelete'; +import type { AssetGroupStore } from './AssetGroup'; +import { deleteAssetGroup, updateAssetGroup } from '../../../../actions/assetgroups/assetgroup-action'; +import AssetGroupForm from './AssetGroupForm'; +import AssetGroupManagement from './AssetGroupManagement'; + +const useStyles = makeStyles(() => ({ + drawerPaper: { + minHeight: '100vh', + width: '50%', + padding: 0, + }, +})); + +interface Props { + assetGroup: AssetGroupStore; +} + +const AssetGroupPopover: React.FC = ({ + assetGroup, +}) => { + // Standard hooks + const classes = useStyles(); + const { t } = useFormatter(); + const dispatch = useAppDispatch(); + + const [anchorEl, setAnchorEl] = useState(null); + + const initialValues = (({ + asset_group_name, + asset_group_description, + asset_group_tags, + }) => ({ + asset_group_name, + asset_group_description: asset_group_description ?? '', + asset_group_tags: asset_group_tags ?? [], + }))(assetGroup); + + // Edition + const [edition, setEdition] = useState(false); + + const handleEdit = () => { + setEdition(true); + setAnchorEl(null); + }; + const submitEdit = (data: AssetGroupInput) => { + dispatch(updateAssetGroup(assetGroup.asset_group_id, data)); + setEdition(false); + }; + + // Manage assets + const [selected, setSelected] = useState(undefined); + + const handleManage = () => { + setSelected(assetGroup.asset_group_id); + setAnchorEl(null); + }; + + // Deletion + const [deletion, setDeletion] = useState(false); + + const handleDelete = () => { + setDeletion(true); + setAnchorEl(null); + }; + const submitDelete = () => { + dispatch(deleteAssetGroup(assetGroup.asset_group_id)); + setDeletion(false); + }; + + return ( +
+ { + ev.stopPropagation(); + setAnchorEl(ev.currentTarget); + }} + aria-haspopup="true" + size="large" + > + + + setAnchorEl(null)} + > + + {t('Update')} + + + {t('Manage assets')} + + + {t('Delete')} + + + + setDeletion(false)} + handleSubmit={submitDelete} + text={t('Do you want to delete the asset group ?')} + /> + setEdition(false)} + title={t('Update the asset group')} + > + setEdition(false)} + /> + + setSelected(undefined)} + elevation={1} + > + {selected !== undefined && ( + setSelected(undefined)} + /> + )} + +
+ ); +}; + +export default AssetGroupPopover; diff --git a/openex-front/src/admin/components/assets/assetgroups/AssetGroups.tsx b/openex-front/src/admin/components/assets/assetgroups/AssetGroups.tsx new file mode 100644 index 0000000000..d487c44329 --- /dev/null +++ b/openex-front/src/admin/components/assets/assetgroups/AssetGroups.tsx @@ -0,0 +1,276 @@ +import { makeStyles } from '@mui/styles'; +import React, { CSSProperties, useState } from 'react'; +import { CSVLink } from 'react-csv'; +import { Drawer as MuiDrawer, IconButton, List, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText, Tooltip } from '@mui/material'; +import { FileDownloadOutlined, LanOutlined } from '@mui/icons-material'; +import { useAppDispatch } from '../../../../utils/hooks'; +import { useFormatter } from '../../../../components/i18n'; +import useSearchAnFilter from '../../../../utils/SortingFiltering'; +import { useHelper } from '../../../../store'; +import type { TagsHelper, UsersHelper } from '../../../../actions/helper'; +import useDataLoader from '../../../../utils/ServerSideEvent'; +import type { AssetGroupsHelper } from '../../../../actions/assetgroups/assetgroup-helper'; +import type { AssetGroupStore } from './AssetGroup'; +import SearchFilter from '../../../../components/SearchFilter'; +import TagsFilter from '../../../../components/TagsFilter'; +import { exportData } from '../../../../utils/Environment'; +import ItemTags from '../../../../components/ItemTags'; +import AssetGroupPopover from './AssetGroupPopover'; +import AssetGroupCreation from './AssetGroupCreation'; +import { fetchAssetGroups } from '../../../../actions/assetgroups/assetgroup-action'; +import AssetGroupManagement from './AssetGroupManagement'; + +const useStyles = makeStyles(() => ({ + parameters: { + marginTop: -10, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + filters: { + display: 'flex', + gap: '10px', + }, + itemHead: { + paddingLeft: 10, + textTransform: 'uppercase', + cursor: 'pointer', + }, + item: { + paddingLeft: 10, + height: 50, + }, + bodyItem: { + height: 20, + fontSize: 13, + float: 'left', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + drawerPaper: { + minHeight: '100vh', + width: '50%', + padding: 0, + }, +})); + +const headerStyles: { + iconSort: CSSProperties; + asset_group_name: CSSProperties; + asset_group_description: CSSProperties; + asset_group_tags: CSSProperties; +} = { + iconSort: { + position: 'absolute', + margin: '0 0 0 5px', + padding: 0, + top: '0px', + }, + asset_group_name: { + float: 'left', + width: '25%', + fontSize: 12, + fontWeight: '700', + }, + asset_group_description: { + float: 'left', + width: '50%', + fontSize: 12, + fontWeight: '700', + }, + asset_group_tags: { + float: 'left', + width: '25%', + fontSize: 12, + fontWeight: '700', + }, +}; + +const inlineStyles: { + asset_group_name: CSSProperties; + asset_group_description: CSSProperties; + asset_group_tags: CSSProperties; +} = { + asset_group_name: { + width: '25%', + }, + asset_group_description: { + width: '50%', + }, + asset_group_tags: { + width: '25%', + }, +}; + +const AssetGroups = () => { + // Standard hooks + const classes = useStyles(); + const dispatch = useAppDispatch(); + const { t } = useFormatter(); + // Filter and sort hook + const searchColumns = [ + 'name', + ]; + const filtering = useSearchAnFilter('asset_group', 'name', searchColumns); + // Fetching data + const { assetGroups, userAdmin, tagsMap } = useHelper((helper: AssetGroupsHelper & UsersHelper & TagsHelper) => ({ + assetGroups: helper.getAssetGroups(), + userAdmin: helper.getMe()?.user_admin ?? false, + tagsMap: helper.getTagsMap(), + })); + useDataLoader(() => { + dispatch(fetchAssetGroups()); + }); + const sortedAssetGroups: [AssetGroupStore] = filtering.filterAndSort(assetGroups); + const [selected, setSelected] = useState(undefined); + return ( + <> +
+
+ + +
+
+ {sortedAssetGroups.length > 0 ? ( + + + + + + + + ) : ( + + + + )} +
+
+
+ + + + +   + + + + {filtering.buildHeader( + 'asset_group_name', + 'Name', + true, + headerStyles, + )} + {filtering.buildHeader( + 'asset_group_description', + 'Description', + true, + headerStyles, + )} + {filtering.buildHeader( + 'asset_group_tags', + 'Tags', + true, + headerStyles, + )} +
+ } + /> +   + + {sortedAssetGroups.map((assetGroup) => ( + setSelected(assetGroup)} + > + + + + +
+ {assetGroup.asset_group_name} +
+
+ {assetGroup.asset_group_description} +
+
+ +
+ + } + /> + + + +
+ ))} + + {userAdmin && } + setSelected(undefined)} + elevation={1} + > + {selected !== undefined && ( + setSelected(undefined)} + /> + )} + + + ); +}; + +export default AssetGroups; diff --git a/openex-front/src/admin/components/assets/endpoints/Endpoint.d.ts b/openex-front/src/admin/components/assets/endpoints/Endpoint.d.ts new file mode 100644 index 0000000000..9f6d92424a --- /dev/null +++ b/openex-front/src/admin/components/assets/endpoints/Endpoint.d.ts @@ -0,0 +1,5 @@ +import type { Endpoint } from '../../../../utils/api-types'; + +export type EndpointStore = Omit & { + asset_tags: string[] | undefined; +}; diff --git a/openex-front/src/admin/components/assets/endpoints/EndpointCreation.tsx b/openex-front/src/admin/components/assets/endpoints/EndpointCreation.tsx new file mode 100644 index 0000000000..cedc8e796a --- /dev/null +++ b/openex-front/src/admin/components/assets/endpoints/EndpointCreation.tsx @@ -0,0 +1,99 @@ +import React, { FunctionComponent, useState } from 'react'; +import { ListItemButton, ListItemIcon, ListItemText, Theme } from '@mui/material'; +import { ControlPointOutlined } from '@mui/icons-material'; +import { makeStyles } from '@mui/styles'; + +import { useFormatter } from '../../../../components/i18n'; +import { useAppDispatch } from '../../../../utils/hooks'; +import type { EndpointInput } from '../../../../utils/api-types'; +import EndpointForm from './EndpointForm'; +import { addEndpoint } from '../../../../actions/assets/endpoint-actions'; +import Drawer from '../../../../components/common/Drawer'; +import Dialog from '../../../../components/common/Dialog'; +import ButtonCreate from '../../../../components/common/ButtonCreate'; + +const useStyles = makeStyles((theme: Theme) => ({ + text: { + fontSize: theme.typography.h2.fontSize, + color: theme.palette.primary.main, + fontWeight: theme.typography.h2.fontWeight, + }, +})); + +interface Props { + inline?: boolean; + onCreate?: (result: string) => void; +} + +const EndpointCreation: FunctionComponent = ({ + inline, + onCreate, +}) => { + // Standard hooks + const classes = useStyles(); + const [open, setOpen] = useState(false); + const { t } = useFormatter(); + + const dispatch = useAppDispatch(); + const onSubmit = (data: EndpointInput) => { + dispatch(addEndpoint(data)).then( + (result: { result: string }) => { + if (result.result) { + if (onCreate) { + onCreate(result.result); + } + setOpen(false); + } + return result; + }, + ); + }; + + return ( +
+ {inline ? ( + setOpen(true)} + color="primary" + > + + + + + + ) : ( + setOpen(true)} /> + )} + + {inline ? ( + setOpen(false)} + title={t('Create a new endpoint')} + > + setOpen(false)} + /> + + ) : ( + setOpen(false)} + title={t('Create a new endpoint')} + > + setOpen(false)} + /> + + )} +
+ ); +}; + +export default EndpointCreation; diff --git a/openex-front/src/admin/components/assets/endpoints/EndpointForm.tsx b/openex-front/src/admin/components/assets/endpoints/EndpointForm.tsx new file mode 100644 index 0000000000..7ff9dd7568 --- /dev/null +++ b/openex-front/src/admin/components/assets/endpoints/EndpointForm.tsx @@ -0,0 +1,257 @@ +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; +import { Button, FormHelperText, MenuItem, TextField } from '@mui/material'; +import React, { FormEventHandler } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { DateTimePicker as MuiDateTimePicker } from '@mui/x-date-pickers'; +import { useFormatter } from '../../../../components/i18n'; +import type { EndpointInput } from '../../../../utils/api-types'; +import { zodImplement } from '../../../../utils/Zod'; +import TagField from '../../../../components/field/TagField'; + +interface Props { + onSubmit: SubmitHandler; + handleClose: () => void; + editing?: boolean; + initialValues?: EndpointInput; +} + +const EndpointForm: React.FC = ({ + onSubmit, + handleClose, + editing, + initialValues = { + asset_name: '', + asset_description: '', + asset_last_seen: undefined, + asset_tags: [], + endpoint_hostname: '', + endpoint_ips: [], + endpoint_mac_adresses: [], + endpoint_platform: undefined, + }, +}) => { + // Standard hooks + const { t } = useFormatter(); + + const { + register, + control, + handleSubmit, + setValue, + trigger, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + mode: 'onTouched', + resolver: zodResolver( + zodImplement().with({ + asset_name: z.string().min(1, { message: t('Should not be empty') }), + asset_description: z.string().optional(), + asset_last_seen: z.string().datetime().optional(), + asset_tags: z.string().array().optional(), + endpoint_hostname: z.string().optional(), + endpoint_ips: z.string().ip({ message: t('Invalid Ip Address') }).array().min(1), + endpoint_mac_adresses: z + .string() + .regex( + /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})|([0-9a-fA-F]{4}.[0-9a-fA-F]{4}.[0-9a-fA-F]{4})$/, + t('Invalid MAC address'), + ).array().optional(), + endpoint_platform: z.enum(['Linux', 'Windows', 'Darwin']), + }), + ), + defaultValues: initialValues, + }); + + return ( +
+ + + + ( + { + if (date instanceof Date) { + field.onChange(date?.toISOString()); + } + }} + ampm={false} + format="yyyy-MM-dd HH:mm:ss" + /> + )} + /> + + + + { + const value2 = value?.reduce((accumulator: string, current: string) => (accumulator === '' ? current : `${accumulator}\n${current}`), ''); + const onChange2: FormEventHandler = (event) => { + if (event.currentTarget.value === '') { + setValue('endpoint_ips', []); + trigger('endpoint_ips'); + } else { + onChange(event.currentTarget.value.split('\n')); + } + }; + return ( + <> + `${accumulator !== '' ? `${accumulator}, ` : ''}${index + 1} - ${current?.message}`, '')} + inputProps={{ + onChange: onChange2, + onBlur, + value: value2, + }} + InputLabelProps={{ required: true }} + /> + {t('Please provide one IP address per line.')} + + ); + }} + /> + + { + const value2 = value?.reduce((accumulator: string, current: string) => (accumulator === '' ? current : `${accumulator}\n${current}`), ''); + const onChange2: FormEventHandler = (event) => { + if (event.currentTarget.value === '') { + setValue('endpoint_mac_adresses', []); + trigger('endpoint_mac_adresses'); + } else { + onChange(event.currentTarget.value.split('\n')); + } + }; + return ( + <> + `${accumulator !== '' ? `${accumulator}, ` : ''}${index + 1} - ${current?.message}`, '')} + inputProps={{ + onChange: onChange2, + onBlur, + value: value2, + }} + /> + {t('Please provide one MAC address per line.')} + + ); + }} + /> + + ( + + {'Linux'} + {'Windows'} + {'Darwin'} + + )} + /> + + ( + + )} + /> + +
+ + +
+ + ); +}; + +export default EndpointForm; diff --git a/openex-front/src/admin/components/assets/endpoints/EndpointPopover.tsx b/openex-front/src/admin/components/assets/endpoints/EndpointPopover.tsx new file mode 100644 index 0000000000..310eed7b58 --- /dev/null +++ b/openex-front/src/admin/components/assets/endpoints/EndpointPopover.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { IconButton, Menu, MenuItem } from '@mui/material'; +import { MoreVert } from '@mui/icons-material'; + +import { useFormatter } from '../../../../components/i18n'; +import type { EndpointInput } from '../../../../utils/api-types'; +import EndpointForm from './EndpointForm'; +import { useAppDispatch } from '../../../../utils/hooks'; +import { deleteEndpoint, updateEndpoint } from '../../../../actions/assets/endpoint-actions'; +import Drawer from '../../../../components/common/Drawer'; +import DialogDelete from '../../../../components/common/DialogDelete'; +import type { EndpointStore } from './Endpoint'; +import { updateAssetsOnAssetGroup } from '../../../../actions/assetgroups/assetgroup-action'; + +interface Props { + endpoint: EndpointStore; + assetGroupId?: string; + assetGroupAssetIds?: string[]; +} + +const EndpointPopover: React.FC = ({ + endpoint, + assetGroupId, + assetGroupAssetIds, +}) => { + // Standard hooks + const { t } = useFormatter(); + const dispatch = useAppDispatch(); + + const [anchorEl, setAnchorEl] = useState(null); + + const initialValues = (({ + asset_name, + asset_description, + asset_last_seen, + asset_tags, + endpoint_hostname, + endpoint_ips, + endpoint_mac_adresses, + endpoint_platform, + }) => ({ + asset_name, + asset_description, + asset_last_seen: asset_last_seen ?? undefined, + asset_tags, + endpoint_hostname, + endpoint_ips, + endpoint_mac_adresses: endpoint_mac_adresses ?? [], + endpoint_platform, + }))(endpoint); + + // Edition + const [edition, setEdition] = useState(false); + + const handleEdit = () => { + setEdition(true); + setAnchorEl(null); + }; + const submitEdit = (data: EndpointInput) => { + dispatch(updateEndpoint(endpoint.asset_id, data)); + setEdition(false); + }; + + // Removal + const [removal, setRemoval] = useState(false); + + const handleRemove = () => { + setRemoval(true); + setAnchorEl(null); + }; + const submitRemove = () => { + if (assetGroupId) { + dispatch( + updateAssetsOnAssetGroup(assetGroupId, { + asset_group_assets: assetGroupAssetIds?.filter((id) => id !== endpoint.asset_id), + }), + ).then(() => setRemoval(false)); + } + }; + + // Deletion + const [deletion, setDeletion] = useState(false); + + const handleDelete = () => { + setDeletion(true); + setAnchorEl(null); + }; + const submitDelete = () => { + dispatch(deleteEndpoint(endpoint.asset_id)); + setDeletion(false); + }; + + return ( +
+ { + ev.stopPropagation(); + setAnchorEl(ev.currentTarget); + }} + aria-haspopup="true" + size="large" + > + + + setAnchorEl(null)} + > + + {t('Update')} + + {assetGroupId && ( + + {t('Remove from the asset group')} + + )} + + {t('Delete')} + + + + setEdition(false)} + title={t('Update the endpoint')} + > + setEdition(false)} + /> + + setRemoval(false)} + handleSubmit={submitRemove} + text={t('Do you want to remove the endpoint from the asset group ?')} + /> + setDeletion(false)} + handleSubmit={submitDelete} + text={t('Do you want to delete the endpoint ?')} + /> +
+ ); +}; + +export default EndpointPopover; diff --git a/openex-front/src/admin/components/assets/endpoints/Endpoints.tsx b/openex-front/src/admin/components/assets/endpoints/Endpoints.tsx new file mode 100644 index 0000000000..fd98ec706d --- /dev/null +++ b/openex-front/src/admin/components/assets/endpoints/Endpoints.tsx @@ -0,0 +1,305 @@ +import React, { CSSProperties } from 'react'; +import { makeStyles } from '@mui/styles'; +import { IconButton, List, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText, Tooltip } from '@mui/material'; +import { ComputerOutlined, FileDownloadOutlined } from '@mui/icons-material'; +import { CSVLink } from 'react-csv'; +import EndpointCreation from './EndpointCreation'; +import EndpointPopover from './EndpointPopover'; +import SearchFilter from '../../../../components/SearchFilter'; +import useDataLoader from '../../../../utils/ServerSideEvent'; +import { useHelper } from '../../../../store'; +import useSearchAnFilter from '../../../../utils/SortingFiltering'; +import { useFormatter } from '../../../../components/i18n'; +import { exportData } from '../../../../utils/Environment'; +import { useAppDispatch } from '../../../../utils/hooks'; +import type { TagsHelper, UsersHelper } from '../../../../actions/helper'; +import type { EndpointsHelper } from '../../../../actions/assets/asset-helper'; +import { fetchEndpoints } from '../../../../actions/assets/endpoint-actions'; +import TagsFilter from '../../../../components/TagsFilter'; +import type { EndpointStore } from './Endpoint'; +import ItemTags from '../../../../components/ItemTags'; + +const useStyles = makeStyles(() => ({ + parameters: { + marginTop: -10, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + filters: { + display: 'flex', + gap: '10px', + }, + itemHead: { + paddingLeft: 10, + textTransform: 'uppercase', + cursor: 'pointer', + }, + item: { + paddingLeft: 10, + height: 50, + }, + bodyItem: { + height: 20, + fontSize: 13, + float: 'left', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +})); + +const headerStyles: { + iconSort: CSSProperties; + asset_name: CSSProperties; + endpoint_hostname: CSSProperties; + endpoint_platform: CSSProperties; + endpoint_collected_by: CSSProperties; + asset_tags: CSSProperties; +} = { + iconSort: { + position: 'absolute', + margin: '0 0 0 5px', + padding: 0, + top: '0px', + }, + asset_name: { + float: 'left', + width: '25%', + fontSize: 12, + fontWeight: '700', + }, + endpoint_hostname: { + float: 'left', + width: '20%', + fontSize: 12, + fontWeight: '700', + }, + endpoint_platform: { + float: 'left', + width: '15%', + fontSize: 12, + fontWeight: '700', + }, + endpoint_collected_by: { + float: 'left', + width: '20%', + fontSize: 12, + fontWeight: '700', + }, + asset_tags: { + float: 'left', + width: '20%', + fontSize: 12, + fontWeight: '700', + }, +}; + +const inlineStyles: { + asset_name: CSSProperties; + endpoint_hostname: CSSProperties; + endpoint_platform: CSSProperties; + endpoint_collected_by: CSSProperties; + asset_tags: CSSProperties; +} = { + asset_name: { + width: '25%', + }, + endpoint_hostname: { + width: '20%', + }, + endpoint_platform: { + width: '15%', + }, + endpoint_collected_by: { + width: '20%', + }, + asset_tags: { + width: '20%', + }, +}; + +const collectedBy = (endpoint: EndpointStore) => { + return Object.keys(endpoint.asset_sources ?? {}).join(', '); +}; + +const Endpoints = () => { + // Standard hooks + const classes = useStyles(); + const dispatch = useAppDispatch(); + const { t } = useFormatter(); + // Filter and sort hook + const searchColumns = [ + 'name', + ]; + const filtering = useSearchAnFilter('asset', 'name', searchColumns); + // Fetching data + const { endpoints, userAdmin, tagsMap } = useHelper((helper: EndpointsHelper & UsersHelper & TagsHelper) => ({ + endpoints: helper.getEndpoints(), + userAdmin: helper.getMe()?.user_admin ?? false, + tagsMap: helper.getTagsMap(), + })); + useDataLoader(() => { + dispatch(fetchEndpoints()); + }); + const sortedEndpoints: [EndpointStore] = filtering.filterAndSort(endpoints); + return ( + <> +
+
+ + +
+
+ {sortedEndpoints.length > 0 ? ( + + + + + + + + ) : ( + + + + )} +
+
+ + + + +   + + + + {filtering.buildHeader( + 'asset_name', + 'Name', + true, + headerStyles, + )} + {filtering.buildHeader( + 'endpoint_hostname', + 'Hostname', + true, + headerStyles, + )} + {filtering.buildHeader( + 'endpoint_platform', + 'Platform', + true, + headerStyles, + )} + {filtering.buildHeader( + 'endpoint_collected_by', + 'Collected by', + false, + headerStyles, + )} + {filtering.buildHeader( + 'asset_tags', + 'Tags', + true, + headerStyles, + )} + + } + /> +   + + {sortedEndpoints.map((endpoint) => ( + + + + + +
+ {endpoint.asset_name} +
+
+ {endpoint.endpoint_hostname} +
+
+ {endpoint.endpoint_platform} +
+
+ {collectedBy(endpoint)} +
+
+ +
+ + } + /> + + + +
+ ))} +
+ {userAdmin && } + + ); +}; + +export default Endpoints; diff --git a/openex-front/src/admin/components/exercises/injects/InjectDefinition.js b/openex-front/src/admin/components/exercises/injects/InjectDefinition.js index c0e18a7b4e..10dfe82650 100644 --- a/openex-front/src/admin/components/exercises/injects/InjectDefinition.js +++ b/openex-front/src/admin/components/exercises/injects/InjectDefinition.js @@ -3,19 +3,19 @@ import * as PropTypes from 'prop-types'; import * as R from 'ramda'; import withStyles from '@mui/styles/withStyles'; import { + Button, + FormControlLabel, + FormGroup, + IconButton, + InputLabel, List, ListItem, - MenuItem, ListItemIcon, - ListItemText, - InputLabel, ListItemSecondaryAction, - IconButton, - Typography, - FormGroup, - FormControlLabel, + ListItemText, + MenuItem, Switch, - Button, + Typography, } from '@mui/material'; import { Form } from 'react-final-form'; import { connect } from 'react-redux'; @@ -23,11 +23,11 @@ import { ArrowDropDownOutlined, ArrowDropUpOutlined, AttachmentOutlined, - GroupsOutlined, CloseRounded, ControlPointOutlined, DeleteOutlined, EmojiEventsOutlined, + GroupsOutlined, HelpOutlineOutlined, } from '@mui/icons-material'; import arrayMutators from 'final-form-arrays'; @@ -35,7 +35,7 @@ import { FieldArray } from 'react-final-form-arrays'; import inject18n from '../../../../components/i18n'; import { fetchInjectTeams, updateInject } from '../../../../actions/Inject'; import { fetchDocuments } from '../../../../actions/Document'; -import { fetchExerciseArticles, fetchChannels } from '../../../../actions/Channel'; +import { fetchChannels, fetchExerciseArticles } from '../../../../actions/Channel'; import { fetchChallenges } from '../../../../actions/Challenge'; import ItemTags from '../../../../components/ItemTags'; import { storeHelper } from '../../../../actions/Schema'; @@ -680,6 +680,18 @@ class InjectDefinition extends Component { if (field.mandatory && (value === undefined || R.isEmpty(value))) { errors[field.key] = t('This field is required.'); } + if (field.mandatoryGroups) { + const { mandatoryGroups } = field; + const conditionOk = mandatoryGroups?.some((mandatoryKey) => { + const v = values[mandatoryKey]; + return v !== undefined && !R.isEmpty(v); + }); + // If condition are not filled + if (!conditionOk) { + const labels = mandatoryGroups.map((key) => injectType.fields.find((f) => f.key === key).label).join(', '); + errors[field.key] = t(`One of this field is required : ${labels}.`); + } + } }); } return errors; diff --git a/openex-front/src/admin/components/exercises/injects/InjectForm.js b/openex-front/src/admin/components/exercises/injects/InjectForm.js index a9a7271294..f306686ee2 100644 --- a/openex-front/src/admin/components/exercises/injects/InjectForm.js +++ b/openex-front/src/admin/components/exercises/injects/InjectForm.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import * as PropTypes from 'prop-types'; import * as R from 'ramda'; import { Form } from 'react-final-form'; -import { Button, Box } from '@mui/material'; +import { Box, Button } from '@mui/material'; import withStyles from '@mui/styles/withStyles'; import TextField from '../../../../components/TextField'; import inject18n from '../../../../components/i18n'; @@ -127,7 +127,7 @@ class InjectForm extends Component { renderOption={(renderProps, option) => (
- +
{t(option.label)}
diff --git a/openex-front/src/admin/components/exercises/injects/InjectIcon.js b/openex-front/src/admin/components/exercises/injects/InjectIcon.js index 4f8c006aa6..f4829b616e 100644 --- a/openex-front/src/admin/components/exercises/injects/InjectIcon.js +++ b/openex-front/src/admin/components/exercises/injects/InjectIcon.js @@ -1,12 +1,11 @@ -import React, { Component } from 'react'; +import React from 'react'; import * as PropTypes from 'prop-types'; -import { EmailOutlined, SmsOutlined, NotificationsActiveOutlined, HelpOutlined, SpeakerNotesOutlined, ApiOutlined, EmojiEventsOutlined } from '@mui/icons-material'; +import { ApiOutlined, EmailOutlined, EmojiEventsOutlined, HelpOutlined, NotificationsActiveOutlined, SmsOutlined, SpeakerNotesOutlined } from '@mui/icons-material'; import { Mastodon, NewspaperVariantMultipleOutline, Twitter } from 'mdi-material-ui'; -import Airbus from '../../../../static/images/contracts/airbus.png'; import CustomTooltip from '../../../../components/CustomTooltip'; -import { fileUri } from '../../../../utils/Environment'; +import { useHelper } from '../../../../store'; -const iconSelector = (type, variant, fontSize, done, disabled) => { +const iconSelector = (type, variant, fontSize, done, disabled, contractImage) => { let style; switch (variant) { case 'inline': @@ -64,7 +63,7 @@ const iconSelector = (type, variant, fontSize, done, disabled) => { case 'openex_lade': return ( Airbus Lade { sx={{ color: done ? '#4caf50' : '#e91e63' }} /> ); + case 'openex_caldera': + return ( + Caldera + ); default: return ; } }; -class InjectIcon extends Component { - render() { - const { type, size, variant, tooltip, done, disabled } = this.props; - const fontSize = size || 'medium'; - if (tooltip) { - return ( - - {iconSelector(type, variant, fontSize, done, disabled)} - - ); - } - return iconSelector(type, variant, fontSize, done, disabled); +const InjectIcon = (props) => { + const { type, size, variant, tooltip, done, disabled } = props; + + const { + contractImages, + } = useHelper((helper) => { + return { + contractImages: helper.getContractImages(), + }; + }); + const contractImage = `data:image/png;charset=utf-8;base64, ${contractImages[type]}`; + const fontSize = size || 'medium'; + if (tooltip) { + return ( + + {iconSelector(type, variant, fontSize, done, disabled, contractImage)} + + ); } -} + return iconSelector(type, variant, fontSize, done, disabled, contractImage); +}; InjectIcon.propTypes = { type: PropTypes.string, diff --git a/openex-front/src/admin/components/lessons/Index.tsx b/openex-front/src/admin/components/lessons/Index.tsx index 9ca1217441..96adb70e34 100644 --- a/openex-front/src/admin/components/lessons/Index.tsx +++ b/openex-front/src/admin/components/lessons/Index.tsx @@ -10,7 +10,7 @@ import useDataLoader from '../../../utils/ServerSideEvent'; import { useHelper } from '../../../store'; import { fetchLessonsTemplates } from '../../../actions/Lessons'; import { useAppDispatch } from '../../../utils/hooks'; -import type { LessonsTemplatesHelper } from '../../../actions/lessons/lesson'; +import type { LessonsTemplatesHelper } from '../../../actions/lessons/lesson-helper'; const useStyles = makeStyles(() => ({ root: { diff --git a/openex-front/src/admin/components/lessons/LessonsTemplate.tsx b/openex-front/src/admin/components/lessons/LessonsTemplate.tsx index 4355209650..8930452804 100644 --- a/openex-front/src/admin/components/lessons/LessonsTemplate.tsx +++ b/openex-front/src/admin/components/lessons/LessonsTemplate.tsx @@ -12,7 +12,7 @@ import LessonsTemplateCategoryPopover from './categories/LessonsTemplateCategory import CreateLessonsTemplateQuestion from './categories/questions/CreateLessonsTemplateQuestion'; import LessonsTemplateQuestionPopover from './categories/questions/LessonsTemplateQuestionPopover'; import { useAppDispatch } from '../../../utils/hooks'; -import type { LessonsTemplatesHelper } from '../../../actions/lessons/lesson'; +import type { LessonsTemplatesHelper } from '../../../actions/lessons/lesson-helper'; import type { UsersHelper } from '../../../actions/helper'; import type { LessonsTemplateCategory, LessonsTemplateQuestion } from '../../../utils/api-types'; diff --git a/openex-front/src/admin/components/nav/TopBar.tsx b/openex-front/src/admin/components/nav/TopBar.tsx index 4424d9a302..252a6d3032 100644 --- a/openex-front/src/admin/components/nav/TopBar.tsx +++ b/openex-front/src/admin/components/nav/TopBar.tsx @@ -10,6 +10,7 @@ import TopMenuSettings from './TopMenuSettings'; import TopMenuExercises from './TopMenuExercises'; import TopMenuExercise from './TopMenuExercise'; import TopMenuPersons from './TopMenuPersons'; +import TopMenuAssets from './TopMenuAssets'; import TopMenuOrganizations from './TopMenuOrganizations'; import TopMenuDocuments from './TopMenuDocuments'; import TopMenuMedias from './TopMenuMedias'; @@ -116,6 +117,7 @@ const TopBar: React.FC = () => { {location.pathname.includes('/admin/exercises/') && ( )} + {location.pathname.includes('/admin/assets') && } {location.pathname.includes('/admin/persons') && } {location.pathname.includes('/admin/organizations') && ( diff --git a/openex-front/src/admin/components/nav/TopMenuAssets.tsx b/openex-front/src/admin/components/nav/TopMenuAssets.tsx new file mode 100644 index 0000000000..7f61951738 --- /dev/null +++ b/openex-front/src/admin/components/nav/TopMenuAssets.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { Button } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import { useFormatter } from '../../../components/i18n'; +import type { Theme } from '../../../components/Theme'; + +const useStyles = makeStyles((theme) => ({ + button: { + marginRight: theme.spacing(2), + padding: '0 5px 0 5px', + minHeight: 20, + minWidth: 20, + textTransform: 'none', + }, +})); + +const TopMenuAssets: React.FC = () => { + const location = useLocation(); + const classes = useStyles(); + const { t } = useFormatter(); + return ( + <> + + + + ); +}; + +export default TopMenuAssets; diff --git a/openex-front/src/components/TagsFilter.js b/openex-front/src/components/TagsFilter.js index 6b9eac965f..475dce2082 100644 --- a/openex-front/src/components/TagsFilter.js +++ b/openex-front/src/components/TagsFilter.js @@ -45,12 +45,11 @@ const TagsFilter = (props) => { return (
{ diff --git a/openex-front/src/components/common/ButtonCreate.tsx b/openex-front/src/components/common/ButtonCreate.tsx new file mode 100644 index 0000000000..0ee0ca3b40 --- /dev/null +++ b/openex-front/src/components/common/ButtonCreate.tsx @@ -0,0 +1,36 @@ +import React, { FunctionComponent } from 'react'; +import { Add } from '@mui/icons-material'; +import { Fab } from '@mui/material'; +import { makeStyles } from '@mui/styles'; + +const useStyles = makeStyles(() => ({ + createButton: { + position: 'fixed', + bottom: 30, + right: 30, + }, +})); + +interface Props { + onClick: () => void +} + +const ButtonCreate: FunctionComponent = ({ + onClick, +}) => { + // Standard hooks + const classes = useStyles(); + + return ( + + + + ); +}; + +export default ButtonCreate; diff --git a/openex-front/src/components/common/DialogDelete.tsx b/openex-front/src/components/common/DialogDelete.tsx new file mode 100644 index 0000000000..15bf1c1809 --- /dev/null +++ b/openex-front/src/components/common/DialogDelete.tsx @@ -0,0 +1,43 @@ +import React, { FunctionComponent } from 'react'; +import { Button, Dialog as DialogMUI, DialogActions, DialogContent, DialogContentText } from '@mui/material'; +import Transition from './Transition'; +import { useFormatter } from '../i18n'; + +interface DialogDeleteProps { + open: boolean; + handleClose: () => void; + handleSubmit: () => void; + text: string; +} + +const DialogDelete: FunctionComponent = ({ + open = false, + handleClose, + handleSubmit, + text, +}) => { + const { t } = useFormatter(); + + return ( + + + + {text} + + + + + + + + ); +}; + +export default DialogDelete; diff --git a/openex-front/src/components/common/SortHeadersList.tsx b/openex-front/src/components/common/SortHeadersList.tsx new file mode 100644 index 0000000000..07d153be17 --- /dev/null +++ b/openex-front/src/components/common/SortHeadersList.tsx @@ -0,0 +1,75 @@ +import React, { CSSProperties, FunctionComponent, useState } from 'react'; +import { ArrowDropDownOutlined, ArrowDropUpOutlined } from '@mui/icons-material'; +import { makeStyles } from '@mui/styles'; +import { useFormatter } from '../i18n'; + +const useStyles = makeStyles(() => ({ + iconSort: { + position: 'absolute', + margin: '0 0 0 5px', + padding: 0, + top: '0px', + }, +})); + +export interface Header { + field: string + label: string + isSortable: boolean +} + +interface Props { + headers: Header[] + inlineStylesHeaders: Record + initialSortBy: string +} + +const SortHeadersList: FunctionComponent = ({ + headers, + inlineStylesHeaders, + initialSortBy, +}) => { + // Standard hooks + const { t } = useFormatter(); + const classes = useStyles(); + + const [sortBy, setSortBy] = useState(initialSortBy); + const [sortAsc, setSortAsc] = useState(true); + + const reverseBy = (field: string) => { + setSortBy(field); + setSortAsc(!sortAsc); + }; + + const sortComponent = (asc: boolean) => { + return asc ? ( + + ) : ( + + ); + }; + + const sortHeader = (header: Header, style: CSSProperties) => { + if (header.isSortable) { + return ( +
reverseBy(header.field)}> + {t(header.label)} + {sortBy === header.field ? sortComponent(sortAsc) : ''} +
+ ); + } + return ( +
+ {t(header.label)} +
+ ); + }; + + return ( + <> + {headers.map((header: Header) => (sortHeader(header, inlineStylesHeaders[header.field])))} + + ); +}; + +export default SortHeadersList; diff --git a/openex-front/src/components/field/TagField.tsx b/openex-front/src/components/field/TagField.tsx new file mode 100644 index 0000000000..44a4909fde --- /dev/null +++ b/openex-front/src/components/field/TagField.tsx @@ -0,0 +1,160 @@ +import React, { CSSProperties, FunctionComponent, useState } from 'react'; +import * as R from 'ramda'; +import { AddOutlined, LabelOutlined } from '@mui/icons-material'; +import { Autocomplete as MuiAutocomplete, Box, Dialog, DialogContent, DialogTitle, IconButton, TextField } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import { FieldErrors } from 'react-hook-form'; +import { useAppDispatch } from '../../utils/hooks'; +import useDataLoader from '../../utils/ServerSideEvent'; +import type { Tag } from '../../utils/api-types'; +import { useHelper } from '../../store'; +import type { TagsHelper, UsersHelper } from '../../actions/helper'; +import { useFormatter } from '../i18n'; +import { addTag, fetchTags } from '../../actions/Tag'; +import TagForm from '../../admin/components/settings/tags/TagForm'; + +const useStyles = makeStyles(() => ({ + icon: { + paddingTop: 4, + display: 'inline-block', + }, + text: { + display: 'inline-block', + flexGrow: 1, + marginLeft: 10, + }, + autoCompleteIndicator: { + display: 'none', + }, +})); + +interface Props { + name: string; + label: string; + fieldValue: string[]; + fieldOnChange: (values: string[]) => void; + errors: FieldErrors; + style: CSSProperties; +} + +const TagField: FunctionComponent = ({ + name, + label, + fieldValue, + fieldOnChange, + errors, + style, +}) => { + // Standard hooks + const { t } = useFormatter(); + const classes = useStyles(); + + // Fetching data + const { tags, userAdmin }: { tags: [Tag]; userAdmin: boolean } = useHelper((helper: TagsHelper & UsersHelper) => ({ + tags: helper.getTags(), + userAdmin: helper.getMe()?.user_admin ?? false, + })); + const dispatch = useAppDispatch(); + useDataLoader(() => { + dispatch(fetchTags()); + }); + + // Handle tag creation + const [tagCreation, setTagCreation] = useState(false); + const handleOpenTagCreation = () => setTagCreation(true); + const handleCloseTagCreation = () => setTagCreation(false); + + // Form + const tagsOptions = tags.map( + (n) => ({ + id: n.tag_id, + label: n.tag_name, + color: n.tag_color, + }), + ); + const values = () => { + return tagsOptions.filter((tag) => fieldValue.includes(tag.id)); + }; + + const onSubmit = (data: Tag) => { + dispatch(addTag(data)) + .then((result: { entities: { tags: Record }, result: string }) => { + if (result.result) { + const newTag = result.entities.tags[result.result]; + const newTags = R.append( + { + id: newTag.tag_id, + label: newTag.tag_name, + color: newTag.tag_color, + }, + fieldValue, + ); + fieldOnChange(newTags); + handleCloseTagCreation(); + } + return result; + }); + }; + + return ( +
+ { + fieldOnChange(value.map((v) => v.id)); + }} + renderOption={(props, option) => ( + +
+ +
+
{option.label}
+
+ )} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderInput={(params) => ( + + )} + classes={{ clearIndicator: classes.autoCompleteIndicator }} + /> + handleOpenTagCreation()} + edge="end" + style={{ position: 'absolute', top: 30, right: 35 }} + > + + + {userAdmin && ( + + {t('Create a new tag')} + + + + + )} +
+ ); +}; + +export default TagField; diff --git a/openex-front/src/constants/ActionTypes.js b/openex-front/src/constants/ActionTypes.js index f36258ad7a..852d6f52b5 100644 --- a/openex-front/src/constants/ActionTypes.js +++ b/openex-front/src/constants/ActionTypes.js @@ -10,3 +10,7 @@ export const IDENTITY_LOGIN_SUCCESS = 'IDENTITY_LOGIN_SUCCESS'; export const IDENTITY_LOGIN_FAILED = 'IDENTITY_LOGIN_FAILED'; export const IDENTITY_LOGOUT_SUCCESS = 'IDENTITY_LOGOUT_SUCCESS'; // endregion + +// region CONTRACT +export const CONTRACT_IMAGES = 'CONTRACT_IMAGES'; +// endregion diff --git a/openex-front/src/reducers/App.js b/openex-front/src/reducers/App.js index 542bbae027..f1f0abd57a 100644 --- a/openex-front/src/reducers/App.js +++ b/openex-front/src/reducers/App.js @@ -30,6 +30,10 @@ const app = (state = Immutable({}), action = {}) => { return state.set('logged', null); } + case Constants.CONTRACT_IMAGES: { + return state.set('contractImages', action.payload.data); + } + default: { return state; } diff --git a/openex-front/src/reducers/Referential.js b/openex-front/src/reducers/Referential.js index 49e7f167a0..78bf94b911 100644 --- a/openex-front/src/reducers/Referential.js +++ b/openex-front/src/reducers/Referential.js @@ -42,6 +42,8 @@ export const entitiesInitializer = Immutable({ variables: Immutable({}), killchainphases: Immutable({}), attackpatterns: Immutable({}), + endpoints: Immutable({}), + asset_groups: Immutable({}), }), }); diff --git a/openex-front/src/root.tsx b/openex-front/src/root.tsx index a3f9ca26f4..8203411211 100644 --- a/openex-front/src/root.tsx +++ b/openex-front/src/root.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy, useEffect } from 'react'; +import React, { lazy, Suspense, useEffect } from 'react'; import * as R from 'ramda'; import { Navigate, Route, Routes } from 'react-router-dom'; import { StyledEngineProvider } from '@mui/material/styles'; diff --git a/openex-front/src/static/images/contracts/airbus.png b/openex-front/src/static/images/contracts/airbus.png deleted file mode 100644 index d4deb06585d22e40be9f90301e918ee26c6c3a46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9894 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 + asset_tags?: Tag[]; + asset_type?: string; + /** @format date-time */ + asset_updated_at?: string; + updateAttributes?: object; +} + +export interface AssetGroup { + asset_group_assets?: Asset[]; + /** @format date-time */ + asset_group_created_at?: string; + asset_group_description?: string; + asset_group_id: string; + asset_group_name: string; + asset_group_tags?: Tag[]; + /** @format date-time */ + asset_group_updated_at?: string; + updateAttributes?: object; +} + +export interface AssetGroupInput { + asset_group_description?: string; + asset_group_name: string; + asset_group_tags?: string[]; +} + +export interface AttackPattern { + /** @format date-time */ + attack_pattern_created_at?: string; + attack_pattern_description?: string; + attack_pattern_external_id: string; + attack_pattern_id: string; + attack_pattern_name: string; + attack_pattern_parent?: AttackPattern; + attack_pattern_permissions_required?: string[]; + attack_pattern_platforms?: string[]; + /** @format date-time */ + attack_pattern_updated_at?: string; + updateAttributes?: object; +} + +export interface AttackPatternCreateInput { + attack_pattern_description?: string; + attack_pattern_external_id: string; + attack_pattern_name: string; + attack_pattern_parent?: string; + attack_pattern_permissions_required?: string[]; + attack_pattern_platforms?: string[]; +} + export interface Challenge { challenge_category?: string; challenge_content?: string; @@ -281,6 +338,7 @@ export interface ContractElement { linkedFields?: LinkedFieldModel[]; linkedValues?: string[]; mandatory?: boolean; + mandatoryGroups?: string[]; type?: | "text" | "number" @@ -405,6 +463,42 @@ export interface DryrunCreateInput { dryrun_users?: string[]; } +export interface Endpoint { + /** @format date-time */ + asset_created_at?: string; + asset_description?: string; + asset_id: string; + asset_name: string; + asset_sources?: Record; + asset_tags?: Tag[]; + asset_type?: string; + /** @format date-time */ + asset_updated_at?: string; + endpoint_hostname?: string; + endpoint_ips: string[]; + /** @format date-time */ + asset_last_seen?: string; + endpoint_mac_adresses?: string[]; + endpoint_platform: "Linux" | "Windows" | "Darwin"; + updateAttributes?: object; +} + +export interface EndpointInput { + asset_description?: string; + asset_name: string; + asset_tags?: string[]; + endpoint_hostname?: string; + /** + * @maxItems 2147483647 + * @minItems 1 + */ + endpoint_ips: string[]; + /** @format date-time */ + asset_last_seen?: string; + endpoint_mac_adresses?: string[]; + endpoint_platform: "Linux" | "Windows" | "Darwin"; +} + export interface Evaluation { /** @format date-time */ evaluation_created_at?: string; @@ -424,7 +518,7 @@ export interface EvaluationInput { } export interface Execution { - execution_async_id?: string; + execution_async_ids?: string[]; execution_runtime?: boolean; /** @format date-time */ execution_start?: string; @@ -703,7 +797,7 @@ export interface InjectInput { } export interface InjectStatus { - status_async_id?: string; + status_async_ids?: string[]; /** @format date-time */ status_date?: string; /** @format int32 */ @@ -732,6 +826,26 @@ export interface InjectUpdateTriggerInput { inject_depends_duration?: number; } +export interface KillChainPhase { + /** @format date-time */ + phase_created_at?: string; + phase_id?: string; + phase_kill_chain_name?: string; + phase_name?: string; + /** @format int64 */ + phase_order?: number; + /** @format date-time */ + phase_updated_at?: string; + updateAttributes?: object; +} + +export interface KillChainPhaseCreateInput { + phase_kill_chain_name?: string; + phase_name: string; + /** @format int64 */ + phase_order?: number; +} + export interface LessonsAnswer { /** @format date-time */ lessons_answer_created_at?: string; @@ -1120,7 +1234,7 @@ export interface StatisticElement { export interface Tag { tag_color?: string; - tag_id?: string; + tag_id: string; tag_name?: string; tags_documents?: Document[]; updateAttributes?: object; @@ -1191,6 +1305,10 @@ export interface Token { updateAttributes?: object; } +export interface UpdateAssetsOnAssetGroupInput { + asset_group_assets?: string[]; +} + export interface UpdateMePasswordInput { user_current_password: string; user_plain_password: string; diff --git a/openex-front/yarn.lock b/openex-front/yarn.lock index 9f084abae0..3a8af086a3 100644 --- a/openex-front/yarn.lock +++ b/openex-front/yarn.lock @@ -3685,6 +3685,15 @@ __metadata: languageName: node linkType: hard +"@types/react-csv@npm:1.1.10": + version: 1.1.10 + resolution: "@types/react-csv@npm:1.1.10" + dependencies: + "@types/react": "*" + checksum: 0a19efd0bb559a288ce640abc491850c81e75ca5900c25044d93ad633d89d62e49c6963a1d5657e664e61edd526295a4a93641dd8429703cc4ebf9a0ab4d4157 + languageName: node + linkType: hard + "@types/react-dom@npm:18.2.18, @types/react-dom@npm:^18.0.0": version: 18.2.18 resolution: "@types/react-dom@npm:18.2.18" @@ -10696,6 +10705,7 @@ __metadata: "@testing-library/react": 14.1.2 "@types/node": 20.11.5 "@types/react": 18.2.48 + "@types/react-csv": 1.1.10 "@types/react-dom": 18.2.18 "@types/seamless-immutable": 7.1.19 "@typescript-eslint/eslint-plugin": 6.19.0 diff --git a/openex-injectors b/openex-injectors index 73d1978e8c..1a306172f9 160000 --- a/openex-injectors +++ b/openex-injectors @@ -1 +1 @@ -Subproject commit 73d1978e8c382d931c4e5d8debe96b96d0f9b063 +Subproject commit 1a306172f977210d451477a1b17bba859d9875a7 diff --git a/openex-model/pom.xml b/openex-model/pom.xml index eaddaed81c..71ba052acc 100644 --- a/openex-model/pom.xml +++ b/openex-model/pom.xml @@ -53,5 +53,10 @@ jakarta.validation jakarta.validation-api + + commons-validator + commons-validator + ${commons-validator.version} + diff --git a/openex-model/src/main/java/io/openex/annotation/Ipv4OrIpv6Constraint.java b/openex-model/src/main/java/io/openex/annotation/Ipv4OrIpv6Constraint.java new file mode 100644 index 0000000000..657a7bf710 --- /dev/null +++ b/openex-model/src/main/java/io/openex/annotation/Ipv4OrIpv6Constraint.java @@ -0,0 +1,24 @@ +package io.openex.annotation; + +import io.openex.validator.Ipv4OrIpv6Validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import jakarta.validation.ReportAsSingleViolation; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target(FIELD) +@Retention(RUNTIME) +@Constraint(validatedBy = Ipv4OrIpv6Validator.class) +@ReportAsSingleViolation +public @interface Ipv4OrIpv6Constraint { + String message() default "must be ipv4 or ipv6"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/openex-model/src/main/java/io/openex/database/model/Asset.java b/openex-model/src/main/java/io/openex/database/model/Asset.java new file mode 100644 index 0000000000..f18caae895 --- /dev/null +++ b/openex-model/src/main/java/io/openex/database/model/Asset.java @@ -0,0 +1,89 @@ +package io.openex.database.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.hypersistence.utils.hibernate.type.basic.PostgreSQLHStoreType; +import io.openex.database.audit.ModelBaseListener; +import io.openex.helper.MultiIdDeserializer; +import lombok.Data; +import lombok.Setter; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.UuidGenerator; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.time.Instant.now; +import static jakarta.persistence.DiscriminatorType.STRING; +import static lombok.AccessLevel.NONE; + +@Data +@Entity +@Table(name = "assets") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name="asset_type", discriminatorType = STRING) +@EntityListeners(ModelBaseListener.class) +public class Asset implements Base { + + @Id + @Column(name = "asset_id") + @GeneratedValue(generator = "UUID") + @UuidGenerator + @JsonProperty("asset_id") + @NotBlank + private String id; + + @Column(name = "asset_type", insertable = false, updatable = false) + @JsonProperty("asset_type") + @Setter(NONE) + private String type; + + @Column(name = "asset_sources") + @JsonProperty("asset_sources") + @Type(PostgreSQLHStoreType.class) + private Map sources = new HashMap<>(); + + @Column(name = "asset_blobs") + @JsonProperty("asset_blobs") + @Type(PostgreSQLHStoreType.class) + private Map blobs = new HashMap<>(); + + @NotBlank + @Column(name = "asset_name") + @JsonProperty("asset_name") + private String name; + + @Column(name = "asset_description") + @JsonProperty("asset_description") + private String description; + + @Column(name = "asset_last_seen") + @JsonProperty("asset_last_seen") + private Instant lastSeen; + + // -- TAG -- + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "assets_tags", + joinColumns = @JoinColumn(name = "asset_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id")) + @JsonSerialize(using = MultiIdDeserializer.class) + @JsonProperty("asset_tags") + private List tags = new ArrayList<>(); + + // -- AUDIT -- + + @Column(name = "asset_created_at") + @JsonProperty("asset_created_at") + private Instant createdAt = now(); + + @Column(name = "asset_updated_at") + @JsonProperty("asset_updated_at") + private Instant updatedAt = now(); +} diff --git a/openex-model/src/main/java/io/openex/database/model/AssetGroup.java b/openex-model/src/main/java/io/openex/database/model/AssetGroup.java new file mode 100644 index 0000000000..d0fc6b1f8e --- /dev/null +++ b/openex-model/src/main/java/io/openex/database/model/AssetGroup.java @@ -0,0 +1,69 @@ +package io.openex.database.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.openex.database.audit.ModelBaseListener; +import io.openex.helper.MultiIdDeserializer; +import lombok.Data; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.annotations.UuidGenerator; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import static java.time.Instant.now; + +@Data +@Entity +@Table(name = "asset_groups") +@EntityListeners(ModelBaseListener.class) +public class AssetGroup implements Base { + + @Id + @Column(name = "asset_group_id") + @GeneratedValue(generator = "UUID") + @UuidGenerator + @JsonProperty("asset_group_id") + @NotBlank + private String id; + + @NotBlank + @Column(name = "asset_group_name") + @JsonProperty("asset_group_name") + private String name; + + @Column(name = "asset_group_description") + @JsonProperty("asset_group_description") + private String description; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "asset_groups_assets", + joinColumns = @JoinColumn(name = "asset_group_id"), + inverseJoinColumns = @JoinColumn(name = "asset_id")) + @JsonSerialize(using = MultiIdDeserializer.class) + @JsonProperty("asset_group_assets") + private List assets = new ArrayList<>(); + + // -- TAG -- + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "asset_groups_tags", + joinColumns = @JoinColumn(name = "asset_group_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id")) + @JsonSerialize(using = MultiIdDeserializer.class) + @JsonProperty("asset_group_tags") + private List tags = new ArrayList<>(); + + // -- AUDIT -- + + @Column(name = "asset_group_created_at") + @JsonProperty("asset_group_created_at") + private Instant createdAt = now(); + + @Column(name = "asset_group_updated_at") + @JsonProperty("asset_group_updated_at") + private Instant updatedAt = now(); +} diff --git a/openex-model/src/main/java/io/openex/database/model/AssetGroupAsset.java b/openex-model/src/main/java/io/openex/database/model/AssetGroupAsset.java new file mode 100644 index 0000000000..47dcbe0f5c --- /dev/null +++ b/openex-model/src/main/java/io/openex/database/model/AssetGroupAsset.java @@ -0,0 +1,30 @@ +package io.openex.database.model; + +import lombok.Data; + +import jakarta.persistence.*; +import java.io.Serializable; + +@Data +@Entity +@IdClass(AssetGroupAsset.AssetGroupAssetId.class) +@Table(name = "asset_groups_assets") +public class AssetGroupAsset { + + @Id + @Column(name = "asset_group_id") + private String assetGroupId; + + @Id + @Column(name = "asset_id") + private String assetId; + + @Data + public static class AssetGroupAssetId implements Serializable { + + private String assetGroupId; + private String assetId; + + } + +} diff --git a/openex-model/src/main/java/io/openex/database/model/Endpoint.java b/openex-model/src/main/java/io/openex/database/model/Endpoint.java new file mode 100644 index 0000000000..e68681ba7a --- /dev/null +++ b/openex-model/src/main/java/io/openex/database/model/Endpoint.java @@ -0,0 +1,51 @@ +package io.openex.database.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.openex.annotation.Ipv4OrIpv6Constraint; +import io.openex.database.audit.ModelBaseListener; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Type; + +@EqualsAndHashCode(callSuper = true) +@Data +@Entity +@DiscriminatorValue("Endpoint") +@EntityListeners(ModelBaseListener.class) +public class Endpoint extends Asset { + + public enum PLATFORM_TYPE { + @JsonProperty("Linux") + Linux, + @JsonProperty("Windows") + Windows, + @JsonProperty("Darwin") + Darwin, + } + + @NotEmpty + @Ipv4OrIpv6Constraint + @Type(value = io.openex.database.converter.PostgreSqlStringArrayType.class) + @Column(name = "endpoint_ips") + @JsonProperty("endpoint_ips") + private String[] ips; + + @Column(name = "endpoint_hostname") + @JsonProperty("endpoint_hostname") + private String hostname; + + @Column(name = "endpoint_platform") + @JsonProperty("endpoint_platform") + @Enumerated(EnumType.STRING) + @NotNull + private PLATFORM_TYPE platform; + + @Type(value = io.openex.database.converter.PostgreSqlStringArrayType.class) + @Column(name = "endpoint_mac_addresses") + @JsonProperty("endpoint_mac_addresses") + private String[] macAddresses; + +} diff --git a/openex-model/src/main/java/io/openex/database/model/Execution.java b/openex-model/src/main/java/io/openex/database/model/Execution.java index 05fd3a30dc..458ad7166b 100644 --- a/openex-model/src/main/java/io/openex/database/model/Execution.java +++ b/openex-model/src/main/java/io/openex/database/model/Execution.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import lombok.Setter; +import org.hibernate.annotations.Type; import java.time.Instant; import java.util.ArrayList; @@ -32,8 +33,9 @@ public class Execution { @Setter @Getter - @JsonProperty("execution_async_id") - private String asyncId; + @JsonProperty("execution_async_ids") + @Type(value = io.openex.database.converter.PostgreSqlStringArrayType.class) + private String[] asyncIds; @Getter @Setter @@ -60,7 +62,7 @@ public void stop() { @JsonIgnore public boolean isSynchronous() { - return asyncId == null; + return asyncIds == null; } public static Execution executionError(boolean runtime, String identifier, String message) { diff --git a/openex-model/src/main/java/io/openex/database/model/ExecutionTrace.java b/openex-model/src/main/java/io/openex/database/model/ExecutionTrace.java index 06796936b4..2429516df2 100644 --- a/openex-model/src/main/java/io/openex/database/model/ExecutionTrace.java +++ b/openex-model/src/main/java/io/openex/database/model/ExecutionTrace.java @@ -2,12 +2,15 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Objects; +@Getter public class ExecutionTrace { @JsonProperty("trace_identifier") @@ -16,12 +19,15 @@ public class ExecutionTrace { @JsonProperty("trace_users") private List userIds = new ArrayList<>(); + @Setter @JsonProperty("trace_message") private String message; + @Setter @JsonProperty("trace_status") private ExecutionStatus status; + @Setter @JsonProperty("trace_time") private Instant traceTime; @@ -62,38 +68,6 @@ public static ExecutionTrace traceError(String identifier, String message) { return new ExecutionTrace(ExecutionStatus.ERROR, identifier, List.of(), message, null); } - public ExecutionStatus getStatus() { - return status; - } - - public void setStatus(ExecutionStatus status) { - this.status = status; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - - public Instant getTraceTime() { - return traceTime; - } - - public void setTraceTime(Instant traceTime) { - this.traceTime = traceTime; - } - - public Exception getException() { - return exception; - } - - public List getUserIds() { - return userIds; - } - @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/openex-model/src/main/java/io/openex/database/model/InjectStatus.java b/openex-model/src/main/java/io/openex/database/model/InjectStatus.java index 8135ced251..5995ea00fe 100644 --- a/openex-model/src/main/java/io/openex/database/model/InjectStatus.java +++ b/openex-model/src/main/java/io/openex/database/model/InjectStatus.java @@ -6,6 +6,7 @@ import lombok.Getter; import lombok.Setter; import org.hibernate.annotations.UuidGenerator; +import org.hibernate.annotations.Type; import jakarta.persistence.*; import java.time.Instant; @@ -13,12 +14,12 @@ import static java.time.Instant.now; +@Setter +@Getter @Entity @Table(name = "injects_statuses") public class InjectStatus implements Base { - @Getter - @Setter @Id @Column(name = "status_id") @GeneratedValue(generator = "UUID") @@ -26,39 +27,28 @@ public class InjectStatus implements Base { @JsonProperty("status_id") private String id; - @Getter - @Setter @Column(name = "status_name") @JsonProperty("status_name") private String name; - @Getter - @Setter - @Column(name = "status_async_id") - @JsonProperty("status_async_id") - private String asyncId; + @Column(name = "status_async_ids") + @JsonProperty("status_async_ids") + @Type(value = io.openex.database.converter.PostgreSqlStringArrayType.class) + private String[] asyncIds; - @Getter - @Setter @Column(name = "status_reporting") @Convert(converter = ExecutionConverter.class) @JsonProperty("status_reporting") private Execution reporting; - @Getter - @Setter @Column(name = "status_date") @JsonProperty("status_date") private Instant date; - @Getter - @Setter @Column(name = "status_execution") @JsonProperty("status_execution") private Integer executionTime; - @Getter - @Setter @OneToOne @JoinColumn(name = "status_inject") @JsonIgnore @@ -67,7 +57,7 @@ public class InjectStatus implements Base { // region transient public static InjectStatus fromExecution(Execution execution, Inject inject) { InjectStatus injectStatus = new InjectStatus(); - injectStatus.setAsyncId(execution.getAsyncId()); + injectStatus.setAsyncIds(execution.getAsyncIds()); injectStatus.setInject(inject); injectStatus.setDate(now()); if (execution.isSynchronous()) { diff --git a/openex-model/src/main/java/io/openex/database/model/Tag.java b/openex-model/src/main/java/io/openex/database/model/Tag.java index 3b141859c2..9d30a3c81a 100644 --- a/openex-model/src/main/java/io/openex/database/model/Tag.java +++ b/openex-model/src/main/java/io/openex/database/model/Tag.java @@ -5,9 +5,12 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.openex.database.audit.ModelBaseListener; import io.openex.helper.MultiIdDeserializer; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; import org.hibernate.annotations.UuidGenerator; -import jakarta.persistence.*; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -16,78 +19,69 @@ @Table(name = "tags") @EntityListeners(ModelBaseListener.class) public class Tag implements Base { - @Id - @Column(name = "tag_id") - @GeneratedValue(generator = "UUID") - @UuidGenerator - @JsonProperty("tag_id") - private String id; - - @Column(name = "tag_name") - @JsonProperty("tag_name") - private String name; - - @Column(name = "tag_color") - @JsonProperty("tag_color") - private String color; - - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable(name = "documents_tags", - joinColumns = @JoinColumn(name = "tag_id"), - inverseJoinColumns = @JoinColumn(name = "document_id")) - @JsonSerialize(using = MultiIdDeserializer.class) - @JsonProperty("tags_documents") - private List documents = new ArrayList<>(); - - @JsonIgnore - @Override - public boolean isUserHasAccess(User user) { - return true; - } - - @Override - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name.toLowerCase(); - } - - public String getColor() { - return color; - } - - public void setColor(String color) { - this.color = color.toLowerCase(); - } - - public List getDocuments() { - return documents; - } - public void setDocuments(List documents) { - this.documents = documents; + @Setter + @Id + @Column(name = "tag_id") + @GeneratedValue(generator = "UUID") + @UuidGenerator + @JsonProperty("tag_id") + @NotBlank + private String id; + + @Getter + @Column(name = "tag_name") + @JsonProperty("tag_name") + private String name; + + @Getter + @Column(name = "tag_color") + @JsonProperty("tag_color") + private String color; + + @Setter + @Getter + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "documents_tags", + joinColumns = @JoinColumn(name = "tag_id"), + inverseJoinColumns = @JoinColumn(name = "document_id")) + @JsonSerialize(using = MultiIdDeserializer.class) + @JsonProperty("tags_documents") + private List documents = new ArrayList<>(); + + @JsonIgnore + @Override + public boolean isUserHasAccess(User user) { + return true; + } + + @Override + public String getId() { + return id; + } + + public void setName(String name) { + this.name = name.toLowerCase(); + } + + public void setColor(String color) { + this.color = color.toLowerCase(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || !Base.class.isAssignableFrom(o.getClass())) return false; - Base base = (Base) o; - return id.equals(base.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(id); + if (o == null || !Base.class.isAssignableFrom(o.getClass())) { + return false; } + Base base = (Base) o; + return id.equals(base.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } } diff --git a/openex-model/src/main/java/io/openex/database/repository/AssetGroupRepository.java b/openex-model/src/main/java/io/openex/database/repository/AssetGroupRepository.java new file mode 100644 index 0000000000..85e6a481c4 --- /dev/null +++ b/openex-model/src/main/java/io/openex/database/repository/AssetGroupRepository.java @@ -0,0 +1,22 @@ +package io.openex.database.repository; + +import io.openex.database.model.Asset; +import io.openex.database.model.AssetGroup; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +@Repository +public interface AssetGroupRepository extends CrudRepository { + + @Query("select asset from Asset asset " + + "inner join AssetGroupAsset aga on aga.assetId = asset.id " + + "inner join AssetGroup assetGroup on aga.assetGroupId = assetGroup.id " + + "where assetGroup.id = :assetGroupId") + List assetsFromAssetGroup(@NotBlank @Param("assetGroupId") final String assetGroupId); + +} diff --git a/openex-model/src/main/java/io/openex/database/repository/AssetRepository.java b/openex-model/src/main/java/io/openex/database/repository/AssetRepository.java new file mode 100644 index 0000000000..e6b9908c25 --- /dev/null +++ b/openex-model/src/main/java/io/openex/database/repository/AssetRepository.java @@ -0,0 +1,17 @@ +package io.openex.database.repository; + +import io.openex.database.model.Asset; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AssetRepository extends CrudRepository { + + @Query("select a from Asset a where a.type IN :types") + List findByType(@Param("types") final List types); + +} diff --git a/openex-model/src/main/java/io/openex/database/repository/EndpointRepository.java b/openex-model/src/main/java/io/openex/database/repository/EndpointRepository.java new file mode 100644 index 0000000000..345a97c37e --- /dev/null +++ b/openex-model/src/main/java/io/openex/database/repository/EndpointRepository.java @@ -0,0 +1,21 @@ +package io.openex.database.repository; + +import io.openex.database.model.Endpoint; +import jakarta.validation.constraints.NotBlank; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface EndpointRepository extends CrudRepository { + + @Query(value = "select e.* from assets e where e.asset_sources[:sourceKey] = :sourceValue", nativeQuery = true) + Optional findBySource( + @NotBlank final @Param("sourceKey") String sourceKey, + @NotBlank final @Param("sourceValue") String sourceValue + ); + +} diff --git a/openex-model/src/main/java/io/openex/database/repository/InjectStatusRepository.java b/openex-model/src/main/java/io/openex/database/repository/InjectStatusRepository.java index de2f352142..059996b574 100644 --- a/openex-model/src/main/java/io/openex/database/repository/InjectStatusRepository.java +++ b/openex-model/src/main/java/io/openex/database/repository/InjectStatusRepository.java @@ -17,6 +17,6 @@ public interface InjectStatusRepository extends CrudRepository findById(@NotNull String id); - @Query(value = "select c from InjectStatus c where c.name = 'PENDING' and c.inject.type = :injectType and c.asyncId is not null") + @Query(value = "select c from InjectStatus c where c.name = 'PENDING' and c.inject.type = :injectType and c.asyncIds is not null") List pendingForInjectType(@Param("injectType") String injectType); } diff --git a/openex-model/src/main/java/io/openex/validator/Ipv4OrIpv6Validator.java b/openex-model/src/main/java/io/openex/validator/Ipv4OrIpv6Validator.java new file mode 100644 index 0000000000..4f434f7a08 --- /dev/null +++ b/openex-model/src/main/java/io/openex/validator/Ipv4OrIpv6Validator.java @@ -0,0 +1,18 @@ +package io.openex.validator; + +import io.openex.annotation.Ipv4OrIpv6Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.apache.commons.validator.routines.InetAddressValidator; + +import java.util.Arrays; + +public class Ipv4OrIpv6Validator implements ConstraintValidator { + + @Override + public boolean isValid(final String[] ips, final ConstraintValidatorContext cxt) { + InetAddressValidator validator = InetAddressValidator.getInstance(); + return Arrays.stream(ips).allMatch(validator::isValid); + } + +} diff --git a/pom.xml b/pom.xml index cbb8551ba0..d015d5ef9b 100644 --- a/pom.xml +++ b/pom.xml @@ -19,8 +19,11 @@ 8.5.7 - 2.15.1 + 2.0.1.Final + 2.15.0 + 1.8.0 3.12.1 + 0.8.11