feat: expose admin audit pages

This commit is contained in:
youfak 2026-08-02 15:29:15 +08:00
parent e220d5368c
commit ec3ceb2c9a
10 changed files with 423 additions and 15 deletions

View File

@ -111,7 +111,7 @@ flowchart LR
- **已完成**严格配置、Provider 获取与协调、Redis 活动池、Distribution 原子 - **已完成**严格配置、Provider 获取与协调、Redis 活动池、Distribution 原子
提取与限流、Controller 的 Admin/Distribution/Metrics 监听,以及 PostgreSQL 提取与限流、Controller 的 Admin/Distribution/Metrics 监听,以及 PostgreSQL
管理状态WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和 管理状态与有界审计查询WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和
Redis 会话栅栏,以及 Gateway Outcome 上报的有界队列、序列确认与重试; Redis 会话栅栏,以及 Gateway Outcome 上报的有界队列、序列确认与重试;
Controller 的 Redis 共享 BASIC/EGRESS/TARGET 检查任务、按上游的有界轮转调度、HTTP/HTTPS/SOCKS5 Controller 的 Redis 共享 BASIC/EGRESS/TARGET 检查任务、按上游的有界轮转调度、HTTP/HTTPS/SOCKS5
Checker 探测和 Checker 探测和

View File

