Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
import io.openaev.rest.injector_contract.form.InjectorContractUpdateMappingInput;
import io.openaev.rest.injector_contract.input.InjectorContractSearchPaginationInput;
import io.openaev.rest.injector_contract.output.InjectorContractBaseOutput;
import io.openaev.rest.injector_contract.output.InjectorContractDomainCountOutput;
import io.openaev.utils.pagination.SearchPaginationInput;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
Expand All @@ -26,6 +29,7 @@ public class InjectorContractApi extends RestBehavior {
public static final String INJECTOR_CONTRACT_URL = "/api/injector_contracts";

private final InjectorContractService injectorContractService;
private final InjectorContractDomainStatsService injectorContractDomainStatsService;

@GetMapping(INJECTOR_CONTRACT_URL)
@RBAC(actionPerformed = Action.SEARCH, resourceType = ResourceType.INJECTOR_CONTRACT)
Expand Down Expand Up @@ -58,6 +62,14 @@ public Page<? extends InjectorContractBaseOutput> injectorContracts(
}
}

@PostMapping(INJECTOR_CONTRACT_URL + "/domain-counts")
@RBAC(actionPerformed = Action.SEARCH, resourceType = ResourceType.INJECTOR_CONTRACT)
public List<InjectorContractDomainCountOutput> getDomainCounts(
@RequestBody @Valid final InjectorContractSearchPaginationInput input) {
SearchPaginationInput filtered = handleArchitectureFilter(input);
return injectorContractService.getDomainCounts(filtered);
}

