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:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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: "Не удалось сохранить настройки канала. Попробуйте ещё раз."
|
||||
|
||||
@@ -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: "无法保存渠道配置,请重试。"
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user