@ -7,6 +7,7 @@ servers:
- url: http://127.0.0.1:8082 - url: http://127.0.0.1:8082
tags: tags:
- name: Status - name: Status
- name: Audit
- name: Upstreams - name: Upstreams
- name: Routing - name: Routing
- name: Configuration - name: Configuration
@ -36,6 +37,42 @@ paths:
'405': {$ref: '#/components/responses/MethodNotAllowed'} '405': {$ref: '#/components/responses/MethodNotAllowed'}
'500': {$ref: '#/components/responses/InternalServerError'} '500': {$ref: '#/components/responses/InternalServerError'}
'503': {$ref: '#/components/responses/ServiceUnavailable'} '503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/v1/audit:
get:
tags: [Audit]
operationId: listAuditRecords
summary: 按 ID 游标读取管理面审计记录
description: |
仅返回权威管理面的变更审计记录,按 `id` 升序排列。`afterId` 是排他游标:
后续页面只包含 `id` 大于该值的记录。未传 `limit` 时返回 100 条,单页最多
1000 条。
审计接口不返回 Proxy、Upstream URL、凭据或活动池内容。
parameters:
- name: afterId
in: query
required: false
description: 排他游标;仅返回 ID 大于此值的记录。
schema: {type: integer, minimum: 0, default: 0}
- name: limit
in: query
required: false
description: 单页记录数;默认 100最大 1000。
schema: {type: integer, minimum: 1, maximum: 1000, default: 100}
responses:
'200':
description: 管理面审计记录页
headers:
X-Request-ID: {$ref: '#/components/headers/RequestID'}
content:
application/json:
schema: {$ref: '#/components/schemas/AuditPage'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'400': {$ref: '#/components/responses/BadRequest'}
'405': {$ref: '#/components/responses/MethodNotAllowed'}
'500': {$ref: '#/components/responses/InternalServerError'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/v1/upstreams/{name}/enable: /api/v1/upstreams/{name}/enable:
post: post:
tags: [Upstreams] tags: [Upstreams]
@ -152,6 +189,31 @@ components:
description: 服务端最终使用的请求标识。 description: 服务端最终使用的请求标识。
schema: {type: string, maxLength: 128} schema: {type: string, maxLength: 128}
schemas: schemas:
AuditPage:
type: object
additionalProperties: false
required: [records]
properties:
records:
type: array
description: 按 ID 升序的审计记录;没有更多记录时为空数组。
items: {$ref: '#/components/schemas/AuditRecord'}
AuditRecord:
type: object
additionalProperties: false
required: [id, requestId, actorId, action, resourceType, resourceName, changed, version, occurredAt]
properties:
id: {type: integer, minimum: 0}
requestId: {type: string}
actorId: {type: string}
sourceIp: {type: string}
action: {type: string}
resourceType: {type: string}
resourceName: {type: string}
changed: {type: boolean}
version: {type: integer, minimum: 0}
reason: {type: string}
occurredAt: {type: string, format: date-time}
Status: Status:
type: object type: object
additionalProperties: false additionalProperties: false

View File

@ -10,6 +10,7 @@ import (
type document struct { type document struct {
OpenAPI string `yaml:"openapi"` OpenAPI string `yaml:"openapi"`
Paths map[string]map[string]any `yaml:"paths"` Paths map[string]map[string]any `yaml:"paths"`
Components map[string]any `yaml:"components"`
} }
func TestDistributionContract(t *testing.T) { func TestDistributionContract(t *testing.T) {
@ -37,6 +38,7 @@ func TestAdminContract(t *testing.T) {
spec := readDocument(t, "admin.yaml") spec := readDocument(t, "admin.yaml")
for _, path := range []string{ for _, path := range []string{
"/api/v1/status", "/api/v1/status",
"/api/v1/audit",
"/api/v1/upstreams/{name}/enable", "/api/v1/upstreams/{name}/enable",
"/api/v1/upstreams/{name}/disable", "/api/v1/upstreams/{name}/disable",
"/api/v1/routing/{name}/switch", "/api/v1/routing/{name}/switch",
@ -48,6 +50,67 @@ func TestAdminContract(t *testing.T) {
} }
requireResponses(t, spec.Paths["/api/v1/routing/{name}/switch"]["post"], requireResponses(t, spec.Paths["/api/v1/routing/{name}/switch"]["post"],
"200", "400", "401", "403", "404", "405", "409", "413", "415", "422", "500", "503") "200", "400", "401", "403", "404", "405", "409", "413", "415", "422", "500", "503")
requireResponses(t, spec.Paths["/api/v1/audit"]["get"],
"200", "400", "401", "403", "405", "500", "503")
requireAuditPaginationContract(t, spec.Paths["/api/v1/audit"]["get"])
requireAuditSchemas(t, spec)
}
func requireAuditPaginationContract(t *testing.T, operation any) {
t.Helper()
operationMap, ok := operation.(map[string]any)
if !ok {
t.Fatalf("audit operation has type %T, want map", operation)
}
parameters, ok := operationMap["parameters"].([]any)
if !ok {
t.Fatalf("audit parameters has type %T, want array", operationMap["parameters"])
}
afterID := parameterSchema(t, parameters, "afterId")
if afterID["type"] != "integer" || afterID["minimum"] != 0 || afterID["default"] != 0 {
t.Errorf("afterId schema = %#v, want integer with minimum/default 0", afterID)
}
limit := parameterSchema(t, parameters, "limit")
if limit["type"] != "integer" || limit["minimum"] != 1 || limit["maximum"] != 1000 || limit["default"] != 100 {
t.Errorf("limit schema = %#v, want integer [1, 1000] with default 100", limit)
}
}
func parameterSchema(t *testing.T, parameters []any, name string) map[string]any {
t.Helper()
for _, raw := range parameters {
parameter, ok := raw.(map[string]any)
if !ok || parameter["name"] != name || parameter["in"] != "query" {
continue
}
schema, ok := parameter["schema"].(map[string]any)
if !ok {
t.Fatalf("%s parameter schema has type %T, want map", name, parameter["schema"])
}
return schema
}
t.Fatalf("query parameter %q is missing", name)
return nil
}
func requireAuditSchemas(t *testing.T, spec document) {
t.Helper()
schemas, ok := spec.Components["schemas"].(map[string]any)
if !ok {
t.Fatal("components.schemas is missing")
}
for _, name := range []string{"AuditPage", "AuditRecord"} {
if _, ok := schemas[name].(map[string]any); !ok {
t.Errorf("schema %s is missing", name)
}
}
record, _ := schemas["AuditRecord"].(map[string]any)
properties, _ := record["properties"].(map[string]any)
for _, forbidden := range []string{"proxy", "upstreamUrl", "credential", "activePool"} {
if _, exists := properties[forbidden]; exists {
t.Errorf("AuditRecord must not expose %q", forbidden)
}
}
} }
func requireResponses(t *testing.T, operation any, codes ...string) { func requireResponses(t *testing.T, operation any, codes ...string) {

View File

@ -6,6 +6,7 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
## 端点 ## 端点
- `GET /api/v1/status`:返回配置/快照版本、Upstream 聚合计数和 Worker 状态。 - `GET /api/v1/status`:返回配置/快照版本、Upstream 聚合计数和 Worker 状态。
- `GET /api/v1/audit`:按审计记录 ID 升序读取权威管理面的变更记录。
- `POST /api/v1/upstreams/{name}/enable`:启用 Upstream。 - `POST /api/v1/upstreams/{name}/enable`:启用 Upstream。
- `POST /api/v1/upstreams/{name}/disable`:停止新 Fetch/分配并自然 Drain。 - `POST /api/v1/upstreams/{name}/disable`:停止新 Fetch/分配并自然 Drain。
- `POST /api/v1/routing/{name}/switch`:用 expectedCurrent 做 CAS 手工切换。 - `POST /api/v1/routing/{name}/switch`:用 expectedCurrent 做 CAS 手工切换。
@ -17,6 +18,19 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
配置重载校验失败返回 422旧配置继续运行。Status 只返回低基数聚合信息, 配置重载校验失败返回 422旧配置继续运行。Status 只返回低基数聚合信息,
不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。 不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。
## 审计分页与数据范围
`GET /api/v1/audit` 使用 `afterId` 排他游标分页:响应只包含 `id` 大于
`afterId` 的记录,并按 `id` 升序排列;未传时 `afterId``0`。未传 `limit`
时服务端使用 `100`
`limit` 必须为 `1..1000`,其中 `1000` 等于 `adminstate.MaxPageSize`。客户端应将
本页最后一条记录的 `id` 作为下一次请求的 `afterId`;空 `records` 表示当前游标后
没有记录。
该接口仅暴露管理面变更记录及操作者标识、可信来源 IP、操作类型、资源标识、变更
状态、控制面版本、原因和发生时间。它不返回 Proxy、任何 Upstream/Provider URL、
凭据或 Secret、Provider 响应载荷,也不读取或暴露 Redis 活动池及其提取状态。
## 运行时实现边界 ## 运行时实现边界
`admin.Handler` 只依赖 `Service` 控制面接口,不直接操作数据库、路由游标或配置 `admin.Handler` 只依赖 `Service` 控制面接口,不直接操作数据库、路由游标或配置

View File

@ -16,7 +16,8 @@
- Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、 - Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。 Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
- Admin OpenAPI状态、Upstream 启停、Routing 切换和配置重载。 - Admin OpenAPI状态、按 ID 游标分页的权威审计查询、Upstream 启停、Routing
切换和配置重载。
- 两份 OpenAPI 已进入 Go/CI 结构门禁覆盖本地引用闭合、operationId、响应和 - 两份 OpenAPI 已进入 Go/CI 结构门禁覆盖本地引用闭合、operationId、响应和
security scheme完整标准工具验证仍待补齐。 security scheme完整标准工具验证仍待补齐。
- ProtobufWorker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/ - ProtobufWorker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
@ -44,8 +45,9 @@
- `PROVIDER-*`Provider HTTP Client、严格响应上限、模板解析安全边界、凭据 - `PROVIDER-*`Provider HTTP Client、严格响应上限、模板解析安全边界、凭据
引用 Store 与 Reconciler Adapter 已实现。 引用 Store 与 Reconciler Adapter 已实现。
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin - `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
Handler 已实现共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入, Handler 已实现Admin 审计查询以有界 `afterId` 游标读取 PostgreSQL 权威记录,
Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。 不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID 与本地准入
保护链已接入Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
- `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、 - `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、
原子独占提取、短期幂等、Worker ownership、库存和有界过期清理Memory/Redis 原子独占提取、短期幂等、Worker ownership、库存和有界过期清理Memory/Redis
运行同一公用契约。 运行同一公用契约。
@ -123,7 +125,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis 7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET 原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET
生产任务调度与 REMOVE 编排仍待实现。 生产任务调度与 REMOVE 编排仍待实现。
8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。 8. Admin/Distribution 细粒度授权Distribution 分布式限流和 Admin 审计查询已完成。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。 9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
10. 将 reject/wait/direct 接入 Distribution 运行链。 10. 将 reject/wait/direct 接入 Distribution 运行链。
## 4. 容量结论 ## 4. 容量结论

View File

@ -5,18 +5,23 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"proxy-pool/internal/domain/adminstate"
"proxy-pool/internal/platform/httpapi" "proxy-pool/internal/platform/httpapi"
"proxy-pool/internal/platform/httpsecurity" "proxy-pool/internal/platform/httpsecurity"
) )
const ( const (
statusPath = "/api/v1/status" statusPath = "/api/v1/status"
auditPath = "/api/v1/audit"
reloadPath = "/api/v1/config/reload" reloadPath = "/api/v1/config/reload"
upstreamPrefix = "/api/v1/upstreams/" upstreamPrefix = "/api/v1/upstreams/"
routingPrefix = "/api/v1/routing/" routingPrefix = "/api/v1/routing/"
maxResourceNameBytes = 128 maxResourceNameBytes = 128
defaultAuditPageSize = 100
) )
var ( var (
@ -29,6 +34,7 @@ var (
type Service interface { type Service interface {
Status(context.Context) (Status, error) Status(context.Context) (Status, error)
ReadAudit(context.Context, AuditQuery) (AuditPage, error)
SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error) SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error)
SwitchRouting(context.Context, SwitchCommand) (MutationResult, error) SwitchRouting(context.Context, SwitchCommand) (MutationResult, error)
ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error) ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error)
@ -78,6 +84,36 @@ type MutationResult struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
type AuditQuery struct {
AfterID uint64
Limit int
}
func (query AuditQuery) Validate() error {
if query.Limit <= 0 || query.Limit > adminstate.MaxPageSize {
return ErrInvalidConfiguration
}
return nil
}
type AuditRecord struct {
ID uint64 `json:"id"`
RequestID string `json:"requestId"`
ActorID string `json:"actorId"`
SourceIP string `json:"sourceIp,omitempty"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceName string `json:"resourceName"`
Changed bool `json:"changed"`
Version uint64 `json:"version"`
Reason string `json:"reason,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
}
type AuditPage struct {
Records []AuditRecord `json:"records"`
}
type SetUpstreamCommand struct { type SetUpstreamCommand struct {
RequestID string RequestID string
ActorID string ActorID string
@ -136,6 +172,12 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
} }
handler.getStatus(writer, request, requestID) handler.getStatus(writer, request, requestID)
return return
case auditPath:
if !requireMethod(writer, request, http.MethodGet, requestID) {
return
}
handler.readAudit(writer, request, requestID)
return
case reloadPath: case reloadPath:
if !requireMethod(writer, request, http.MethodPost, requestID) { if !requireMethod(writer, request, http.MethodPost, requestID) {
return return
@ -178,6 +220,57 @@ func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Requ
_ = httpapi.WriteJSON(writer, http.StatusOK, status) _ = httpapi.WriteJSON(writer, http.StatusOK, status)
} }
func (handler *Handler) readAudit(writer http.ResponseWriter, request *http.Request, requestID string) {
query, err := parseAuditQuery(request)
if err != nil {
writeTransportProblem(writer, http.StatusBadRequest, "INVALID_AUDIT_QUERY", "Invalid audit query", "audit pagination fields violate the API contract", requestID)
return
}
page, err := handler.service.ReadAudit(request.Context(), query)
if err != nil {
writeServiceProblem(writer, err, requestID)
return
}
if page.Records == nil {
page.Records = []AuditRecord{}
}
writer.Header().Set(httpapi.HeaderRequestID, requestID)
_ = httpapi.WriteJSON(writer, http.StatusOK, page)
}
func parseAuditQuery(request *http.Request) (AuditQuery, error) {
if request == nil || request.URL == nil {
return AuditQuery{}, ErrInvalidConfiguration
}
values := request.URL.Query()
query := AuditQuery{Limit: defaultAuditPageSize}
for name, value := range values {
if len(value) != 1 {
return AuditQuery{}, ErrInvalidConfiguration
}
switch name {
case "afterId":
parsed, err := strconv.ParseUint(value[0], 10, 64)
if err != nil {
return AuditQuery{}, ErrInvalidConfiguration
}
query.AfterID = parsed
case "limit":
parsed, err := strconv.ParseUint(value[0], 10, 32)
if err != nil || parsed == 0 || parsed > adminstate.MaxPageSize {
return AuditQuery{}, ErrInvalidConfiguration
}
query.Limit = int(parsed)
default:
return AuditQuery{}, ErrInvalidConfiguration
}
}
if err := query.Validate(); err != nil {
return AuditQuery{}, err
}
return query, nil
}
func (handler *Handler) setUpstreamEnabled( func (handler *Handler) setUpstreamEnabled(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,

View File

@ -8,6 +8,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"proxy-pool/internal/platform/httpapi" "proxy-pool/internal/platform/httpapi"
"proxy-pool/internal/platform/httpsecurity" "proxy-pool/internal/platform/httpsecurity"
@ -38,6 +39,80 @@ func TestHandlerReturnsStatusWithoutSensitiveDetails(t *testing.T) {
} }
} }
func TestHandlerReadsBoundedAuditPage(t *testing.T) {
t.Parallel()
occurredAt := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
service := &stubService{audit: AuditPage{Records: []AuditRecord{{
ID: 8, RequestID: "req-8", ActorID: "admin:alice", Action: "switch_routing",
ResourceType: "routing", ResourceName: "checkout", Changed: true, Version: 12,
Reason: "capacity", OccurredAt: occurredAt,
}}}}
handler := mustHandler(t, service)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/audit?afterId=7&limit=2", nil)
request.Header.Set(httpapi.HeaderRequestID, "req-audit-page")
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
if service.auditCalls != 1 || service.lastAudit != (AuditQuery{AfterID: 7, Limit: 2}) {
t.Fatalf("ReadAudit() calls=%d query=%+v", service.auditCalls, service.lastAudit)
}
if requestID := recorder.Header().Get(httpapi.HeaderRequestID); requestID != "req-audit-page" {
t.Fatalf("response request ID = %q, want req-audit-page", requestID)
}
var response AuditPage
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.Records) != 1 || response.Records[0].ID != 8 || !response.Records[0].OccurredAt.Equal(occurredAt) {
t.Fatalf("unexpected audit response: %+v", response)
}
}
func TestHandlerUsesDefaultAuditLimitAndRejectsInvalidAuditQueries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantStatus int
wantQuery AuditQuery
}{
{name: "default", path: "/api/v1/audit", wantStatus: http.StatusOK, wantQuery: AuditQuery{Limit: defaultAuditPageSize}},
{name: "unknown field", path: "/api/v1/audit?beforeId=1", wantStatus: http.StatusBadRequest},
{name: "duplicate field", path: "/api/v1/audit?limit=1&limit=2", wantStatus: http.StatusBadRequest},
{name: "zero limit", path: "/api/v1/audit?limit=0", wantStatus: http.StatusBadRequest},
{name: "oversized limit", path: "/api/v1/audit?limit=1001", wantStatus: http.StatusBadRequest},
{name: "invalid cursor", path: "/api/v1/audit?afterId=nope", wantStatus: http.StatusBadRequest},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
service := &stubService{}
handler := mustHandler(t, service)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
if recorder.Code != test.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.wantStatus, recorder.Body.String())
}
if test.wantStatus == http.StatusOK {
if service.auditCalls != 1 || service.lastAudit != test.wantQuery {
t.Fatalf("ReadAudit() calls=%d query=%+v, want %+v", service.auditCalls, service.lastAudit, test.wantQuery)
}
return
}
if service.auditCalls != 0 || !strings.Contains(recorder.Body.String(), `"code":"INVALID_AUDIT_QUERY"`) {
t.Fatalf("invalid audit query reached service or returned wrong problem: calls=%d body=%s", service.auditCalls, recorder.Body.String())
}
})
}
}
func TestNewHandlerRejectsMissingDependenciesAndInvalidLimit(t *testing.T) { func TestNewHandlerRejectsMissingDependenciesAndInvalidLimit(t *testing.T) {
t.Parallel() t.Parallel()
if _, err := NewHandler(nil, allowAuthorizer{}, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) { if _, err := NewHandler(nil, allowAuthorizer{}, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) {
@ -232,6 +307,7 @@ func TestHandlerRejectsInvalidTransportRequests(t *testing.T) {
wantStatus int wantStatus int
}{ }{
{name: "method", method: http.MethodPut, path: "/api/v1/config/reload", wantStatus: http.StatusMethodNotAllowed}, {name: "method", method: http.MethodPut, path: "/api/v1/config/reload", wantStatus: http.StatusMethodNotAllowed},
{name: "audit method", method: http.MethodPost, path: "/api/v1/audit", wantStatus: http.StatusMethodNotAllowed},
{name: "unknown route", method: http.MethodGet, path: "/missing", wantStatus: http.StatusNotFound}, {name: "unknown route", method: http.MethodGet, path: "/missing", wantStatus: http.StatusNotFound},
{name: "invalid name", method: http.MethodPost, path: "/api/v1/upstreams//enable", wantStatus: http.StatusNotFound}, {name: "invalid name", method: http.MethodPost, path: "/api/v1/upstreams//enable", wantStatus: http.StatusNotFound},
{name: "unknown JSON field", method: http.MethodPost, path: "/api/v1/routing/r/switch", body: `{"expectedCurrent":"a","target":"b","extra":1}`, content: httpapi.JSONContentType, wantStatus: http.StatusBadRequest}, {name: "unknown JSON field", method: http.MethodPost, path: "/api/v1/routing/r/switch", body: `{"expectedCurrent":"a","target":"b","extra":1}`, content: httpapi.JSONContentType, wantStatus: http.StatusBadRequest},
@ -277,12 +353,15 @@ func mustHandler(t *testing.T, service Service) *Handler {
type stubService struct { type stubService struct {
status Status status Status
audit AuditPage
mutation MutationResult mutation MutationResult
err error err error
lastAudit AuditQuery
lastUpstream SetUpstreamCommand lastUpstream SetUpstreamCommand
lastSwitch SwitchCommand lastSwitch SwitchCommand
lastReload ReloadCommand lastReload ReloadCommand
statusCalls int statusCalls int
auditCalls int
} }
func (service *stubService) Status(context.Context) (Status, error) { func (service *stubService) Status(context.Context) (Status, error) {
@ -290,6 +369,12 @@ func (service *stubService) Status(context.Context) (Status, error) {
return service.status, service.err return service.status, service.err
} }
func (service *stubService) ReadAudit(_ context.Context, query AuditQuery) (AuditPage, error) {
service.auditCalls++
service.lastAudit = query
return service.audit, service.err
}
func (service *stubService) SetUpstreamEnabled(_ context.Context, command SetUpstreamCommand) (MutationResult, error) { func (service *stubService) SetUpstreamEnabled(_ context.Context, command SetUpstreamCommand) (MutationResult, error) {
service.lastUpstream = command service.lastUpstream = command
return service.mutation, service.err return service.mutation, service.err

View File

@ -16,6 +16,7 @@ var ErrInvalidApplicationService = errors.New("invalid admin application service
type StateRepository interface { type StateRepository interface {
adminstate.Mutator adminstate.Mutator
adminstate.SnapshotReader adminstate.SnapshotReader
adminstate.AuditReader
} }
type OperationalStatusReader interface { type OperationalStatusReader interface {
@ -150,6 +151,36 @@ func (service *ApplicationService) SwitchRouting(ctx context.Context, command Sw
return mutationResult(result), mapAdminStateError(err) return mutationResult(result), mapAdminStateError(err)
} }
// ReadAudit returns one bounded, stable page from the authoritative management
// audit log. It deliberately exposes only management-plane records and never
// reads Proxy activity, credentials, or Provider payloads.
func (service *ApplicationService) ReadAudit(ctx context.Context, query AuditQuery) (AuditPage, error) {
if service == nil || nilInterface(service.state) || query.Validate() != nil {
return AuditPage{}, ErrInvalidConfiguration
}
records, err := service.state.ReadAudit(ctx, adminstate.AuditQuery{AfterID: query.AfterID, Limit: query.Limit})
if err != nil {
return AuditPage{}, mapAdminStateError(err)
}
page := AuditPage{Records: make([]AuditRecord, 0, len(records))}
for _, record := range records {
page.Records = append(page.Records, AuditRecord{
ID: record.ID,
RequestID: record.RequestID,
ActorID: record.Actor.ID,
SourceIP: record.Actor.SourceIP,
Action: string(record.Action),
ResourceType: record.ResourceType,
ResourceName: record.ResourceName,
Changed: record.Changed,
Version: record.Revision,
Reason: record.Reason,
OccurredAt: record.OccurredAt.UTC(),
})
}
return page, nil
}
func (service *ApplicationService) Status(ctx context.Context) (Status, error) { func (service *ApplicationService) Status(ctx context.Context) (Status, error) {
snapshot, err := service.state.Snapshot(ctx) snapshot, err := service.state.Snapshot(ctx)
if err != nil { if err != nil {

View File

@ -206,6 +206,42 @@ func TestApplicationServiceMapsRoutingSwitchAndDomainErrors(t *testing.T) {
} }
} }
func TestApplicationServiceReadsAuthoritativeAuditPage(t *testing.T) {
t.Parallel()
occurredAt := time.Date(2026, 8, 2, 10, 0, 0, 0, time.FixedZone("test", 8*60*60))
state := &recordingAdminState{audits: []adminstate.AuditRecord{{
ID: 42, RequestID: "req-switch", Actor: adminstate.Actor{ID: "admin:alice", SourceIP: "192.0.2.10"},
Action: adminstate.ActionSwitchRoute, ResourceType: "routing", ResourceName: "checkout",
Changed: true, Revision: 9, Reason: "capacity", OccurredAt: occurredAt,
}}}
service := mustApplicationService(t, state, applicationTestOptions(time.Now))
page, err := service.ReadAudit(context.Background(), AuditQuery{AfterID: 41, Limit: 2})
if err != nil {
t.Fatalf("ReadAudit() error = %v", err)
}
if state.lastAuditQuery != (adminstate.AuditQuery{AfterID: 41, Limit: 2}) {
t.Fatalf("audit query = %+v", state.lastAuditQuery)
}
if len(page.Records) != 1 {
t.Fatalf("audit records = %+v", page.Records)
}
record := page.Records[0]
if record.ID != 42 || record.Action != string(adminstate.ActionSwitchRoute) || record.Version != 9 ||
record.ActorID != "admin:alice" || record.SourceIP != "192.0.2.10" || !record.OccurredAt.Equal(occurredAt.UTC()) {
t.Fatalf("mapped audit record = %+v", record)
}
state.err = adminstate.ErrUnavailable
if _, err := service.ReadAudit(context.Background(), AuditQuery{Limit: 1}); !errors.Is(err, ErrUnavailable) || !errors.Is(err, adminstate.ErrUnavailable) {
t.Fatalf("ReadAudit() unavailable error = %v", err)
}
if _, err := service.ReadAudit(context.Background(), AuditQuery{}); !errors.Is(err, ErrInvalidConfiguration) {
t.Fatalf("ReadAudit() invalid query error = %v", err)
}
}
func TestApplicationServiceBuildsStatusFromAuthoritativeAndOperationalSnapshots(t *testing.T) { func TestApplicationServiceBuildsStatusFromAuthoritativeAndOperationalSnapshots(t *testing.T) {
t.Parallel() t.Parallel()
state := &recordingAdminState{snapshot: adminstate.Snapshot{ state := &recordingAdminState{snapshot: adminstate.Snapshot{
@ -645,6 +681,8 @@ type recordingAdminState struct {
mutation adminstate.MutationResult mutation adminstate.MutationResult
err error err error
snapshot adminstate.Snapshot snapshot adminstate.Snapshot
audits []adminstate.AuditRecord
lastAuditQuery adminstate.AuditQuery
lastUpstream adminstate.SetUpstreamCommand lastUpstream adminstate.SetUpstreamCommand
lastSwitch adminstate.SwitchRoutingCommand lastSwitch adminstate.SwitchRoutingCommand
lastDisable adminstate.DisableRoutingCommand lastDisable adminstate.DisableRoutingCommand
@ -683,6 +721,10 @@ func (*orderedCommitState) Snapshot(context.Context) (adminstate.Snapshot, error
return adminstate.Snapshot{}, nil return adminstate.Snapshot{}, nil
} }
func (*orderedCommitState) ReadAudit(context.Context, adminstate.AuditQuery) ([]adminstate.AuditRecord, error) {
return nil, nil
}
func (state *recordingAdminState) SetUpstreamEnabled(_ context.Context, command adminstate.SetUpstreamCommand) (adminstate.MutationResult, error) { func (state *recordingAdminState) SetUpstreamEnabled(_ context.Context, command adminstate.SetUpstreamCommand) (adminstate.MutationResult, error) {
state.lastUpstream = command state.lastUpstream = command
return state.mutation, state.err return state.mutation, state.err
@ -710,6 +752,11 @@ func (state *recordingAdminState) Snapshot(context.Context) (adminstate.Snapshot
return state.snapshot, state.err return state.snapshot, state.err
} }
func (state *recordingAdminState) ReadAudit(_ context.Context, query adminstate.AuditQuery) ([]adminstate.AuditRecord, error) {
state.lastAuditQuery = query
return append([]adminstate.AuditRecord(nil), state.audits...), state.err
}
type staticOperationalStatusReader struct { type staticOperationalStatusReader struct {
status OperationalStatus status OperationalStatus
err error err error

View File

@ -57,12 +57,16 @@ func TestRuntimeServesDistributionAndAdminOnIndependentListeners(t *testing.T) {
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", nil, http.StatusUnauthorized) assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", nil, http.StatusUnauthorized)
adminHeaders := http.Header{"Authorization": []string{"Bearer admin-token"}} adminHeaders := http.Header{"Authorization": []string{"Bearer admin-token"}}
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", adminHeaders, http.StatusOK) assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", adminHeaders, http.StatusOK)
assertStatus(t, http.MethodGet, adminURL+"/api/v1/audit", adminHeaders, http.StatusOK)
assertStatus(t, http.MethodGet, adminURL+"/health/live", adminHeaders, http.StatusNotFound) assertStatus(t, http.MethodGet, adminURL+"/health/live", adminHeaders, http.StatusNotFound)
assertStatus(t, http.MethodGet, metricsURL+"/readyz", nil, http.StatusOK) assertStatus(t, http.MethodGet, metricsURL+"/readyz", nil, http.StatusOK)
assertStatus(t, http.MethodGet, metricsURL+"/api/v1/status", nil, http.StatusNotFound) assertStatus(t, http.MethodGet, metricsURL+"/api/v1/status", nil, http.StatusNotFound)
if adminService.statusCalls.Load() != 1 { if adminService.statusCalls.Load() != 1 {
t.Fatalf("admin status calls = %d, want 1", adminService.statusCalls.Load()) t.Fatalf("admin status calls = %d, want 1", adminService.statusCalls.Load())
} }
if adminService.auditCalls.Load() != 1 {
t.Fatalf("admin audit calls = %d, want 1", adminService.auditCalls.Load())
}
cancel() cancel()
select { select {
@ -229,7 +233,9 @@ func (stubReadiness) Ready(context.Context) error { return nil }
type stubAdminService struct { type stubAdminService struct {
status admin.Status status admin.Status
audit admin.AuditPage
statusCalls atomic.Int64 statusCalls atomic.Int64
auditCalls atomic.Int64
} }
func (service *stubAdminService) Status(context.Context) (admin.Status, error) { func (service *stubAdminService) Status(context.Context) (admin.Status, error) {
@ -237,6 +243,11 @@ func (service *stubAdminService) Status(context.Context) (admin.Status, error) {
return service.status, nil return service.status, nil
} }
func (service *stubAdminService) ReadAudit(context.Context, admin.AuditQuery) (admin.AuditPage, error) {
service.auditCalls.Add(1)
return service.audit, nil
}
func (*stubAdminService) SetUpstreamEnabled(context.Context, admin.SetUpstreamCommand) (admin.MutationResult, error) { func (*stubAdminService) SetUpstreamEnabled(context.Context, admin.SetUpstreamCommand) (admin.MutationResult, error) {
return admin.MutationResult{}, nil return admin.MutationResult{}, nil
} }