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