/**
* Retrieves a specific injector contract by ID.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package io.openaev.rest.injector_contract;

import io.openaev.database.model.Domain;
import io.openaev.database.model.InjectorContract;
import io.openaev.database.specification.InjectorContractSpecification;
import io.openaev.rest.injector_contract.input.InjectorContractSearchPaginationInput;
import io.openaev.rest.injector_contract.output.InjectorContractDomainCountOutput;
import io.openaev.service.UserService;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Tuple;
import jakarta.persistence.criteria.*;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class InjectorContractDomainStatsService {

@PersistenceContext private final EntityManager entityManager;

private final UserService userService;

public List<InjectorContractDomainCountOutput> countByDomain(
InjectorContractSearchPaginationInput input) {

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Tuple> cq = cb.createTupleQuery();
Root<InjectorContract> root = cq.from(InjectorContract.class);

Specification<InjectorContract> spec =
InjectorContractSpecification.hasAccessToInjectorContract(userService.currentUser());

Predicate predicate = spec.toPredicate(root, cq, cb);
if (predicate != null) {
cq.where(predicate);
}

Join<InjectorContract, Domain> domainJoin = root.join("domains", JoinType.LEFT);

cq.multiselect(
domainJoin.get("id").alias("domainId"),
cb.countDistinct(root.get("id")).alias("contractCount"))
.groupBy(domainJoin.get("id"));

return entityManager.createQuery(cq).getResultList().stream()
.map(
t ->
new InjectorContractDomainCountOutput(
t.get("domainId", String.class), t.get("contractCount", Long.class)))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import static io.openaev.helper.DatabaseHelper.updateRelation;
import static io.openaev.helper.StreamHelper.fromIterable;
import static io.openaev.helper.StreamHelper.iterableToSet;
import static io.openaev.utils.FilterUtilsJpa.computeFilterGroupJpa;
import static io.openaev.utils.JpaUtils.*;
import static io.openaev.utils.pagination.SearchUtilsJpa.computeSearchJpa;
import static io.openaev.utils.pagination.SortUtilsCriteriaBuilder.toSortCriteriaBuilder;

import com.fasterxml.jackson.databind.JsonNode;
Expand All @@ -25,10 +27,12 @@
import io.openaev.rest.injector_contract.form.InjectorContractUpdateInput;
import io.openaev.rest.injector_contract.form.InjectorContractUpdateMappingInput;
import io.openaev.rest.injector_contract.output.InjectorContractBaseOutput;
import io.openaev.rest.injector_contract.output.InjectorContractDomainCountOutput;
import io.openaev.rest.injector_contract.output.InjectorContractFullOutput;
import io.openaev.rest.vulnerability.service.VulnerabilityService;
import io.openaev.service.UserService;
import io.openaev.utils.TargetType;
import io.openaev.utils.pagination.SearchPaginationInput;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Tuple;
Expand Down Expand Up @@ -547,4 +551,29 @@ public InjectorContract convertInjectorFromInput(InjectorContractInput in, Injec
}
return injectorContract;
}

public List<InjectorContractDomainCountOutput> getDomainCounts(SearchPaginationInput input) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<InjectorContractDomainCountOutput> query =
cb.createQuery(InjectorContractDomainCountOutput.class);
Root<InjectorContract> root = query.from(InjectorContract.class);

Join<InjectorContract, Domain> domainJoin = root.join("domains");

Specification<InjectorContract> filterSpec = computeFilterGroupJpa(input.getFilterGroup());
Specification<InjectorContract> searchSpec = computeSearchJpa(input.getTextSearch());
Specification<InjectorContract> finalSpec = Specification.where(filterSpec).and(searchSpec);

if (finalSpec != null) {
Predicate predicate = finalSpec.toPredicate(root, query, cb);
if (predicate != null) {
query.where(predicate);
}
}

query.multiselect(domainJoin.get("id"), cb.countDistinct(root));
query.groupBy(domainJoin.get("id"));

return entityManager.createQuery(query).getResultList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.openaev.rest.injector_contract.output;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class InjectorContractDomainCountOutput {
@NotBlank
@JsonProperty("domain")
@Schema(description = "The domain name extracted from OpenAEV", example = "Endpoints")
private String domain;

@NotNull
@JsonProperty("count")
@Schema(description = "Total number of observations linked to this domain", example = "42")
private Long count;

public InjectorContractDomainCountOutput(String domain, Long count) {
this.domain = domain;
this.count = count;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.openaev.rest.injector_contract.output;

import java.util.List;
import java.util.Map;
import lombok.Data;

@Data
public class InjectorContractSearchResult {
private List<InjectorContractFullOutput> contracts;
private Map<String, Long> injectorContractDomainCounts;

public InjectorContractSearchResult(
List<InjectorContractFullOutput> contracts, Map<String, Long> domainCounts) {
this.contracts = contracts;
this.injectorContractDomainCounts = domainCounts;
}
}
Comment on lines +7 to +17
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new output class is never used anywhere in the codebase. The getDomainCounts endpoint returns List directly instead. Consider removing this unused class or clarifying its intended usage.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import io.openaev.IntegrationTest;
import io.openaev.database.model.*;
import io.openaev.database.repository.DomainRepository;
import io.openaev.database.repository.InjectorContractRepository;
import io.openaev.rest.domain.enums.PresetDomain;
import io.openaev.rest.injector_contract.form.InjectorContractAddInput;
import io.openaev.rest.injector_contract.form.InjectorContractUpdateInput;
import io.openaev.rest.injector_contract.form.InjectorContractUpdateMappingInput;
Expand Down Expand Up @@ -76,6 +78,7 @@ public class InjectorContractApiTest extends IntegrationTest {
@Autowired private InjectorContractRepository injectorContractRepository;
@Autowired private DomainComposer domainComposer;
@Autowired private PayloadComposer payloadComposer;
@Autowired private DomainRepository domainRepository;

@Autowired private UserComposer userComposer;
@Autowired private GroupComposer groupComposer;
Expand Down Expand Up @@ -1525,4 +1528,73 @@ void testSearchInjectorContractsWithFullDetails(
}
}
}


@Nested
@DisplayName("When contracts are linked to security domains")
class WhenContractsAreLinkedToDomains {
@Test
@DisplayName("It should aggregate counts correctly by domain category")
void getDomainCountsReturnAggregation() throws Exception {
domainRepository.deleteAll();
em.flush();

Set<Domain> endpointDomain =
domainComposer.forDomain(PresetDomain.ENDPOINT).persist().getSet();
Set<Domain> cloudDomain = domainComposer.forDomain(PresetDomain.CLOUD).persist().getSet();

Injector validInjector = injectorFixture.getWellKnownOaevImplantInjector();

InjectorContract contract1 = InjectorContractFixture.createDefaultInjectorContract();
contract1.setId(UUID.randomUUID().toString());

contract1.setDomains(new HashSet<>(endpointDomain));

injectorContractComposer.forInjectorContract(contract1).withInjector(validInjector).persist();

InjectorContract contract2 = InjectorContractFixture.createDefaultInjectorContract();
contract2.setId(UUID.randomUUID().toString());

contract2.setDomains(new HashSet<>(endpointDomain));

injectorContractComposer.forInjectorContract(contract2).withInjector(validInjector).persist();

InjectorContract contract3 = InjectorContractFixture.createDefaultInjectorContract();
contract3.setId(UUID.randomUUID().toString());

contract3.setDomains(new HashSet<>(cloudDomain));

injectorContractComposer.forInjectorContract(contract3).withInjector(validInjector).persist();

InjectorContractSearchPaginationInput input = new InjectorContractSearchPaginationInput();

String response =
mvc.perform(
post(INJECTOR_CONTRACT_URL + "/domain-counts")
.contentType(MediaType.APPLICATION_JSON)
.content(asJsonString(input)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();

assertThatJson(response)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS, Option.IGNORING_ARRAY_ORDER)
.isEqualTo(
String.format(
"""
[
{
"domain": "%s",
"count": 2
},
{
"domain": "%s",
"count": 1
}
]
""",
endpointDomain.iterator().next().getId(), cloudDomain.iterator().next().getId()));
}
}
}
43 changes: 0 additions & 43 deletions openaev-front/src/actions/InjectorContracts.js

This file was deleted.

55 changes: 55 additions & 0 deletions openaev-front/src/actions/InjectorContracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type Dispatch } from 'redux';

import { delReferential, getReferential, postReferential, putReferential, simpleCall, simplePostCall } from '../utils/Action';
import {
type InjectorContract,
type InjectorContractAddInput,
type InjectorContractUpdateInput,
type InjectorContractUpdateMappingInput,
type SearchPaginationInput,
} from '../utils/api-types';
import * as schema from './Schema';

const INJECTOR_CONTRACT_URI = '/api/injector_contracts';

export const fetchInjectorContract = (injectorContractId: InjectorContract['injector_contract_id']) => (dispatch: Dispatch) => {
return getReferential(schema.injectorContract, `${INJECTOR_CONTRACT_URI}/${injectorContractId}`)(dispatch);
};

export const directFetchInjectorContract = (injectorContractId: InjectorContract['injector_contract_id']) => {
return simpleCall(`${INJECTOR_CONTRACT_URI}/${injectorContractId}`);
};

export const fetchInjectorsContracts = () => (dispatch: Dispatch) => {
return getReferential(schema.arrayOfInjectorContracts, `${INJECTOR_CONTRACT_URI}`)(dispatch);
};

export const searchInjectorContracts = (paginationInput: SearchPaginationInput) => {
const data = paginationInput;
const uri = `${INJECTOR_CONTRACT_URI}/search`;
return simplePostCall(uri, data);
};

export const updateInjectorContract = (injectorContractId: InjectorContract['injector_contract_id'], data: InjectorContractUpdateInput) => (dispatch: Dispatch) => {
return putReferential(schema.injectorContract, `${INJECTOR_CONTRACT_URI}/${injectorContractId}`, data)(dispatch);
};

export const updateInjectorContractMapping = (injectorContractId: InjectorContract['injector_contract_id'], data: InjectorContractUpdateMappingInput) => (dispatch: Dispatch) => {
const uri = `${INJECTOR_CONTRACT_URI}/${injectorContractId}/mapping`;
return putReferential(schema.injectorContract, uri, data)(dispatch);
};

export const addInjectorContract = (data: InjectorContractAddInput) => (dispatch: Dispatch) => {
return postReferential(schema.injectorContract, `${INJECTOR_CONTRACT_URI}`, data)(dispatch);
};

// This action must use InjectorContractSearchPaginationInput to stay
// synchronized with the search route filters
export const fetchDomainCounts = (data: SearchPaginationInput) => {
const uri = `${INJECTOR_CONTRACT_URI}/domain-counts`;
return simplePostCall(uri, data);
};

export const deleteInjectorContract = (injectorContractId: InjectorContract['injector_contract_id']) => (dispatch: Dispatch) => {
return delReferential(`${INJECTOR_CONTRACT_URI}/${injectorContractId}`, 'injectorcontracts', injectorContractId)(dispatch);
};
5 changes: 2 additions & 3 deletions openaev-front/src/actions/domains/domain-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ const DOMAIN_URI = '/api/domains';
const fetchDomains = () => (dispatch: Dispatch) => {
return getReferential(arrayOfDomains, DOMAIN_URI)(dispatch);
};

export default fetchDomains;

// -- OPTION --

export const searchDomainsByNameAsOption = (searchText: string = '') => {
Expand All @@ -21,3 +18,5 @@ export const searchDomainsByNameAsOption = (searchText: string = '') => {
export const searchDomainsByIdsAsOption = (ids: string[]) => {
return simplePostCall(`${DOMAIN_URI}/options`, ids);
};

export default fetchDomains;
Loading