From d0a2f4f92373f9f84a51bff2f8d8705b0c945de4 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 19:27:29 +0800 Subject: [PATCH] feat: add redacted tenant audit queries --- .../aioa/audit/api/AuditQueryController.kt | 25 +++++++++++ .../audit/application/AuditQueryService.kt | 44 +++++++++++++++++++ .../all8ai/aioa/audit/domain/AuditEvent.kt | 24 ++++++++++ .../JooqAuditEventRepository.kt | 31 ++++++++++++- .../shared/security/AuthorizationPolicy.kt | 5 ++- .../application/AuditQueryServiceTest.kt | 40 +++++++++++++++++ contracts/openapi/aioa-v1.yaml | 18 ++++++-- docs/engineering/roadmap.md | 8 ++++ 8 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/audit/api/AuditQueryController.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/audit/application/AuditQueryService.kt create mode 100644 backend/boot/src/test/kotlin/com/all8ai/aioa/audit/application/AuditQueryServiceTest.kt diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/api/AuditQueryController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/api/AuditQueryController.kt new file mode 100644 index 0000000..1598a33 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/api/AuditQueryController.kt @@ -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 = service.list( + currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), traceId, action, resourceType, limit, + ) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/application/AuditQueryService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/application/AuditQueryService.kt new file mode 100644 index 0000000..6c19949 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/application/AuditQueryService.kt @@ -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 { + 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, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/domain/AuditEvent.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/domain/AuditEvent.kt index 4496eab..c42ea19 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/domain/AuditEvent.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/domain/AuditEvent.kt @@ -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, +) + +interface AuditQueryRepository { + fun list( + tenantId: UUID, + traceId: String?, + action: String?, + resourceType: String?, + limit: Int, + ): List +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/infrastructure/JooqAuditEventRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/infrastructure/JooqAuditEventRepository.kt index c3dd4ea..fc2dd05 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/infrastructure/JooqAuditEventRepository.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/infrastructure/JooqAuditEventRepository.kt @@ -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 { + 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, + ) + } + } } diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt index 995beeb..14de569 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt @@ -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, @@ -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) }, ) } diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/audit/application/AuditQueryServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/audit/application/AuditQueryServiceTest.kt new file mode 100644 index 0000000..a1a6b49 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/audit/application/AuditQueryServiceTest.kt @@ -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 { + 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() + }) + 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) = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles) +} diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 8fac1bc..8ac2812 100644 --- a/contracts/openapi/aioa-v1.yaml +++ b/contracts/openapi/aioa-v1.yaml @@ -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] diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index acfb7a3..bdf20da 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -48,6 +48,14 @@ - [x] 查询本人流程进度 - [x] 确认卡片、Kotlin 代理鉴权和 AI 审计 +## M4:管理与运营闭环 + +- [x] OA 管理员按租户脱敏查询审计记录 +- [ ] OA 管理员组织与角色维护 API +- [ ] 流程定义、版本和运行实例只读管理 +- [ ] Flutter 管理工具入口与权限驱动展示 +- [ ] 业务与推送运行指标仪表板 + ## Definition of Done 每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。