From d2f28b7d0e2672f3b125774c44f3c7bc7cd2be9e Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 21 Apr 2026 11:04:15 +0800 Subject: [PATCH] Add auth context contract and Phase 0 validation ADR --- docs/README.md | 7 ++ ...0010-channel-phase0-provider-validation.md | 41 ++++++++ .../Credentials/AuthContext.cs | 96 +++++++++++++++++++ .../Credentials/AuthPrincipal.cs | 12 +++ src/Aevatar.Foundation.Abstractions/README.md | 3 + .../AuthContextTests.cs | 78 +++++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 docs/decisions/0010-channel-phase0-provider-validation.md create mode 100644 src/Aevatar.Foundation.Abstractions/Credentials/AuthContext.cs create mode 100644 src/Aevatar.Foundation.Abstractions/Credentials/AuthPrincipal.cs create mode 100644 test/Aevatar.Foundation.Abstractions.Tests/AuthContextTests.cs diff --git a/docs/README.md b/docs/README.md index f700d5a2c..8edb05ecb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ Authoritative architecture and developer guides. Each covers one topic. +- [[RFC] Aevatar Chat — Multi-Channel Adapter Architecture](canon/aevatar-channel-architecture.md) - [Aevatar Foundation](canon/architecture.md) - [Workflow Chat API 能力说明(框架层)](canon/chat-api.md) - [Connector 配置与执行逻辑](canon/connector.md) @@ -30,6 +31,9 @@ Immutable records of architectural choices and their rationale. - [Orleans Kafka Provider Backend Architecture](decisions/0003-kafka-transport.md) - [Workflow 调度 Actor 化 & 多智能体协作演进方案](decisions/0006-multi-agent-evolution.md) - [Aevatar Stream Forward 架构说明(2026-02-22)](decisions/0007-stream-forward.md) +- [Channel Runtime Multi-Token Credential Routing](decisions/0008-channel-runtime-multi-token-routing.md) +- [Channel Bot Callback Architecture — Lessons from Lark Integration](decisions/0009-channel-bot-callback-architecture.md) +- [Channel Phase 0 Persistent Provider Validation Result](decisions/0010-channel-phase0-provider-validation.md) ## History @@ -44,11 +48,14 @@ Point-in-time design snapshots. Not authoritative — for context only. ### 2026-04 +- [2026-04-09-scripting-authority-write-path-cqrs-closure](history/2026-04/2026-04-09-scripting-authority-write-path-cqrs-closure.md) +- [2026-04-17-nyxid-chat-registry-lifecycle](history/2026-04/2026-04-17-nyxid-chat-registry-lifecycle.md) - [claude-code-architecture-learnings](history/2026-04/claude-code-architecture-learnings.md) - [nyxid-chat-console-design](history/2026-04/nyxid-chat-console-design.md) ## Audit Scorecard +- [2026-04-08-architecture-audit-detailed.md](audit-scorecard/2026-04-08-architecture-audit-detailed.md) - [FEATURE_APP_SERVICES_AUDIT.md](audit-scorecard/FEATURE_APP_SERVICES_AUDIT.md) ## Templates diff --git a/docs/decisions/0010-channel-phase0-provider-validation.md b/docs/decisions/0010-channel-phase0-provider-validation.md new file mode 100644 index 000000000..1957ebd50 --- /dev/null +++ b/docs/decisions/0010-channel-phase0-provider-validation.md @@ -0,0 +1,41 @@ +--- +title: "Channel Phase 0 Persistent Provider Validation Result" +status: accepted +owner: eanzhao +--- + +# ADR-0010: Channel Phase 0 Persistent Provider Validation Result + +## Context + +Issue `#255` defined Phase 0 as a prerequisite for the Channel RFC. One acceptance item required a documented result for the persistent-provider validation harness before Channel runtime work could rely on durable inbox semantics. + +The repository now contains a provider redelivery harness in [PersistentStreamProviderRedeliveryValidationTests](../../test/Aevatar.Foundation.Runtime.Hosting.Tests/PersistentStreamProviderRedeliveryValidationTests.cs), but the in-repo Orleans stream backends remain: + +- `InMemory` for local development and deterministic tests +- `KafkaProvider` for the durable backend that exists in this repository today + +There is still no EventHubs transport/provider implementation in the repo to validate directly. + +## Decision + +Phase 0 records the following validation result: + +- `KafkaProvider` is the only durable backend validated in-repo today. +- EventHubs is not treated as implicitly equivalent to Kafka. +- Until an EventHubs backend exists in this repository and passes the same throw-vs-return redelivery harness, Channel runtime work must keep Kafka as the durable-provider fallback. + +This is an explicit fallback outcome, not an EventHubs pass. + +## Evidence + +- PR `#267` added the redelivery harness and then narrowed its scope honestly to Kafka-only. +- The harness verifies both required semantics for the currently supported durable backend: + - `OnNextAsync` returns normally -> no redelivery + - `OnNextAsync` throws with propagated failure -> message is redelivered + +## Consequences + +- Issue `#255` can treat the provider-validation acceptance item as documented with a fallback stance. +- Future EventHubs work must extend the harness for EventHubs explicitly and capture a fresh result before claiming EventHubs as a durable inbox provider. +- Channel Phase 1 implementations must not assume EventHubs checkpoint semantics from vendor docs or from Kafka behavior. diff --git a/src/Aevatar.Foundation.Abstractions/Credentials/AuthContext.cs b/src/Aevatar.Foundation.Abstractions/Credentials/AuthContext.cs new file mode 100644 index 000000000..c3bf17030 --- /dev/null +++ b/src/Aevatar.Foundation.Abstractions/Credentials/AuthContext.cs @@ -0,0 +1,96 @@ +namespace Aevatar.Foundation.Abstractions.Credentials; + +/// +/// Stable auth intent that can cross module boundaries without carrying a raw secret. +/// The credential reference is late-bound and resolved only at the provider edge. +/// +public sealed record AuthContext +{ + public AuthContext( + AuthPrincipal principal, + string? principalId = null, + string? credentialRef = null, + string? onBehalfOfUserId = null) + { + Principal = principal; + PrincipalId = Normalize(principalId); + CredentialRef = Normalize(credentialRef); + OnBehalfOfUserId = Normalize(onBehalfOfUserId); + + Validate(Principal, PrincipalId, OnBehalfOfUserId); + } + + public AuthPrincipal Principal { get; } + + /// + /// Stable user identity when the principal is user-scoped. + /// + public string? PrincipalId { get; } + + /// + /// Opaque late-bound reference. This is never the raw secret itself. + /// + public string? CredentialRef { get; } + + /// + /// Audit target for delegated sends. + /// + public string? OnBehalfOfUserId { get; } + + public bool UsesBotIdentity => Principal == AuthPrincipal.Bot; + + public static AuthContext Bot(string? credentialRef = null) => + new(AuthPrincipal.Bot, credentialRef: credentialRef); + + public static AuthContext User(string principalId, string? credentialRef = null) => + new(AuthPrincipal.User, principalId: principalId, credentialRef: credentialRef); + + public static AuthContext OnBehalfOfUser( + string principalId, + string onBehalfOfUserId, + string? credentialRef = null) => + new( + AuthPrincipal.OnBehalfOfUser, + principalId: principalId, + credentialRef: credentialRef, + onBehalfOfUserId: onBehalfOfUserId); + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; + + private static void Validate( + AuthPrincipal principal, + string? principalId, + string? onBehalfOfUserId) + { + switch (principal) + { + case AuthPrincipal.Bot: + if (principalId is not null) + throw new ArgumentException("Bot auth context cannot carry a principal id.", nameof(principalId)); + + if (onBehalfOfUserId is not null) + throw new ArgumentException("Bot auth context cannot carry an on-behalf-of user id.", nameof(onBehalfOfUserId)); + + break; + case AuthPrincipal.User: + if (principalId is null) + throw new ArgumentException("User auth context requires a principal id.", nameof(principalId)); + + if (onBehalfOfUserId is not null) + throw new ArgumentException("User auth context cannot carry an on-behalf-of user id.", nameof(onBehalfOfUserId)); + + break; + case AuthPrincipal.OnBehalfOfUser: + if (principalId is null) + throw new ArgumentException("Delegated auth context requires a principal id.", nameof(principalId)); + + if (onBehalfOfUserId is null) + throw new ArgumentException("Delegated auth context requires an on-behalf-of user id.", nameof(onBehalfOfUserId)); + + break; + default: + throw new ArgumentOutOfRangeException(nameof(principal), principal, "Unsupported auth principal."); + } + } +} diff --git a/src/Aevatar.Foundation.Abstractions/Credentials/AuthPrincipal.cs b/src/Aevatar.Foundation.Abstractions/Credentials/AuthPrincipal.cs new file mode 100644 index 000000000..e6414b6df --- /dev/null +++ b/src/Aevatar.Foundation.Abstractions/Credentials/AuthPrincipal.cs @@ -0,0 +1,12 @@ +namespace Aevatar.Foundation.Abstractions.Credentials; + +/// +/// Identifies which principal should be used when resolving an auth context. +/// The raw secret is still obtained later through . +/// +public enum AuthPrincipal +{ + Bot = 0, + User = 1, + OnBehalfOfUser = 2, +} diff --git a/src/Aevatar.Foundation.Abstractions/README.md b/src/Aevatar.Foundation.Abstractions/README.md index 769177896..aa8cdb79f 100644 --- a/src/Aevatar.Foundation.Abstractions/README.md +++ b/src/Aevatar.Foundation.Abstractions/README.md @@ -7,6 +7,7 @@ - 定义 Agent/Actor/Runtime 的核心接口 - 定义事件发布、流、模块与持久化接口 - 定义框架级 connector 契约(`IConnector` / `IConnectorRegistry`) +- 定义框架级凭证解析契约(`AuthContext` / `ICredentialProvider`) - 提供跨项目共享的 Proto 消息 - 提供少量基础工具类型(如 `AgentId`、时间工具、属性标记) @@ -26,6 +27,7 @@ Aevatar.Foundation.Abstractions/ ├── Attributes/ ├── EventModules/ ├── Connectors/ +├── Credentials/ ├── Context/ ├── Propagation/ ├── Persistence/ @@ -45,6 +47,7 @@ Aevatar.Foundation.Abstractions/ - `IEventContext`:模块上下文的共性根接口 - `IEventModule`:可插拔事件处理模块(含优先级) - `IConnector` / `IConnectorRegistry`:命名 connector 调用契约与注册表 +- `AuthContext` / `ICredentialProvider`:principal-aware 凭证引用与延迟解析契约 - `IStateStore` / `IEventStore`:状态与事件持久化契约 ## Proto 说明 diff --git a/test/Aevatar.Foundation.Abstractions.Tests/AuthContextTests.cs b/test/Aevatar.Foundation.Abstractions.Tests/AuthContextTests.cs new file mode 100644 index 000000000..12f36c297 --- /dev/null +++ b/test/Aevatar.Foundation.Abstractions.Tests/AuthContextTests.cs @@ -0,0 +1,78 @@ +using Aevatar.Foundation.Abstractions.Credentials; +using Shouldly; + +namespace Aevatar.Foundation.Abstractions.Tests; + +public sealed class AuthContextTests +{ + [Fact] + public void Bot_Factory_CreatesBotContext() + { + var context = AuthContext.Bot("secrets://bot/default"); + + context.Principal.ShouldBe(AuthPrincipal.Bot); + context.PrincipalId.ShouldBeNull(); + context.CredentialRef.ShouldBe("secrets://bot/default"); + context.OnBehalfOfUserId.ShouldBeNull(); + context.UsesBotIdentity.ShouldBeTrue(); + } + + [Fact] + public void User_Factory_CreatesUserContext() + { + var context = AuthContext.User("user-123", "secrets://user/123"); + + context.Principal.ShouldBe(AuthPrincipal.User); + context.PrincipalId.ShouldBe("user-123"); + context.CredentialRef.ShouldBe("secrets://user/123"); + context.OnBehalfOfUserId.ShouldBeNull(); + context.UsesBotIdentity.ShouldBeFalse(); + } + + [Fact] + public void OnBehalfOfUser_Factory_CreatesDelegatedContext() + { + var context = AuthContext.OnBehalfOfUser( + principalId: "workflow-runner", + onBehalfOfUserId: "owner-42", + credentialRef: "secrets://workflow/owner-42"); + + context.Principal.ShouldBe(AuthPrincipal.OnBehalfOfUser); + context.PrincipalId.ShouldBe("workflow-runner"); + context.OnBehalfOfUserId.ShouldBe("owner-42"); + context.CredentialRef.ShouldBe("secrets://workflow/owner-42"); + } + + [Fact] + public void Constructor_NormalizesWhitespaceOnlyOptionals() + { + var context = new AuthContext(AuthPrincipal.Bot, credentialRef: " "); + + context.CredentialRef.ShouldBeNull(); + } + + [Fact] + public void Constructor_RejectsBotPrincipalId() + { + Should.Throw(() => new AuthContext( + AuthPrincipal.Bot, + principalId: "bot-id")) + .ParamName.ShouldBe("principalId"); + } + + [Fact] + public void Constructor_RejectsMissingUserPrincipalId() + { + Should.Throw(() => new AuthContext(AuthPrincipal.User)) + .ParamName.ShouldBe("principalId"); + } + + [Fact] + public void Constructor_RejectsMissingDelegatedAuditTarget() + { + Should.Throw(() => new AuthContext( + AuthPrincipal.OnBehalfOfUser, + principalId: "workflow-runner")) + .ParamName.ShouldBe("onBehalfOfUserId"); + } +}