diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditDto.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditDto.java new file mode 100644 index 0000000..ae26c4f --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditDto.java @@ -0,0 +1,38 @@ +package net.siegeln.cameleer.saas.audit; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public final class AuditDto { + + private AuditDto() {} + + public record AuditLogEntry( + UUID id, + String actorEmail, + UUID tenantId, + String action, + String resource, + String environment, + String result, + String sourceIp, + Instant createdAt + ) { + public static AuditLogEntry from(AuditEntity e) { + return new AuditLogEntry( + e.getId(), e.getActorEmail(), e.getTenantId(), + e.getAction(), e.getResource(), e.getEnvironment(), + e.getResult(), e.getSourceIp(), e.getCreatedAt() + ); + } + } + + public record AuditLogPage( + List content, + int page, + int size, + long totalElements, + int totalPages + ) {} +} diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java index 1764798..b60c6b6 100644 --- a/src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java @@ -1,6 +1,10 @@ package net.siegeln.cameleer.saas.audit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.Instant; @@ -13,4 +17,26 @@ public interface AuditRepository extends JpaRepository { List findByTenantIdAndCreatedAtBetween(UUID tenantId, Instant from, Instant to); List findByActorId(UUID actorId); + + @Query(""" + SELECT a FROM AuditEntity a + WHERE (:tenantId IS NULL OR a.tenantId = :tenantId) + AND (:action IS NULL OR a.action = :action) + AND (:result IS NULL OR a.result = :result) + AND (:from IS NULL OR a.createdAt >= :from) + AND (:to IS NULL OR a.createdAt <= :to) + AND (:search IS NULL + OR LOWER(a.actorEmail) LIKE LOWER(CONCAT('%', :search, '%')) + OR LOWER(a.resource) LIKE LOWER(CONCAT('%', :search, '%'))) + ORDER BY a.createdAt DESC + """) + Page findFiltered( + @Param("tenantId") UUID tenantId, + @Param("action") String action, + @Param("result") String result, + @Param("from") Instant from, + @Param("to") Instant to, + @Param("search") String search, + Pageable pageable + ); } diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java index 6d6ea90..6d13e09 100644 --- a/src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java @@ -1,7 +1,10 @@ package net.siegeln.cameleer.saas.audit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.Map; import java.util.UUID; @@ -30,4 +33,10 @@ public class AuditService { entry.setMetadata(metadata); auditRepository.save(entry); } + + public Page search(UUID tenantId, String action, String result, + Instant from, Instant to, String search, + Pageable pageable) { + return auditRepository.findFiltered(tenantId, action, result, from, to, search, pageable); + } } diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantAuditController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantAuditController.java new file mode 100644 index 0000000..e8e409e --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantAuditController.java @@ -0,0 +1,50 @@ +package net.siegeln.cameleer.saas.portal; + +import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogEntry; +import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogPage; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.config.TenantContext; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.UUID; + +@RestController +@RequestMapping("/api/tenant/audit") +public class TenantAuditController { + + private final AuditService auditService; + + public TenantAuditController(AuditService auditService) { + this.auditService = auditService; + } + + @GetMapping + public ResponseEntity list( + @RequestParam(required = false) String action, + @RequestParam(required = false) String result, + @RequestParam(required = false) String search, + @RequestParam(required = false) Instant from, + @RequestParam(required = false) Instant to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size) { + + UUID tenantId = TenantContext.getTenantId(); + size = Math.min(size, 100); + var pageResult = auditService.search(tenantId, action, result, from, to, search, + PageRequest.of(page, size)); + + var entries = pageResult.getContent().stream() + .map(AuditLogEntry::from) + .toList(); + + return ResponseEntity.ok(new AuditLogPage( + entries, pageResult.getNumber(), pageResult.getSize(), + pageResult.getTotalElements(), pageResult.getTotalPages())); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuditController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuditController.java new file mode 100644 index 0000000..7725894 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuditController.java @@ -0,0 +1,51 @@ +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogEntry; +import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogPage; +import net.siegeln.cameleer.saas.audit.AuditService; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.UUID; + +@RestController +@RequestMapping("/api/vendor/audit") +@PreAuthorize("hasAuthority('SCOPE_platform:admin')") +public class VendorAuditController { + + private final AuditService auditService; + + public VendorAuditController(AuditService auditService) { + this.auditService = auditService; + } + + @GetMapping + public ResponseEntity list( + @RequestParam(required = false) UUID tenantId, + @RequestParam(required = false) String action, + @RequestParam(required = false) String result, + @RequestParam(required = false) String search, + @RequestParam(required = false) Instant from, + @RequestParam(required = false) Instant to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size) { + + size = Math.min(size, 100); + var pageResult = auditService.search(tenantId, action, result, from, to, search, + PageRequest.of(page, size)); + + var entries = pageResult.getContent().stream() + .map(AuditLogEntry::from) + .toList(); + + return ResponseEntity.ok(new AuditLogPage( + entries, pageResult.getNumber(), pageResult.getSize(), + pageResult.getTotalElements(), pageResult.getTotalPages())); + } +} diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts index 67c275e..d6d2e93 100644 --- a/ui/src/api/tenant-hooks.ts +++ b/ui/src/api/tenant-hooks.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; -import type { DashboardData, TenantLicenseData, TenantSettings } from '../types/api'; +import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters } from '../types/api'; export function useTenantDashboard() { return useQuery({ @@ -61,3 +61,19 @@ export function useTenantSettings() { queryFn: () => api.get('/tenant/settings'), }); } + +export function useTenantAuditLog(filters: Omit) { + const params = new URLSearchParams(); + if (filters.action) params.set('action', filters.action); + if (filters.result) params.set('result', filters.result); + if (filters.search) params.set('search', filters.search); + if (filters.from) params.set('from', filters.from); + if (filters.to) params.set('to', filters.to); + params.set('page', String(filters.page ?? 0)); + params.set('size', String(filters.size ?? 25)); + + return useQuery({ + queryKey: ['tenant', 'audit', filters], + queryFn: () => api.get(`/tenant/audit?${params.toString()}`), + }); +} diff --git a/ui/src/api/vendor-hooks.ts b/ui/src/api/vendor-hooks.ts index 969e1e4..7ae09a7 100644 --- a/ui/src/api/vendor-hooks.ts +++ b/ui/src/api/vendor-hooks.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; -import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse } from '../types/api'; +import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse, AuditLogPage, AuditLogFilters } from '../types/api'; export function useVendorTenants() { return useQuery({ @@ -57,3 +57,20 @@ export function useRenewLicense() { onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }), }); } + +export function useVendorAuditLog(filters: AuditLogFilters) { + const params = new URLSearchParams(); + if (filters.tenantId) params.set('tenantId', filters.tenantId); + if (filters.action) params.set('action', filters.action); + if (filters.result) params.set('result', filters.result); + if (filters.search) params.set('search', filters.search); + if (filters.from) params.set('from', filters.from); + if (filters.to) params.set('to', filters.to); + params.set('page', String(filters.page ?? 0)); + params.set('size', String(filters.size ?? 25)); + + return useQuery({ + queryKey: ['vendor', 'audit', filters], + queryFn: () => api.get(`/vendor/audit?${params.toString()}`), + }); +} diff --git a/ui/src/components/AuditLogTable.tsx b/ui/src/components/AuditLogTable.tsx new file mode 100644 index 0000000..42b1b3d --- /dev/null +++ b/ui/src/components/AuditLogTable.tsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import { Badge, Button, DataTable, EmptyState, Input, Spinner } from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { ScrollText, ChevronLeft, ChevronRight } from 'lucide-react'; +import type { AuditLogEntry, AuditLogPage, AuditLogFilters, VendorTenantSummary } from '../types/api'; + +const AUDIT_ACTIONS = [ + 'AUTH_REGISTER', 'AUTH_LOGIN', 'AUTH_LOGIN_FAILED', 'AUTH_LOGOUT', + 'TENANT_CREATE', 'TENANT_UPDATE', 'TENANT_SUSPEND', 'TENANT_REACTIVATE', 'TENANT_DELETE', + 'ENVIRONMENT_CREATE', 'ENVIRONMENT_UPDATE', 'ENVIRONMENT_DELETE', + 'APP_CREATE', 'APP_DEPLOY', 'APP_PROMOTE', 'APP_ROLLBACK', 'APP_SCALE', 'APP_STOP', 'APP_DELETE', + 'SECRET_CREATE', 'SECRET_READ', 'SECRET_UPDATE', 'SECRET_DELETE', 'SECRET_ROTATE', + 'CONFIG_UPDATE', + 'TEAM_INVITE', 'TEAM_REMOVE', 'TEAM_ROLE_CHANGE', + 'LICENSE_GENERATE', 'LICENSE_REVOKE', +]; + +function actionColor(action: string): 'success' | 'error' | 'warning' | 'auto' | 'primary' { + if (action.startsWith('AUTH_')) return 'auto'; + if (action.startsWith('TENANT_')) return 'primary'; + if (action.startsWith('TEAM_')) return 'warning'; + if (action.startsWith('LICENSE_')) return 'success'; + return 'auto'; +} + +function formatTime(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); +} + +const selectStyle: React.CSSProperties = { + padding: '6px 10px', + borderRadius: 6, + border: '1px solid var(--border)', + background: 'var(--bg-surface)', + color: 'var(--text-primary)', + fontSize: '0.8125rem', +}; + +interface AuditLogTableProps { + data: AuditLogPage | undefined; + isLoading: boolean; + filters: AuditLogFilters; + onFiltersChange: (filters: AuditLogFilters) => void; + showTenantColumn: boolean; + showTenantFilter: boolean; + tenants?: VendorTenantSummary[]; +} + +export function AuditLogTable({ + data, isLoading, filters, onFiltersChange, + showTenantColumn, showTenantFilter, tenants, +}: AuditLogTableProps) { + + const [searchInput, setSearchInput] = useState(filters.search ?? ''); + + const tenantMap = new Map(); + if (tenants) { + for (const t of tenants) { + tenantMap.set(t.id, t.name); + } + } + + function setFilter(patch: Partial) { + onFiltersChange({ ...filters, page: 0, ...patch }); + } + + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault(); + setFilter({ search: searchInput.trim() || undefined }); + } + + const columns: Column[] = [ + { + key: 'createdAt', + header: 'Time', + render: (_v, row) => ( + + {formatTime(row.createdAt)} + + ), + }, + { + key: 'actorEmail', + header: 'Actor', + render: (_v, row) => ( + + {row.actorEmail ?? '--'} + + ), + }, + ...(showTenantColumn ? [{ + key: 'tenantId' as keyof AuditLogEntry, + header: 'Tenant', + render: (_v: unknown, row: AuditLogEntry) => ( + + {row.tenantId ? (tenantMap.get(row.tenantId) ?? row.tenantId.slice(0, 8)) : '--'} + + ), + }] : []), + { + key: 'action', + header: 'Action', + render: (_v, row) => , + }, + { + key: 'resource', + header: 'Resource', + render: (_v, row) => ( + + {row.resource ?? '--'} + + ), + }, + { + key: 'result', + header: 'Result', + render: (_v, row) => ( + + ), + }, + { + key: 'sourceIp', + header: 'IP', + render: (_v, row) => ( + + {row.sourceIp ?? '--'} + + ), + }, + ]; + + const currentPage = data?.page ?? 0; + const totalPages = data?.totalPages ?? 0; + const totalElements = data?.totalElements ?? 0; + + return ( +
+ {/* Filter bar */} +
+ {showTenantFilter && tenants && ( + + )} + + + + + +
+ setSearchInput(e.target.value)} + style={{ width: 200, fontSize: '0.8125rem' }} + /> + +
+
+ + {/* Table */} + {isLoading && ( +
+ +
+ )} + + {!isLoading && (!data || data.content.length === 0) && ( + } + title="No audit events" + description="No events match the current filters." + /> + )} + + {!isLoading && data && data.content.length > 0 && ( + <> + + + {/* Pagination */} +
+ + {totalElements} event{totalElements !== 1 ? 's' : ''} + {totalPages > 1 && ` \u00b7 Page ${currentPage + 1} of ${totalPages}`} + + {totalPages > 1 && ( +
+ + +
+ )} +
+ + )} +
+ ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 6081f61..0787ded 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -4,7 +4,7 @@ import { Sidebar, TopBar, } from '@cameleer/design-system'; -import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building, Fingerprint } from 'lucide-react'; +import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building, Fingerprint, ScrollText } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; import { useScopes } from '../auth/useScopes'; import { useOrgStore } from '../auth/useOrganization'; @@ -74,6 +74,13 @@ export function Layout() { > Tenants +
navigate('/vendor/audit')} + > + Audit Log +
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} @@ -127,6 +134,16 @@ export function Layout() { {null} + } + label="Audit Log" + open={false} + active={isActive(location, '/tenant/audit')} + onToggle={() => navigate('/tenant/audit')} + > + {null} + + } label="Settings" diff --git a/ui/src/pages/tenant/TenantAuditPage.tsx b/ui/src/pages/tenant/TenantAuditPage.tsx new file mode 100644 index 0000000..6b2ad1a --- /dev/null +++ b/ui/src/pages/tenant/TenantAuditPage.tsx @@ -0,0 +1,23 @@ +import { useState } from 'react'; +import { useTenantAuditLog } from '../../api/tenant-hooks'; +import { AuditLogTable } from '../../components/AuditLogTable'; +import type { AuditLogFilters } from '../../types/api'; + +export function TenantAuditPage() { + const [filters, setFilters] = useState({ page: 0, size: 25 }); + const { data, isLoading } = useTenantAuditLog(filters); + + return ( +
+

Audit Log

+ +
+ ); +} diff --git a/ui/src/pages/vendor/VendorAuditPage.tsx b/ui/src/pages/vendor/VendorAuditPage.tsx new file mode 100644 index 0000000..3ff3689 --- /dev/null +++ b/ui/src/pages/vendor/VendorAuditPage.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { useVendorAuditLog, useVendorTenants } from '../../api/vendor-hooks'; +import { AuditLogTable } from '../../components/AuditLogTable'; +import type { AuditLogFilters } from '../../types/api'; + +export function VendorAuditPage() { + const [filters, setFilters] = useState({ page: 0, size: 25 }); + const { data, isLoading } = useVendorAuditLog(filters); + const { data: tenants } = useVendorTenants(); + + return ( +
+

Audit Log

+ +
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index da6591b..828aa7a 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -11,11 +11,13 @@ import { useOrgStore } from './auth/useOrganization'; import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage'; import { CreateTenantPage } from './pages/vendor/CreateTenantPage'; import { TenantDetailPage } from './pages/vendor/TenantDetailPage'; +import { VendorAuditPage } from './pages/vendor/VendorAuditPage'; import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage'; import { TenantLicensePage } from './pages/tenant/TenantLicensePage'; import { OidcConfigPage } from './pages/tenant/OidcConfigPage'; import { TeamPage } from './pages/tenant/TeamPage'; import { SettingsPage } from './pages/tenant/SettingsPage'; +import { TenantAuditPage } from './pages/tenant/TenantAuditPage'; function LandingRedirect() { const scopes = useScopes(); @@ -66,12 +68,18 @@ export function AppRouter() { } /> + }> + + + } /> {/* Tenant portal */} } /> } /> } /> } /> + } /> } /> {/* Default redirect — vendor goes to /vendor/tenants, customer to /tenant */} diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index ce14202..4341005 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -93,3 +93,35 @@ export interface TenantSettings { serverEndpoint: string | null; createdAt: string; } + +// Audit log types +export interface AuditLogEntry { + id: string; + actorEmail: string | null; + tenantId: string | null; + action: string; + resource: string | null; + environment: string | null; + result: string; + sourceIp: string | null; + createdAt: string; +} + +export interface AuditLogPage { + content: AuditLogEntry[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface AuditLogFilters { + action?: string; + result?: string; + search?: string; + from?: string; + to?: string; + tenantId?: string; + page?: number; + size?: number; +}