feat: add redacted tenant audit queries
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
+30
-1
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+40
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -48,6 +48,14 @@
|
||||
- [x] 查询本人流程进度
|
||||
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
|
||||
|
||||
## M4:管理与运营闭环
|
||||
|
||||
- [x] OA 管理员按租户脱敏查询审计记录
|
||||
- [ ] OA 管理员组织与角色维护 API
|
||||
- [ ] 流程定义、版本和运行实例只读管理
|
||||
- [ ] Flutter 管理工具入口与权限驱动展示
|
||||
- [ ] 业务与推送运行指标仪表板
|
||||
|
||||
## Definition of Done
|
||||
|
||||
每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。
|
||||
|
||||
Reference in New Issue
Block a user