Skip to content

feat(ai): add production authentication and usage auditing - #1609

Open
Qiao-yq wants to merge 45 commits into
apache:aifrom
Qiao-yq:feat/ai-1539-production-auth-usage-audit
Open

Qiao-yq wants to merge 45 commits into
apache:aifrom
Qiao-yq:feat/ai-1539-production-auth-usage-audit

Conversation

@Qiao-yq

@Qiao-yq Qiao-yq commented Oct 22, 2026 •

Copy link
Copy Markdown

Please provide a description of this PR:

Draft. This PR must merge after #1594, #1596, the prerequisite snapshot fix #1608, and the Admin token/JWKS work in #1600. Merge order: #1594 → #1596 → #1608, with #1600 merged before this PR. The branch currently includes the latest heads of #1594 (8eaa867), #1596 (90e4127), #1608 (ac732c9), and #1600 (0f4a9c0).

Add the Phase 1 PR4 production authentication and usage/audit foundations for #1539.

  • verify Admin-issued RS256 JWTs against the published JWKS, including issuer, audience, required expiration, issued-at, and not-before claims; propagate trusted identities through HTTP and Store access checks, with cross-user and cross-tenant regression tests
  • persist privacy-safe Usage/Audit metadata in Memory and GORM, including model CallID and tool-call identifiers; record each completed model call once and preserve missing provider usage as unknown
  • persist interaction lifecycle and model/tool completion events without prompt, response, or tool payloads; retain detailed stage and iteration events in tracing
  • preserve identity when recording final events after cancellation, with an independent persistence timeout; apply independent usage/audit rollout flags and consistent version metadata
  • enforce capacity and retention limits, system-only maintenance, cancellable bounded cleanup, and transactional GORM capacity reservations
  • document the authentication and accounting guarantees in an ADR and retain PricingRecord/PricingResolver contracts for later cost accounting

Full pricing, Source attribution, and Context Compiler work remain outside this PR. The Admin-issued token/JWKS integration from #1600 is included. Persistence remains best effort when storage fails or capacity is exhausted. This PR does not declare the Phase 1 G1 gate passed.

While the dependencies are open, GitHub's comparison against ai includes their changes. Review the PR4-only changes here.

Validation from ai/:

  • go test ./... -timeout 120s
  • go test -race ./security ./component/hooks ./component/server/... ./component/agent/react ./component/memory/... ./store/... -timeout 180s
  • go vet ./...
  • git diff --check fix/ai-1539-context-window..HEAD

Coverage includes JWT identity isolation, cancelled-call persistence, unknown usage, call correlation with Usage disabled, independent flags, version metadata, cleanup permissions, capacity concurrency, and existing-table migration. GORM tests use SQLite; production MySQL/PostgreSQL concurrency and migration acceptance remain to be performed.

Part of #1539. Blocked by #1594, #1596, #1600, and #1608.

To help us figure out who should review this PR, please put an X in all the areas that this PR affects.

  • Docs
  • Installation
  • User Experience
  • Dubboctl
  • Console
  • Core Component

Please check any characteristics that apply to this pull request.

  • Includes tests
  • Changes security-sensitive behavior
  • Adds database tables and migration

Qiao-yq and others added 29 commits October 10, 2026 11:36

@AlexStocks AlexStocks left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审查结论

技术实现可用,但存在一个必须先解决的跨 PR 冲突:本 PR 与 #1600 是两套互斥的认证实现,不能同时合并。 单看本 PR,HS256 校验的参数设置是正确的,用量/审计只记元数据不记内容,这部分质量不错。真正的问题在选型,不在代码。

