Portal: team-scoped Free PDF Editors usage card for SaaS (#6924)

## What

Phase 2 of the Free PDF Editors usage card (self-hosted shipped in
#6919): make it work on **SaaS**, where one backend serves many teams so
every figure must be scoped to the **caller's team**.

| Metric | SaaS (per team) |
|---|---|
| **Editors deployed** | team member count (`team_memberships`) |
| **Active this month** | distinct members with a free-UI
(`source='WEB'`, non-`UI_DATA`) audit event in 30d, clamped ≤ deployed |
| **PDFs edited** | the team's cumulative free-UI
`PDF_PROCESS`+`FILE_OPERATION` events |

Cost stays `$0`; uncomputable figures render **N/A**.

## Backend

- **Gate the self-hosted controller** `@Profile("!saas")` — its counts
are server-wide, which would leak across tenants on SaaS. New
team-scoped `SaasFleetUsageController` `@Profile("saas")` owns the same
`/api/v1/usage/fleet-stats` path (mutually exclusive profiles → no
mapping conflict).
- **Team resolution** mirrors `PaygWalletController`:
`AuthenticationUtils.getCurrentUser(auth, userRepo)` →
`TeamMembershipRepository.findPrimaryMembership` → members via
`findByTeamId`. `@PreAuthorize("isAuthenticated()")` (team leaders
aren't global admins; any member sees their own team's totals).
- **Audit → team join**: on SaaS the audit `principal` is the user's
email and `User.username == email`, so principals join cleanly to a
team's member usernames (no hashing — only raw-JWT/over-long principals
get hashed). Two new `principal IN` count queries do the filtering,
served by the `(source, timestamp, principal)` index from #6919.
- Billing/ledger is deliberately **not** used — it only records billable
ops; free-editor activity comes from audit (same `source='WEB'` signal
as self-hosted).
- `null`→N/A when EE auditing < STANDARD; 401 on no-auth; empty-fleet
guard for the (post-migration-shouldn't-happen) teamless caller.

## Frontend

- New `src/portal-saas/api/fleetStats.ts` (rides the `@portal/*` cascade
from #6900) reads via **`apiClient.saas`** — the Supabase JWT the SaaS
backend uses to resolve the team. Re-exports `FleetStats` via
`@portal-proprietary`. **The card and `useAsync` hook are untouched.**

## Tests

`STIRLING_FLAVOR=saas` build green — `:proprietary` + `:saas` compile,
`SaasFleetUsageControllerTest` (team scoping, audit-off→null, clamp,
no-team→empty, unauth→401) and the existing suites pass; spotless clean.

## Notes

- Requires SaaS auditing at STANDARD (it is) — else N/A.
- Depends on #6900 (merged) for the portal-saas override layer and #6919
(merged) for the audit `source` column + DTO.
This commit is contained in:
ConnorYoh
2026-07-10 13:28:33 +00:00
committed by GitHub
parent 84d4455682
commit ece3562dc9
6 changed files with 315 additions and 3 deletions
@@ -4,6 +4,7 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -38,6 +39,9 @@ import stirling.software.proprietary.security.database.repository.UserRepository
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
// Self-hosted only: counts are server-wide. On SaaS this endpoint is owned by the team-scoped
// SaasFleetUsageController (@Profile("saas")) so one backend can't leak another tenant's usage.
@Profile("!saas")
public class FleetUsageController {
private final PersistentAuditEventRepository auditRepository;
@@ -276,4 +276,25 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
@Param("source") String source,
@Param("excludeType") String excludeType,
@Param("since") Instant since);
// Team-scoped (SaaS) variants: same free-UI counts, constrained to a team's member principals.
@Query(
"SELECT COUNT(e) FROM PersistentAuditEvent e "
+ "WHERE e.type IN :types AND e.source = :source "
+ "AND e.principal IN :principals AND e.timestamp > :since")
long countByTypeInAndSourceAndPrincipalInAndTimestampAfter(
@Param("types") List<String> types,
@Param("source") String source,
@Param("principals") List<String> principals,
@Param("since") Instant since);
@Query(
"SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e "
+ "WHERE e.source = :source AND e.type <> :excludeType "
+ "AND e.principal IN :principals AND e.timestamp > :since")
long countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter(
@Param("source") String source,
@Param("excludeType") String excludeType,
@Param("principals") List<String> principals,
@Param("since") Instant since);
}
@@ -0,0 +1,103 @@
package stirling.software.saas.usage;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* SaaS counterpart to {@code FleetUsageController}: the same "Free PDF Editors" figures, scoped to
* the caller's team (one SaaS backend serves many tenants, so the self-hosted server-wide variant
* is disabled here via {@code @Profile("!saas")}).
*
* <ul>
* <li>editorsDeployed — number of team members ({@code team_memberships}), not seat limits;
* <li>activeThisMonth — distinct members with a free-UI ("WEB", non-{@code UI_DATA}) audit event
* in the last 30 days, clamped to a subset of deployed;
* <li>pdfsProcessed — the team's cumulative free-UI PDF/file operations.
* </ul>
*
* <p>Team resolution + membership mirror {@code PaygWalletController}. Audit-derived figures are
* null (rendered "N/A") when EE auditing is below STANDARD. Cost is always $0 (client literal).
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/usage")
@Profile("saas")
@RequiredArgsConstructor
public class SaasFleetUsageController {
private static final List<String> PDF_TYPES = List.of("PDF_PROCESS", "FILE_OPERATION");
private final UserRepository userRepository;
private final TeamMembershipRepository memberRepo;
private final PersistentAuditEventRepository auditRepository;
private final AuditConfigurationProperties auditConfig;
@GetMapping("/fleet-stats")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<FleetUsageStats> fleetStats(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<TeamMembership> primary = memberRepo.findPrimaryMembership(user.getId());
if (primary.isEmpty()) {
// Authenticated caller without a team — shouldn't happen post-migration; report an
// empty fleet rather than 500.
return ResponseEntity.ok(new FleetUsageStats(0L, null, null));
}
Long teamId = primary.get(0).getTeam().getId();
List<String> members =
memberRepo.findByTeamId(teamId).stream()
.map(m -> m.getUser().getUsername())
.toList();
Long deployed = (long) members.size();
// Guard the empty IN-list (invalid JPQL) as well as the audit-level gate.
boolean auditOn = !members.isEmpty() && auditConfig.isLevelEnabled(AuditLevel.STANDARD);
Instant since = Instant.now().minus(30, ChronoUnit.DAYS);
Long active =
auditOn
? auditRepository
.countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter(
"WEB", "UI_DATA", members, since)
: null;
Long pdfs =
auditOn
? auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter(
PDF_TYPES, "WEB", members, Instant.EPOCH)
: null;
if (active != null && active > deployed) {
active = deployed; // active editors are a subset of those deployed
}
return ResponseEntity.ok(new FleetUsageStats(deployed, active, pdfs));
}
}
@@ -0,0 +1,168 @@
package stirling.software.saas.usage;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.model.api.usage.FleetUsageStats;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
@ExtendWith(MockitoExtension.class)
class SaasFleetUsageControllerTest {
@Mock private UserRepository userRepository;
@Mock private TeamMembershipRepository memberRepo;
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private AuditConfigurationProperties auditConfig;
private SaasFleetUsageController controller;
@BeforeEach
void setUp() {
controller =
new SaasFleetUsageController(
userRepository, memberRepo, auditRepository, auditConfig);
}
/** An Authentication whose principal is a User (AuthenticationUtils returns it directly). */
private Authentication authFor(long userId) {
User user = mock(User.class);
when(user.getId()).thenReturn(userId);
Authentication auth = mock(Authentication.class);
when(auth.getPrincipal()).thenReturn(user);
return auth;
}
private TeamMembership memberOf(long teamId, String username) {
// lenient: a member used only in the roster has its team.getId() stub unused, which strict
// stubbing would otherwise flag.
Team team = mock(Team.class);
lenient().when(team.getId()).thenReturn(teamId);
User u = mock(User.class);
lenient().when(u.getUsername()).thenReturn(username);
TeamMembership m = mock(TeamMembership.class);
lenient().when(m.getTeam()).thenReturn(team);
lenient().when(m.getUser()).thenReturn(u);
return m;
}
@Test
@DisplayName("figures are scoped to the caller's team members")
void teamScopedFigures() {
Authentication auth = authFor(1L);
TeamMembership leader = memberOf(42L, "leader@acme.test");
TeamMembership bob = memberOf(42L, "bob@acme.test");
when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader));
when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader, bob));
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter(
eq("WEB"), eq("UI_DATA"), anyList(), any(Instant.class)))
.thenReturn(1L);
when(auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter(
anyList(), eq("WEB"), anyList(), any(Instant.class)))
.thenReturn(88L);
ResponseEntity<FleetUsageStats> res = controller.fleetStats(auth);
FleetUsageStats stats = res.getBody();
assertThat(stats).isNotNull();
assertThat(stats.editorsDeployed()).isEqualTo(2L);
assertThat(stats.activeThisMonth()).isEqualTo(1L);
assertThat(stats.pdfsProcessed()).isEqualTo(88L);
verify(auditRepository)
.countByTypeInAndSourceAndPrincipalInAndTimestampAfter(
eq(List.of("PDF_PROCESS", "FILE_OPERATION")),
eq("WEB"),
eq(List.of("leader@acme.test", "bob@acme.test")),
any(Instant.class));
}
@Test
@DisplayName("audit-derived figures are null when auditing is below STANDARD")
void auditOffYieldsNulls() {
Authentication auth = authFor(1L);
TeamMembership leader = memberOf(42L, "leader@acme.test");
when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader));
when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader));
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false);
FleetUsageStats stats = controller.fleetStats(auth).getBody();
assertThat(stats).isNotNull();
assertThat(stats.editorsDeployed()).isEqualTo(1L);
assertThat(stats.activeThisMonth()).isNull();
assertThat(stats.pdfsProcessed()).isNull();
verify(auditRepository, never())
.countByTypeInAndSourceAndPrincipalInAndTimestampAfter(
anyList(), any(), anyList(), any(Instant.class));
}
@Test
@DisplayName("active is clamped to deployed (a subset)")
void activeClampedToDeployed() {
Authentication auth = authFor(1L);
TeamMembership leader = memberOf(42L, "leader@acme.test");
when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader));
when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader));
when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true);
when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter(
eq("WEB"), eq("UI_DATA"), anyList(), any(Instant.class)))
.thenReturn(5L);
when(auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter(
anyList(), eq("WEB"), anyList(), any(Instant.class)))
.thenReturn(10L);
FleetUsageStats stats = controller.fleetStats(auth).getBody();
assertThat(stats).isNotNull();
assertThat(stats.activeThisMonth()).isEqualTo(1L);
}
@Test
@DisplayName("a caller with no team gets an empty fleet, not a 500")
void noTeamReturnsEmpty() {
Authentication auth = authFor(1L);
when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of());
FleetUsageStats stats = controller.fleetStats(auth).getBody();
assertThat(stats).isNotNull();
assertThat(stats.editorsDeployed()).isEqualTo(0L);
assertThat(stats.activeThisMonth()).isNull();
assertThat(stats.pdfsProcessed()).isNull();
}
@Test
@DisplayName("an unauthenticated request is 401")
void unauthenticatedIs401() {
ResponseEntity<FleetUsageStats> res = controller.fleetStats(null);
assertThat(res.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
}
@@ -0,0 +1,16 @@
import { apiClient } from "@portal/api/http";
import type { FleetStats } from "@portal-proprietary/api/fleetStats";
export type { FleetStats };
/**
* SaaS build: fleet usage is team-scoped and served by the SaaS backend, so it is
* read via {@code apiClient.saas} — the admin's Supabase JWT, which the SaaS backend
* uses to resolve the caller's team. Shadows the self-hosted
* src/portal/api/fleetStats.ts (which reads the local backend server-wide).
*/
export function fetchFleetStats(signal?: AbortSignal): Promise<FleetStats> {
return apiClient.saas.json<FleetStats>("/api/v1/usage/fleet-stats", {
signal,
});
}
+3 -3
View File
@@ -4,9 +4,9 @@ import { apiClient } from "@portal/api/http";
* Free-editor fleet usage for the {@link FreePdfEditorsCard}.
*
* Self-hosted (this module) reads the local Stirling backend — the figures come
* from this instance's audit trail, filtered to free UI tool runs. A SaaS build
* shadows this module (src/saas/portal/api/fleetStats.ts) to read the
* team-scoped SaaS backend instead.
* from this instance's audit trail, filtered to free UI tool runs. The SaaS build
* shadows this module (src/portal-saas/api/fleetStats.ts) to read the team-scoped
* SaaS backend instead.
*
* Any field may be null when the backend can't compute it (e.g. EE auditing is
* disabled); the card renders null as "N/A" rather than a misleading 0.