fix: harden identity, live access, drafts and setup (review only)

Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
2026-10-16 12:15:20 +00:00
parent e57610a27c
commit 63e0457886
86 changed files with 5174 additions and 1929 deletions

View File

@@ -1,6 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening.
package dto
import "agent-desk/internal/pkg/enums"
import (
"agent-desk/internal/pkg/enums"
"time"
)
type AuthPrincipal struct {
UserID int64
@@ -11,16 +16,22 @@ type AuthPrincipal struct {
Status enums.Status
Roles []string
Permissions []string
// Session metadata stays internal and binds long-lived connections to the
// original login session and its absolute deadline.
LoginSessionID int64 `json:"-"`
ExpiresAt time.Time `json:"-"`
}
type WebChannelConfig struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
RequireSignedIdentity bool `json:"requireSignedIdentity,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
}
type TelegramChannelConfig struct {

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package request
type SetupSimulateRouteRequest struct {
@@ -8,4 +9,6 @@ type SetupSimulateRouteRequest struct {
type SetupQuickFixRequest struct {
Action string `json:"action" form:"action"`
EntityID int64 `json:"entity_id" form:"entity_id"`
Apply bool `json:"apply" form:"apply"`
PreviewToken string `json:"preview_token" form:"preview_token"`
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package response
type SetupIssueItem struct {
@@ -92,7 +93,20 @@ type SetupSimulateRouteResponse struct {
CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"`
}
type SetupQuickFixResponse struct {
AppliedActions []string `json:"applied_actions"`
Readiness SetupReadinessResponse `json:"readiness"`
type SetupQuickFixChange struct {
EntityType string `json:"entity_type"`
EntityID int64 `json:"entity_id"`
EntityName string `json:"entity_name"`
Operation string `json:"operation"`
Before map[string]any `json:"before"`
After map[string]any `json:"after"`
}
type SetupQuickFixResponse struct {
Applied bool `json:"applied"`
PreviewToken string `json:"preview_token"`
Changes []SetupQuickFixChange `json:"changes"`
WarningCodes []string `json:"warning_codes"`
AppliedActions []string `json:"applied_actions"`
Readiness *SetupReadinessResponse `json:"readiness,omitempty"`
}

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent not found."
error.e0003: "AI Agent not found or disabled."
@@ -567,3 +568,12 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "The user's expected outcom
tool.graph.prepareTicketDraft.param.currentAttempt: "Current attempted troubleshooting steps. Optional."
error.setup.invalidQuickFixAction: "Unsupported setup quick-fix action: %v"
error.setup.authRequired: "Sign in to run setup diagnostics or quick fixes"
error.setup.previewRequired: "Review a setup preview and explicitly confirm its token before applying changes."
error.setup.previewChanged: "Configuration changed since the preview. Review a new preview before applying."
error.setup.targetRequired: "Select a specific existing record, or use its settings to choose the intended configuration."
error.setup.targetUnavailable: "The selected record is unavailable, deleted or ineligible. It was not restored."
error.setup.persistenceFailed: "Setup configuration could not be read or saved. No incomplete repair was committed."
error.setup.bypassUnsupported: "Bypassing AI is not supported by this diagnostic. Run a human-only route check or test AI separately."
error.channel.configChanged: "The channel configuration changed. Reload it and review your changes before saving again."
error.channel.configUpdateFailed: "The channel configuration could not be saved. Please try again."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "ИИ-агент не найден"
error.e0002: "ИИ-агент не найден."
error.e0003: "ИИ-агент не найден или отключён."
@@ -566,4 +567,12 @@ tool.graph.prepareTicketDraft.param.impact: "Область воздействи
tool.graph.prepareTicketDraft.param.expectedOutcome: "Ожидаемый результат или запрос пользователя."
tool.graph.prepareTicketDraft.param.currentAttempt: "Предпринятые шаги по устранению. Необязательно."
error.setup.invalidQuickFixAction: "Неподдерживаемое действие быстрой настройки: %v"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.previewRequired: "Перед применением изменений просмотрите план и явно подтвердите его токен."
error.setup.previewChanged: "После предварительного просмотра настройки изменились. Просмотрите новый план."
error.setup.targetRequired: "Выберите конкретную запись или задайте нужную конфигурацию в её настройках."
error.setup.targetUnavailable: "Выбранная запись недоступна, удалена или не подходит. Она не была восстановлена."
error.setup.persistenceFailed: "Не удалось прочитать или сохранить настройки. Неполное исправление не было сохранено."
error.setup.bypassUnsupported: "Эта диагностика не поддерживает обход ИИ. Проверьте маршрут только с оператором или протестируйте ИИ отдельно."
error.channel.configChanged: "Настройки канала изменились. Перезагрузите их и проверьте изменения перед повторным сохранением."
error.channel.configUpdateFailed: "Не удалось сохранить настройки канала. Попробуйте ещё раз."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent 不存在"
error.e0003: "AI Agent 不存在或已停用"
@@ -567,3 +568,12 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "用户期望的处理结
tool.graph.prepareTicketDraft.param.currentAttempt: "当前已尝试过的处理步骤,可选。"
error.setup.invalidQuickFixAction: "不支持的一键修复操作:%v"
error.setup.authRequired: "请先登录以执行配置诊断或一键修复"
error.setup.previewRequired: "应用更改前,请先预览设置并明确确认预览令牌。"
error.setup.previewChanged: "预览后配置已更改。请重新预览后再应用。"
error.setup.targetRequired: "请选择具体记录,或在对应设置中选择所需配置。"
error.setup.targetUnavailable: "所选记录不可用、已删除或不符合条件,未进行恢复。"
error.setup.persistenceFailed: "无法读取或保存设置配置,未提交不完整的修复。"
error.setup.bypassUnsupported: "此诊断不支持绕过 AI。请检查仅人工路由,或单独测试 AI。"
error.channel.configChanged: "渠道配置已更改。请重新加载并检查更改后再保存。"
error.channel.configUpdateFailed: "无法保存渠道配置,请重试。"

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -269,3 +271,25 @@ func flipLastChar(signature string) string {
func itoa(value int64) string {
return strconv.FormatInt(value, 10)
}
func TestRequireSignedIdentityRejectsUnsignedAndInvalidSignatures(t *testing.T) {
for _, signature := range []string{"", "invalid"} {
ctx, _ := newIdentityContext(map[string]string{"X-External-Id": testUserID, "X-External-Id-Signature": signature})
if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil { t.Fatal("signed-only policy accepted an unsigned or invalid identity") }
}
ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret,testChannelID,testUserID,time.Now()))
user, err := GetExternalUser(ctx, Secrets{ChannelID:testChannelID,IdentityHMAC:testSecret,RequireSignedIdentity:true})
if err != nil || user.IdentityScope != IdentityScope(testChannelID,"hmac") { t.Fatal("valid signed identity did not retain its HMAC scope") }
}
func TestIdentityScopeSeparatesIssuersAndChannels(t *testing.T) {
seen := map[string]bool{}
for _, channel := range []string{"channel-one","channel-two"} {
for _, issuer := range []string{"guest","hmac","jwt"} {
scope := IdentityScope(channel,issuer)
if scope == "" || seen[scope] { t.Fatal("identity scopes collided") }
seen[scope] = true
}
}
if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" { t.Fatal("invalid scope inputs accepted") }
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -24,6 +26,9 @@ type ExternalUser struct {
ExternalSource enums.ExternalSource `json:"externalSource"`
ExternalID string `json:"externalId"`
ExternalName string `json:"externalName"`
// IdentityScope and ChannelID are server-established authorization context, never request fields.
IdentityScope string `json:"-"`
ChannelID int64 `json:"-"`
}
type UserTokenClaims struct {
@@ -47,6 +52,7 @@ type Secrets struct {
// ChannelID is bound into the signed payload so a signature obtained for one
// channel cannot be replayed on another.
ChannelID string
RequireSignedIdentity bool
}
// DefaultIdentityMaxAge is how long a signed visitor identity stays acceptable
@@ -65,9 +71,8 @@ const identityFutureSkewTolerance = 5 * time.Minute
//
// issuedAt is unix seconds. Lower-case hex output. Three details are deliberate:
//
// - The channel id is included because CustomerIdentity is keyed by
// (source, externalId) globally, so without it a signature minted for one
// channel would assert the same identity on every other channel.
// - The channel id is included so signatures cannot be replayed on a
// different channel, even when a deployment shares an HMAC signing key.
// - The timestamp is inside the signed bytes even though it also travels as its
// own field, so it cannot be re-dated without breaking the signature. This
// bounds how long a leaked pair stays usable; it is not a defence against key
@@ -126,6 +131,7 @@ func GetExternalUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) {
ExternalSource: enums.ExternalSourceUser,
ExternalID: claims.UserID,
ExternalName: claims.Name,
IdentityScope: IdentityScope(secrets.ChannelID, "jwt"),
}, nil
}
return getExternalIDUser(ctx, secrets)
@@ -201,13 +207,18 @@ func getExternalIDUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error)
ExternalSource: enums.ExternalSourceUser,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "hmac"),
}, nil
}
if secrets.RequireSignedIdentity {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &ExternalUser{
ExternalSource: enums.ExternalSourceGuest,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "guest"),
}, nil
}
@@ -267,3 +278,24 @@ func decodeExternalDisplayName(s string) string {
}
return strings.TrimSpace(dec)
}
// IdentityScope separates independent identity issuers, trust levels and channels.
// It deliberately does not depend on a secret so ordinary key rotation preserves
// the issuer's history. Reassigning a channel to a different issuer requires a new
// channel, not silently reusing that issuer's namespace.
func IdentityScope(channelCode, issuer string) string {
if strings.TrimSpace(channelCode) == "" { return "" }
switch issuer { case "jwt", "hmac", "guest": default: return "" }
digest := sha256.Sum256([]byte(strings.TrimSpace(channelCode)))
return "v1:" + issuer + ":" + hex.EncodeToString(digest[:])
}
func ValidIdentityScope(channelCode string, external ExternalUser) bool {
if external.ExternalSource == enums.ExternalSourceGuest {
return external.IdentityScope != "" && external.IdentityScope == IdentityScope(channelCode, "guest")
}
if external.ExternalSource == enums.ExternalSourceUser {
return external.IdentityScope != "" && (external.IdentityScope == IdentityScope(channelCode, "jwt") || external.IdentityScope == IdentityScope(channelCode, "hmac"))
}
return false
}