本次审查的基准

  • 相对 ai 的累计 diff:+9138 / −887,107 个文件(含 #1592/#1594/#1596/#1608 四层)
  • 相对上一层的增量:+1792 / −48,28 个文件
  • 与当前 ai(97d2db47)三方合并:0 冲突

先说冲突(P1,合并前置)

同时打开的两个 PR 各自实现了 AI 服务的 HTTP 认证,配置结构互不兼容:

#1609(本 PR) #1600
算法 HS256,共享密钥 RS256 + JWKS
实现位置 ai/security/jwt.go ai/component/server/engine/auth.go + jwks_client.go
YAML authentication: {allow_insecure_static_auth, jwt_secret, jwt_issuer, jwt_audience} auth: {jwks_url, issuer, audience}
签发方 外部 IdP,Admin 不参与 Admin 自己签发 POST /api/v1/auth/token
影响面 仅 ai/ ai/ + pkg/console/auth/* + ui-vue3

两者都要改 ai/component/server/server.yaml 和 ai/schema/json/server.schema.json。Git 层面它们不冲突(我实测 merge-tree 均为 0 冲突),但合并后会出现「两套认证配置块 + 两条身份提取路径」,运维无法判断以哪个为准。请维护者先定选型。从工程角度,#1600 的 Admin 自签发 + JWKS 更贴合「不引入外部 IdP」的现状,且有密钥轮换路径;#1609 的 HS256 静态密钥在多副本部署时需要分发共享密钥,运维成本更高。

已核实的实现细节(本 PR 自身)

JWT 校验参数是完整的。 ai/security/jwt.go:57-62:WithValidMethods([]string{HS256}) + WithIssuer + WithAudience + WithExpirationRequired(),四个要素齐全;:64-67 还额外手工比对 token.Method != jwt.SigningMethodHS256,在 ValidMethods 之后属于冗余但无害的纵深防御。

密钥长度有硬下限。 NewJWTAuthenticator 要求 len(cfg.Secret) >= 32,等于强制 256 bit,和 HS256 的密钥要求一致;secret 拷贝一份存进来,不会因为调用方复用 slice 而被改。

身份不从请求头推导。 只认 Authorization: Bearer,tenant_id/sub 全部来自已验签的 claims,Subject.AuthMethod = AuthMethodJWT。这与 #1592 的「identity 只来自可信 authenticator」是一致的。

审计只记元数据。 ai/component/hooks/usage_audit.go:63 的 AuditEvent 只包含 ID/TenantID/UserID/SessionID/InteractionID/CallID/ToolCallID/EventType/Status/ReasonCode/各版本号/CreatedAt,没有任何模型或工具内容字段 —— 从结构体上而非靠约定来保证不落密文,这点做得对。

缺失的用量不假装有。 usage.AmountSource = UsageAmountUnknown / CostStatus: UsageCostUnknown 在 provider 没返回 usage 时保持 unknown,而不是补 0,对账时不会被误导。

建议(不阻塞)

P2 — 审计在拿不到 AccessContext 时静默丢弃。 usage_audit.go:73-76:

access, err := security.AccessContextFromContext(ctx)
if err != nil || store == nil {
    return ctx
}

store == nil(未配置)跳过是合理的。但 err != nil(ctx 里没有 access)也走同一条静默返回,既不记事件也不打日志。审计/计费子系统的失败模式应该是「显式可见」而不是「安静地少一条记录」;一条丢失的用量记录在月结对账时几乎无法回溯。建议拆开两个分支:store == nil 直接返回,err != nil 至少 logger.Warn("usage audit skipped: no access context", ...),或用 system subject 落一条 status=skipped 的记录。

P3 — 配置错误被映射成 ErrAuthenticationRequired。 jwt.go:37-39:len(cfg.Secret) < 32、Issuer 为空、Audience 为空,全部返回 ErrAuthenticationRequired。这三种都是部署期配置错误,返回 401 语义上等于「客户端没带 token」,会让排查方向完全跑偏(运维看成认证问题,实际是 YAML 写错)。建议单独定义 ErrAuthMisconfigured,或直接 fmt.Errorf 带上具体字段名,让启动期日志一眼能定位。

P3 — 无密钥轮换与 kid 支持。 单一静态 HS256 密钥意味着轮换必须「停服改配置重启」,期间无法新旧密钥并存。如果最终选定 HS256 路线,至少应支持 Secret + SecretPrevious 双密钥验签;如果选 RS256 路线(#1600),这条自动消解。

P3 — tenant_id 回退 tenant 无文档。 jwt.go:80-83 先取 tenant_id,为空再取 tenant。两者同时存在时 tenant_id 优先,这是个隐式约定,签发方一旦同时使用两个 claim 会造成租户归属歧义。建议在 JWTConfig 的注释里写明优先级,或干脆只接受一个 claim 名(配置化)。

P3 — UsageAuditOptions 会被就地改写。 usage_audit.go:56 的 *options = options.withVersions() 修改的是调用方传入的结构体。如果同一个 options 被多个 registration 共享,第一次调用就会把版本号写进去,后续行为依赖调用顺序。建议改为值传递或返回新实例。

合并前置

  1. 必须先与 #1600 完成选型(见上,P1)。
  2. 需等 #1596 → #1608 合入。
  3. 与当前 ai 无 git 冲突。

@larry-zy

Copy link
Copy Markdown

[P2] Preserve unknown usage when the provider omits the usage object

Reviewed head: 8459ec48a4ce74caa03b9a8c2f0aab9ba9a119fe.

The presence check in ai/component/agent/react/hook_lifecycle.go:167–171 does not establish that the provider actually reported token usage:

if response, ok := output.(*ai.ModelResponse); ok && response != nil && response.Usage != nil {
    endState.InputTokens = response.Usage.InputTokens
    endState.OutputTokens = response.Usage.OutputTokens
    endState.TotalTokens = response.Usage.TotalTokens
    endState.UsagePresent = true
}

With the currently pinned Genkit v1.2.0 OpenAI-compatible adapter, a successful completion without a usage object still produces a non-nil GenerationUsage containing zero token counts. This branch marks it as present, and the usage recorder changes AmountSource to provider and stores non-null zero counts. Missing accounting information is therefore indistinguishable from a genuinely reported zero, contrary to this PR's stated contract.

I reproduced this at the reviewed head using a local HTTP provider fixture whose successful response contains choices but no usage. The recorded event has AmountSource=provider and non-null InputTokens=0, OutputTokens=0, and TotalTokens=0; the expected values are AmountSource=unknown and nil token pointers. The existing hook-only tests construct the usage-presence flag directly and do not cover this adapter behavior.

Please preserve explicit usage-field availability at the provider/adapter boundary and propagate it to the accounting hook. Add an HTTP-level regression covering missing/null usage and genuinely reported zero usage; checking whether all token counts are zero would also conflate those cases.

@Qiao-yq
Qiao-yq marked this pull request as ready for review October 25, 2026 13:26
@Qiao-yq

Qiao-yq commented Oct 25, 2026

Copy link
Copy Markdown
Author

Updated in 0f0a069:

  • Integrated feat(ai): add optional JWT authentication with Admin-issued access tokens #1600's RS256/JWKS authentication, removed the HS256 implementation, and resolved conflicts while preserving session ownership isolation and usage/audit recording.
  • Unified authentication configuration, distinguished configuration errors from authentication failures, and added a metadata-only warning when audit identity is missing.
  • Copied audit options without mutating caller configuration, while preserving synchronized runtime feature updates.
  • Fixed provider usage accounting before Genkit normalizes missing usage to zero, including final SSE usage-only chunks; missing/null usage remains unknown and explicit zero remains zero.
  • Preserved frontend idempotency keys and fixed retry context snapshot identity.

Validation: AI module tests, focused race checks, Admin authentication tests, 19 related frontend tests, and the frontend build passed. Frontend type-check errors were reproduced on the pre-change #1609 code.

Dependency: #1600 should merge before #1609. This branch currently includes #1600's commits; after #1600 merges, #1609 should sync with ai and recheck the diff/conflicts.

@larry-zy larry-zy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed head 0f0a069846761effded4dc75878819aad98eed1a, including the integration of #1600 at 0f4a9c0b. The RS256/JWKS implementation is present and JWT identity propagation passes the focused checks. Two additional integration/availability issues are described inline: cancellation of one unverified request can block valid clients through the shared JWKS cooldown, and the actual Router protects the public health endpoint.

Dependency validation before merging

This head still lacks the latest prerequisite fixes in #1594 (fe1f7552) and #1596 (5e7d951c). The following are already reported upstream, so I am recording them as synchronization/validation requirements rather than duplicating their inline findings:

  • Prepare A, fail A, prepare B with a live lease, then retry A: both Memory and GORM/SQLite accept A and permit writes to both active Turns. The same regression passes on the current #1596 head.
  • Complete A, delete its Session, then retry A before asynchronous cleanup: GORM/SQLite returns the retained answer. The same regression is rejected on the current #1596 head. This reproduces a Store-contract violation; it is not a claim of a demonstrated HTTP authorization bypass.
  • The ordinary JSON credential-key fix from #1594 is also absent from this head.
  • The UI retains the collected context object directly. Mutating the page context between an initial failed request and its retry changes the retry payload from version 1 to version 2 under the same key; the same test preserves version 1 on current #1596.

Please synchronize the latest #1594/#1596/#1608 prerequisites and rerun integrated validation before this PR and its descendants merge. This is a statement about the currently tested branch; it does not imply that a correctly resolved merge in the documented order must reintroduce those fixes.

The existing 20 targeted frontend tests pass using a temporary test configuration that disables PostCSS processing to isolate the local Tailwind dependency mismatch. The additional context-mutation regression fails on this head. Database reproductions use SQLite; production MySQL/PostgreSQL behavior was not exercised. The PR description also still describes HS256 and excludes Admin/JWKS integration, so it should be updated to match this implementation.

Comment thread ai/component/server/engine/auth.go Outdated
Comment on lines +231 to +234
err := v.fetchKeys(ctx)
v.mu.Lock()
flight.err = err
v.nextRefresh = v.clock().Add(jwksRefreshInterval)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep request cancellation from starting a shared authentication cooldown

The refresh uses the initiating HTTP request context and then advances nextRefresh by 30 seconds for every error, including that request being canceled; the shared keys have already been cleared. After cache expiry, a parseable, unsigned JWT with an unknown kid can reach this path before authentication succeeds. If that client disconnects during the fetch, subsequent valid-token requests fail for the whole cooldown without trying the healthy JWKS endpoint. I reproduced this with a local endpoint that succeeds at startup, blocks only the canceled refresh, and is healthy thereafter: a previously accepted token is rejected and no third JWKS request occurs. Please give the shared refresh its own bounded lifetime, or allow a fresh caller to retry immediately after request-scoped cancellation, while preserving the expired-key rejection.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8266dfd. The shared refresh now runs on a detached, bounded context (context.WithTimeout(context.WithoutCancel(ctx), 10s)), so an initiating request's cancellation no longer aborts the fetch or advances the 30s failure cooldown. Expired keys are still cleared and rejected when a genuine refresh fails.

I replaced the old cancellation test with TestJWKSRefreshSurvivesRequestCancellation, which follows your reproduction: the startup load succeeds, the initiator cancels while the refresh is blocked, and then a previously accepted token must succeed immediately. I verified the test fails against the previous implementation (valid token -> 401) and passes with the fix.

Comment on lines +90 to +94
func (v *JWTVerifier) Authenticate(r *http.Request) (security.AccessContext, error) {
claims, err := v.verify(r)
if err != nil {
return security.AccessContext{}, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Apply the public-probe exception on the actual Router authentication path

The production Router installs authenticationMiddleware(r.authenticator) globally and invokes this Authenticate method; it never installs JWTVerifier.Middleware(). Consequently, the public /health exception in Middleware() is unused. ServerComponent.Start registers /health on the same engine, so enabling JWT makes a tokenless health probe return 401 and can make a healthy deployment fail its readiness/liveness checks. I reproduced the 401 using NewRouter with a real JWKS verifier and the health registration used by Start. Please exempt the explicit public probes in the middleware actually used by the Router and add an integration test through that Router; the standalone verifier-middleware tests do not cover this wiring.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8266dfd. The public-probe exemption now lives in authenticationMiddleware, the middleware the production Router actually installs, using a shared isPublicProbe helper. JWTVerifier.Middleware() uses the same helper, so the two paths cannot drift.

Added TestRouterHealthProbesArePublic: it builds the real NewRouter with a JWKS verifier and registers /health, /healthz, and /readyz the same way ServerComponent.Start does. Tokenless probes return 200 while /api/v1/ai/sessions still returns 401.

The quoted-value branch of the credential assignment rule stopped at an
escaped quote, leaving the remaining credential suffix visible to the
model. Match escape pairs inside double- and single-quoted values so the
whole value is redacted.

Add regressions for SanitizeText and the modelInputView boundary covering
escaped quotes and backslashes.
# Conflicts:
#	ai/store/memory/store_test.go
Run shared JWKS refreshes on a detached, bounded context so a client
disconnecting cannot abort the fetch or start the failure cooldown for
other callers. Apply the public-probe exemption in the authentication
middleware actually used by the Router, and add regressions through the
real Router and a canceled refresh initiator.
@robocanic

Copy link
Copy Markdown
Contributor

@Qiao-yq please merge the ai branch and resolve conflicts.

…lifecycle

# Conflicts:
#	ai/component/agent/react/react.go
#	ai/component/memory/component.go
#	ai/component/server/component.go
#	ai/component/server/config_test.go
#	ai/component/server/engine/handlers.go
#	ai/component/server/engine/models.go
#	ai/component/server/engine/router.go
#	ai/component/server/engine/session/session.go
#	ai/store/gorm/models.go
#	ai/store/gorm/store.go
#	ai/store/gorm/store_test.go
#	ai/store/memory/store.go
#	ai/store/memory/store_test.go
#	ai/store/store.go
#	ai/store/validation.go
@sonarqubecloud

sonarqubecloud Bot commented Nov 2, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants