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 package com.all8ai.aioa.audit.domain
import java.util.UUID import java.util.UUID
import java.time.Instant
data class AuditEvent( data class AuditEvent(
val id: UUID, val id: UUID,
@@ -18,3 +19,26 @@ data class AuditEvent(
fun interface AuditEventRepository { fun interface AuditEventRepository {
fun append(event: AuditEvent) 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.AuditEvent
import com.all8ai.aioa.audit.domain.AuditEventRepository 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 com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext import org.jooq.DSLContext
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
import org.jooq.impl.DSL
@Repository @Repository
class JooqAuditEventRepository( class JooqAuditEventRepository(
private val dsl: DSLContext, private val dsl: DSLContext,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
) : AuditEventRepository { ) : AuditEventRepository, AuditQueryRepository {
override fun append(event: AuditEvent) { override fun append(event: AuditEvent) {
dsl.execute( dsl.execute(
""" """
@@ -31,4 +36,28 @@ class JooqAuditEventRepository(
objectMapper.writeValueAsString(event.details), 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, AI_LEAVE_PROGRESS_READ_OWN,
APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_READ_ASSIGNED,
APPROVAL_TASK_DECIDE_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED,
AUDIT_READ_TENANT_REDACTED,
} }
enum class DataScope { OWN, ASSIGNED } enum class DataScope { OWN, ASSIGNED, TENANT }
data class UserCapabilities( data class UserCapabilities(
val permissions: Set<ToolPermission>, val permissions: Set<ToolPermission>,
@@ -41,12 +42,14 @@ object AuthorizationPolicy {
val permissions = buildSet { val permissions = buildSet {
if ("employee" in user.roles) addAll(employeePermissions) if ("employee" in user.roles) addAll(employeePermissions)
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions) if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
if ("oa_admin" in user.roles) add(ToolPermission.AUDIT_READ_TENANT_REDACTED)
} }
return UserCapabilities( return UserCapabilities(
permissions, permissions,
buildSet { buildSet {
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN) if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED) 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 openapi: 3.1.0
info: info:
title: AIOA API title: AIOA API
version: 0.12.0 version: 0.13.0
servers: servers:
- url: /api/v1 - url: /api/v1
security: security:
- bearerAuth: [] - bearerAuth: []
paths: 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: /devices/register:
post: post:
operationId: registerCurrentDevice operationId: registerCurrentDevice
@@ -631,11 +643,11 @@ components:
uniqueItems: true uniqueItems: true
items: items:
type: string 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: dataScopes:
type: array type: array
uniqueItems: true uniqueItems: true
items: { type: string, enum: [OWN, ASSIGNED] } items: { type: string, enum: [OWN, ASSIGNED, TENANT] }
OrganizationRef: OrganizationRef:
type: object type: object
required: [id, name] required: [id, name]
+8
View File
@@ -48,6 +48,14 @@
- [x] 查询本人流程进度 - [x] 查询本人流程进度
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计 - [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
## M4:管理与运营闭环
- [x] OA 管理员按租户脱敏查询审计记录
- [ ] OA 管理员组织与角色维护 API
- [ ] 流程定义、版本和运行实例只读管理
- [ ] Flutter 管理工具入口与权限驱动展示
- [ ] 业务与推送运行指标仪表板
## Definition of Done ## Definition of Done
每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。 每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。