mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
+4
@@ -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;
|
||||
|
||||
+21
@@ -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));
|
||||
}
|
||||
}
|
||||
+168
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user