feat: add redacted tenant audit queries

This commit is contained in:
selfrelease
2026-07-18 19:27:29 +08:00
parent 090a7e33ce
commit d0a2f4f923
8 changed files with 190 additions and 5 deletions
@@ -0,0 +1,25 @@
package com.all8ai.aioa.audit.api
import com.all8ai.aioa.audit.application.AuditQueryService
import com.all8ai.aioa.audit.application.RedactedAuditEvent
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/v1/admin/audit-events")
class AuditQueryController(private val currentUserService: CurrentUserService, private val service: AuditQueryService) {
@GetMapping
fun list(
@AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) traceId: String?,
@RequestParam(required = false) action: String?,
@RequestParam(required = false) resourceType: String?,
@RequestParam(defaultValue = "100") @Min(1) @Max(200) limit: Int,
): List<RedactedAuditEvent> = service.list(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), traceId, action, resourceType, limit,
)
}
@@ -0,0 +1,44 @@
package com.all8ai.aioa.audit.application
import com.all8ai.aioa.audit.domain.AuditQueryRepository
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
@Service
class AuditQueryService(private val repository: AuditQueryRepository) {
fun list(actor: CurrentUser, traceId: String?, action: String?, resourceType: String?, limit: Int): List<RedactedAuditEvent> {
actor.requirePermission(ToolPermission.AUDIT_READ_TENANT_REDACTED)
if (limit !in 1..200) throw ApiException(HttpStatus.BAD_REQUEST, "AUDIT_LIMIT_INVALID", "查询数量必须为 1 到 200")
val normalizedTrace = normalize(traceId, 128, "AUDIT_TRACE_ID_INVALID")
val normalizedAction = normalize(action, 120, "AUDIT_ACTION_INVALID")
val normalizedType = normalize(resourceType, 120, "AUDIT_RESOURCE_TYPE_INVALID")
return repository.list(actor.tenantId, normalizedTrace, normalizedAction, normalizedType, limit).map { event ->
RedactedAuditEvent(event.id, event.actorId, event.action, event.resourceType, event.resourceId, event.traceId,
event.result, event.occurredAt, event.details.filterKeys(SAFE_DETAIL_KEYS::contains))
}
}
private fun normalize(value: String?, max: Int, code: String): String? {
if (value == null) return null
val normalized = value.trim()
if (normalized.isEmpty() || normalized.length > max || !normalized.matches(Regex("[A-Za-z0-9._:-]+"))) {
throw ApiException(HttpStatus.BAD_REQUEST, code, "审计查询条件无效")
}
return normalized
}
companion object {
private val SAFE_DETAIL_KEYS = setOf("model", "promptLength", "clarificationCount", "requestId", "taskId", "decision", "processEnded", "fromStatus", "toStatus", "version", "leaveRequestId", "sizeBytes", "contentType", "platform")
}
}
data class RedactedAuditEvent(
val id: UUID, val actorId: UUID, val action: String, val resourceType: String, val resourceId: String?,
val traceId: String, val result: String, val occurredAt: Instant, val details: Map<String, Any?>,
)
@@ -1,6 +1,7 @@
package com.all8ai.aioa.audit.domain
import java.util.UUID
import java.time.Instant
data class AuditEvent(
val id: UUID,
@@ -18,3 +19,26 @@ data class AuditEvent(
fun interface AuditEventRepository {
fun append(event: AuditEvent)
}
data class StoredAuditEvent(
val id: UUID,
val tenantId: UUID,
val actorId: UUID,
val action: String,
val resourceType: String,
val resourceId: String?,
val traceId: String,
val result: String,
val occurredAt: Instant,
val details: Map<String, Any?>,
)
interface AuditQueryRepository {
fun list(
tenantId: UUID,
traceId: String?,
action: String?,
resourceType: String?,
limit: Int,
): List<StoredAuditEvent>
}
@@ -2,15 +2,20 @@ package com.all8ai.aioa.audit.infrastructure
import com.all8ai.aioa.audit.domain.AuditEvent
import com.all8ai.aioa.audit.domain.AuditEventRepository
import com.all8ai.aioa.audit.domain.AuditQueryRepository
import com.all8ai.aioa.audit.domain.StoredAuditEvent
import com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
import org.jooq.impl.DSL
@Repository
class JooqAuditEventRepository(
private val dsl: DSLContext,
private val objectMapper: ObjectMapper,
) : AuditEventRepository {
) : AuditEventRepository, AuditQueryRepository {
override fun append(event: AuditEvent) {
dsl.execute(
"""
@@ -31,4 +36,28 @@ class JooqAuditEventRepository(
objectMapper.writeValueAsString(event.details),
)
}
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int): List<StoredAuditEvent> {
val table = DSL.table(DSL.name("audit", "event"))
var condition = DSL.field(DSL.name("tenant_id"), UUID::class.java).eq(tenantId)
traceId?.let { condition = condition.and(DSL.field(DSL.name("trace_id"), String::class.java).eq(it)) }
action?.let { condition = condition.and(DSL.field(DSL.name("action"), String::class.java).eq(it)) }
resourceType?.let { condition = condition.and(DSL.field(DSL.name("resource_type"), String::class.java).eq(it)) }
return dsl.select().from(table).where(condition)
.orderBy(DSL.field(DSL.name("occurred_at")).desc()).limit(limit).fetch().map { record ->
@Suppress("UNCHECKED_CAST")
StoredAuditEvent(
record.get("id", UUID::class.java)!!,
record.get("tenant_id", UUID::class.java)!!,
record.get("actor_id", UUID::class.java)!!,
record.get("action", String::class.java)!!,
record.get("resource_type", String::class.java)!!,
record.get("resource_id", String::class.java),
record.get("trace_id", String::class.java)!!,
record.get("result", String::class.java)!!,
record.get("occurred_at", OffsetDateTime::class.java)!!.toInstant(),
objectMapper.readValue(record.get("details")!!.toString(), Map::class.java) as Map<String, Any?>,
)
}
}
}