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?>,
)
}
}
}
@@ -13,9 +13,10 @@ enum class ToolPermission {
AI_LEAVE_PROGRESS_READ_OWN,
APPROVAL_TASK_READ_ASSIGNED,
APPROVAL_TASK_DECIDE_ASSIGNED,
AUDIT_READ_TENANT_REDACTED,
}
enum class DataScope { OWN, ASSIGNED }
enum class DataScope { OWN, ASSIGNED, TENANT }
data class UserCapabilities(
val permissions: Set<ToolPermission>,
@@ -41,12 +42,14 @@ object AuthorizationPolicy {
val permissions = buildSet {
if ("employee" in user.roles) addAll(employeePermissions)
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
if ("oa_admin" in user.roles) add(ToolPermission.AUDIT_READ_TENANT_REDACTED)
}
return UserCapabilities(
permissions,
buildSet {
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED)
if (ToolPermission.AUDIT_READ_TENANT_REDACTED in permissions) add(DataScope.TENANT)
},
)
}
@@ -0,0 +1,40 @@
package com.all8ai.aioa.audit.application
import com.all8ai.aioa.audit.domain.*
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import java.time.Instant
import java.util.UUID
class AuditQueryServiceTest {
@Test
fun `oa administrator receives tenant audit with sensitive details removed`() {
val actor = user(setOf("employee", "oa_admin"))
val stored = StoredAuditEvent(UUID.randomUUID(), actor.tenantId, UUID.randomUUID(), "LEAVE_REQUEST_APPROVED", "LEAVE_REQUEST", "leave-1", "trace-12345678", "SUCCESS", Instant.now(), mapOf("decision" to "APPROVED", "comment" to "敏感审批意见", "prompt" to "敏感提示"))
val repository = object : AuditQueryRepository {
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int): List<StoredAuditEvent> {
assertThat(tenantId).isEqualTo(actor.tenantId)
return listOf(stored)
}
}
val result = AuditQueryService(repository).list(actor, null, null, null, 100).single()
assertThat(result.details).containsEntry("decision", "APPROVED")
assertThat(result.details).doesNotContainKeys("comment", "prompt")
}
@Test
fun `ordinary employee cannot query tenant audit`() {
val service = AuditQueryService(object : AuditQueryRepository {
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int) = emptyList<StoredAuditEvent>()
})
assertThatThrownBy { service.list(user(setOf("employee")), null, null, null, 100) }
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
}
private fun user(roles: Set<String>) = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles)
}
+15 -3
View File
@@ -1,12 +1,24 @@
openapi: 3.1.0
info:
title: AIOA API
version: 0.12.0
version: 0.13.0
servers:
- url: /api/v1
security:
- bearerAuth: []
paths:
/admin/audit-events:
get:
operationId: listRedactedTenantAuditEvents
summary: OA 管理员按租户脱敏查询审计记录
parameters:
- { name: traceId, in: query, schema: { type: string, maxLength: 128 } }
- { name: action, in: query, schema: { type: string, maxLength: 120 } }
- { name: resourceType, in: query, schema: { type: string, maxLength: 120 } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
responses:
"200": { description: 当前租户的脱敏审计记录 }
"403": { description: 仅 OA 管理员可查询 }
/devices/register:
post:
operationId: registerCurrentDevice
@@ -631,11 +643,11 @@ components:
uniqueItems: true
items:
type: string
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED]
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED]
dataScopes:
type: array
uniqueItems: true
items: { type: string, enum: [OWN, ASSIGNED] }
items: { type: string, enum: [OWN, ASSIGNED, TENANT] }
OrganizationRef:
type: object
required: [id, name]
+8
View File
@@ -48,6 +48,14 @@
- [x] 查询本人流程进度
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
## M4:管理与运营闭环
- [x] OA 管理员按租户脱敏查询审计记录
- [ ] OA 管理员组织与角色维护 API
- [ ] 流程定义、版本和运行实例只读管理
- [ ] Flutter 管理工具入口与权限驱动展示
- [ ] 业务与推送运行指标仪表板
## Definition of Done
每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。