Skip to content
Merged
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
4 changes: 2 additions & 2 deletions openspec/specs/report-audit-trail/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ All destructive `ReportEndpoint` handlers (`DELETE /reports/{id}`, `DELETE /repo

### Requirement: Owner verification for mark failed by scan ID

`POST /api/v1/reports/failed` (`markFailedByScanId`) SHALL verify ownership before updating report status. For human users (actor resolved via `UserService` without a JWT principal name), every report matching the scan ID SHALL have `metadata.user` equal to the actor. For service accounts (JWT principal name present per `UtilitiesService`), owner verification SHALL be skipped. On success, the backend SHALL write an audit record with operation `MODIFY_STATUS`, affected `report_ids`, and `context` containing `scanId`, `errorType`, and `errorMessage`.
`POST /api/v1/reports/failed` (`markFailedByScanId`) SHALL verify ownership before updating report status. For human users (callers without a configured service-account role), every report matching the scan ID SHALL have `metadata.user` equal to the actor. For service accounts (callers holding any role listed in `exploitiq.security.service-account-roles`), owner verification SHALL be skipped. Service-account detection MUST be role-based and MUST NOT rely on JWT principal type, so it works across OpenShift OAuth and Keycloak. On success, the backend SHALL write an audit record with operation `MODIFY_STATUS`, affected `report_ids`, and `context` containing `scanId`, `errorType`, and `errorMessage`.

#### Scenario: Human user marks own report failed

Expand All @@ -88,7 +88,7 @@ All destructive `ReportEndpoint` handlers (`DELETE /reports/{id}`, `DELETE /repo

#### Scenario: Service account may mark any matching report failed

- **WHEN** an authenticated service account (JWT principal name present) calls `POST /api/v1/reports/failed` for an existing scan ID
- **WHEN** an authenticated service account (holding a configured service-account role such as `exploitiq-api-access`) calls `POST /api/v1/reports/failed` for an existing scan ID
- **THEN** the backend writes a `MODIFY_STATUS` audit record with the service principal as `actor`
- **AND** updates all matching reports
- **AND** returns **202 Accepted**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ public class RoleMappingAugmentor implements SecurityIdentityAugmentor {
@ConfigProperty(name = "quarkus.http.auth.policy.role-policy.roles-allowed", defaultValue = "exploit-iq-admin,exploit-iq-view,exploit-iq-prodsec")
Set<String> targetRoles;

@ConfigProperty(name = "exploitiq.security.service-account-roles")
Set<String> serviceAccountRoles;

@ConfigProperty(name = "quarkus.oidc.enabled", defaultValue = "true")
boolean oidcEnabled;

Expand All @@ -71,21 +74,23 @@ public class RoleMappingAugmentor implements SecurityIdentityAugmentor {
/**
* Augments the security identity.
*
* In DEV/TEST mode with OIDC disabled, it creates a mock privileged user.
* In DEV/TEST mode with OIDC disabled, it creates a mock privileged human user
* (application roles only — not service-account roles).
* Otherwise, it maps OIDC token claims to application roles.
*/
@Override
public Uni<SecurityIdentity> augment(SecurityIdentity identity, AuthenticationRequestContext context) {
if (!oidcEnabled && (LaunchMode.current() == LaunchMode.DEVELOPMENT || LaunchMode.current() == LaunchMode.TEST)
&& identity.isAnonymous()) {
Set<String> humanRoles = humanTargetRoles();
if (!loggedDevModeWarning) {
LOG.warnf("OIDC is disabled and in DEV/TEST mode. Granting anonymous user all target roles: %s",
targetRoles);
LOG.warnf("OIDC is disabled and in DEV/TEST mode. Granting anonymous user human roles: %s",
humanRoles);
loggedDevModeWarning = true;
}
return Uni.createFrom().item(QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal("anonymous"))
.addRoles(targetRoles)
.addRoles(humanRoles)
.build());
}

Expand Down Expand Up @@ -195,4 +200,17 @@ private void processRole(String roleName, String sourceName, Set<String> addedRo
}
}
}

/**
* Human application roles only. Service-account roles are included in
* {@code roles-allowed} for HTTP auth, but must not be granted to the DEV/TEST
* anonymous mock user or owner verification is skipped.
*/
private Set<String> humanTargetRoles() {
Set<String> humanRoles = new HashSet<>(targetRoles);
if (serviceAccountRoles != null) {
humanRoles.removeAll(serviceAccountRoles);
}
return humanRoles;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
import io.quarkus.security.ForbiddenException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.resteasy.reactive.ClientWebApplicationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
Expand Down Expand Up @@ -158,6 +157,14 @@ public class ReportService {
@ConfigProperty(name = "exploit-iq.purge.after", defaultValue = "7d")
Duration purgeAfter;

/**
* Roles that identify service accounts (OIDC-provider agnostic). Callers with any of these
* roles skip report owner verification on mark-failed. Kept outside {@code exploit-iq.*}
* {@link AppConfig} mapping so values resolve at runtime (native-safe; {@code NAMESPACE} available).
*/
@ConfigProperty(name = "exploitiq.security.service-account-roles")
Set<String> serviceAccountRoles;

@Startup
void loadConfig() throws FileNotFoundException, IOException {
includes = getMappingConfig(includesPath);
Expand Down Expand Up @@ -379,8 +386,12 @@ public Collection<String> remove(String actor, Map<String, String> query) {
return deleteIds;
}

/**
* Owner verification applies to human users only. Service accounts are identified by role
* (not JWT principal type), so this works for OpenShift OAuth and Keycloak alike.
*/
private boolean requiresOwnerVerification(SecurityContext securityContext) {
return !(securityContext.getUserPrincipal() instanceof JsonWebToken);
return serviceAccountRoles.stream().noneMatch(securityContext::isUserInRole);
}

private void verifyReportOwnership(String actor, List<Report> reports) {
Expand Down
13 changes: 8 additions & 5 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,9 @@ quarkus.oidc.authentication.java-script-auto-redirect=false
# Strategy:
# - Global policy: Authenticated + one of:
# User roles: exploit-iq-view, exploit-iq-prodsec, exploit-iq-admin
# OpenShift SAs (specific only — not all SAs in the namespace):
# system:serviceaccounts:${NAMESPACE}:exploit-iq-sa (ExploitIQ agent)
# system:serviceaccounts:${NAMESPACE}:pipeline (CI / MLOps pipelines)
# External IdP / Keycloak service accounts: exploitiq-api-access
# Service accounts: ${exploitiq.security.service-account-roles}
# OpenShift: system:serviceaccount:${NAMESPACE}:exploit-iq-sa, ...:pipeline
# Keycloak / external-idp: exploitiq-api-access
# - Exceptions: Specific paths (logout, health, dev-ui) are permitted.
# ==============================================================================

Expand All @@ -143,8 +142,12 @@ quarkus.http.auth.permission.management.policy=permit
%dev.quarkus.http.auth.permission.dev-ui.paths=/q/*
%dev.quarkus.http.auth.permission.dev-ui.policy=permit

# Service-account roles (OIDC-provider agnostic): used for HTTP auth and to skip report owner verification.
# Outside exploit-iq.* AppConfig mapping so native builds do not bake unresolved ${NAMESPACE}.
exploitiq.security.service-account-roles=system:serviceaccount:${NAMESPACE}:exploit-iq-sa,system:serviceaccount:${NAMESPACE}:pipeline,exploitiq-api-access

# Global policy: Require authentication and at least one role
quarkus.http.auth.policy.role-policy.roles-allowed=exploit-iq-view,exploit-iq-prodsec,exploit-iq-admin,system:serviceaccount:${NAMESPACE}:exploit-iq-sa,system:serviceaccount:${NAMESPACE}:pipeline,exploitiq-api-access
quarkus.http.auth.policy.role-policy.roles-allowed=exploit-iq-view,exploit-iq-prodsec,exploit-iq-admin,${exploitiq.security.service-account-roles}
quarkus.http.auth.permission.default.paths=/*
quarkus.http.auth.permission.default.policy=role-policy
# No order specified = lowest priority (applied last, after exceptions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
import jakarta.inject.Inject;
import jakarta.ws.rs.core.SecurityContext;
import org.bson.Document;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.Set;

@QuarkusTest
class ReportServiceAuditTest {

Expand All @@ -31,6 +34,9 @@ class ReportServiceAuditTest {
@Inject
ReportAuditRepositoryService auditRepository;

@ConfigProperty(name = "exploitiq.security.service-account-roles")
Set<String> serviceAccountRoles;

@Test
void removeSingleWritesAuditRecord() {
long before = auditRepository.countDocuments();
Expand All @@ -47,7 +53,7 @@ void removeSingleWritesAuditRecord() {
@Test
void markFailedByScanIdAllowsOwnerInDevMode() {
long before = auditRepository.countDocuments();
SecurityContext securityContext = devSecurityContext();
SecurityContext securityContext = humanSecurityContext();

reportService.markFailedByScanId(
ANONYMOUS_OWNED_SCAN_ID, "processing-error", "owner allowed", "anonymous", securityContext);
Expand All @@ -62,7 +68,7 @@ void markFailedByScanIdAllowsOwnerInDevMode() {
@Test
void markFailedByScanIdRejectsForeignOwner() {
long before = auditRepository.countDocuments();
SecurityContext securityContext = devSecurityContext();
SecurityContext securityContext = humanSecurityContext();

Assertions.assertThrows(
ForbiddenException.class,
Expand All @@ -72,9 +78,35 @@ void markFailedByScanIdRejectsForeignOwner() {
Assertions.assertEquals(before, auditRepository.countDocuments());
}

private static SecurityContext devSecurityContext() {
@Test
void markFailedByScanIdAllowsServiceAccountForForeignOwner() {
Assertions.assertFalse(serviceAccountRoles.isEmpty(), "service-account-roles must be configured");
String serviceRole = serviceAccountRoles.iterator().next();
long before = auditRepository.countDocuments();
SecurityContext securityContext = serviceAccountSecurityContext(serviceRole);

reportService.markFailedByScanId(
FOREIGN_OWNED_SCAN_ID, "processing-error", "service account allowed", "exploit-iq-sa", securityContext);

Assertions.assertEquals(before + 1, auditRepository.countDocuments());
Document audit = auditRepository.findLatestByOperation(ReportAuditOperation.MODIFY_STATUS);
Assertions.assertNotNull(audit);
Assertions.assertEquals("exploit-iq-sa", audit.getString("actor"));
Assertions.assertEquals(
FOREIGN_OWNED_SCAN_ID, audit.get("context", Document.class).getString("scanId"));
}

private static SecurityContext humanSecurityContext() {
SecurityContext securityContext = Mockito.mock(SecurityContext.class);
Mockito.when(securityContext.getUserPrincipal()).thenReturn(new QuarkusPrincipal("anonymous"));
Mockito.when(securityContext.isUserInRole(Mockito.anyString())).thenReturn(false);
return securityContext;
}

private static SecurityContext serviceAccountSecurityContext(String serviceRole) {
SecurityContext securityContext = Mockito.mock(SecurityContext.class);
Mockito.when(securityContext.getUserPrincipal()).thenReturn(new QuarkusPrincipal("exploit-iq-sa"));
Mockito.when(securityContext.isUserInRole(serviceRole)).thenReturn(true);
return securityContext;
}
}