feat(vNext.4): implement trusted-subject security chain for portal user key self-service
- Add portal_auth.go: Portal user session auth with HMAC-signed cookies
- Add /api/portal/session/{login,logout,state} endpoints
- Update nginx config template: cookie-to-header trusted proxy pattern
- Update frontend: sync CRM session on login/logout
- Add TRUSTED_SUBJECT_DEPLOY_GUIDE.md with remote43 deployment steps
- Update EXECUTION_BOARD.md: mark trusted-subject blocking issue as resolved
This implements the secure chain:
Browser → Portal → nginx (cookie→header) → CRM (verify proxy secret)
Required remote43 actions:
1. Generate 64-char hex secret
2. Update .env.crm with TRUSTED_* config
3. Update nginx with cookie map and header injection
4. Restart services
Fixes EXECUTION_BOARD.md 2026-06-08 blocking issue
This commit is contained in:
@@ -9,26 +9,30 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
|
||||
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
|
||||
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
|
||||
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
|
||||
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
|
||||
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
|
||||
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
|
||||
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
|
||||
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
|
||||
EnvRouteRuntimeBackend = "SUB2API_CRM_ROUTE_RUNTIME_BACKEND"
|
||||
EnvRedisAddr = "SUB2API_CRM_REDIS_ADDR"
|
||||
EnvRedisPassword = "SUB2API_CRM_REDIS_PASSWORD"
|
||||
EnvRedisDB = "SUB2API_CRM_REDIS_DB"
|
||||
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
|
||||
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
|
||||
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
|
||||
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
|
||||
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
|
||||
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
|
||||
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
|
||||
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
|
||||
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
|
||||
EnvRouteRuntimeBackend = "SUB2API_CRM_ROUTE_RUNTIME_BACKEND"
|
||||
EnvRedisAddr = "SUB2API_CRM_REDIS_ADDR"
|
||||
EnvRedisPassword = "SUB2API_CRM_REDIS_PASSWORD"
|
||||
EnvRedisDB = "SUB2API_CRM_REDIS_DB"
|
||||
EnvTrustedSubjectHeader = "SUB2API_CRM_TRUSTED_SUBJECT_HEADER"
|
||||
EnvTrustedProxySecretHeader = "SUB2API_CRM_TRUSTED_PROXY_SECRET_HEADER"
|
||||
EnvTrustedProxySecret = "SUB2API_CRM_TRUSTED_PROXY_SECRET"
|
||||
|
||||
DefaultListenAddr = ":8080"
|
||||
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
|
||||
DefaultAdminUsername = "admin"
|
||||
DefaultAdminSessionTTL = 12 * time.Hour
|
||||
DefaultReconcilePollInterval = 10 * time.Minute
|
||||
DefaultRouteRuntimeBackend = "memory"
|
||||
DefaultListenAddr = ":8080"
|
||||
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
|
||||
DefaultAdminUsername = "admin"
|
||||
DefaultAdminSessionTTL = 12 * time.Hour
|
||||
DefaultReconcilePollInterval = 10 * time.Minute
|
||||
DefaultRouteRuntimeBackend = "memory"
|
||||
DefaultTrustedProxySecretHeader = "X-CRM-Trusted-Proxy"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -59,10 +63,17 @@ type RepositoryConfig struct {
|
||||
RepoRoot string
|
||||
}
|
||||
|
||||
type UserKeyAuthConfig struct {
|
||||
TrustedSubjectHeader string
|
||||
TrustedProxySecretHeader string
|
||||
TrustedProxySecret string
|
||||
}
|
||||
|
||||
type StartupConfig struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Repository RepositoryConfig
|
||||
UserKeyAuth UserKeyAuthConfig
|
||||
RouteRuntime RouteRuntimeConfig
|
||||
Reconcile ReconcileConfig
|
||||
}
|
||||
@@ -96,6 +107,11 @@ func loadStartupFromLookupEnv(lookup func(string) (string, bool)) (StartupConfig
|
||||
Repository: RepositoryConfig{
|
||||
RepoRoot: readOptionalEnv(lookup, EnvRepoRoot, ""),
|
||||
},
|
||||
UserKeyAuth: UserKeyAuthConfig{
|
||||
TrustedSubjectHeader: readOptionalEnv(lookup, EnvTrustedSubjectHeader, ""),
|
||||
TrustedProxySecretHeader: readOptionalEnv(lookup, EnvTrustedProxySecretHeader, DefaultTrustedProxySecretHeader),
|
||||
TrustedProxySecret: readOptionalEnv(lookup, EnvTrustedProxySecret, ""),
|
||||
},
|
||||
RouteRuntime: RouteRuntimeConfig{
|
||||
Backend: readOptionalEnv(lookup, EnvRouteRuntimeBackend, DefaultRouteRuntimeBackend),
|
||||
Redis: RedisRuntimeConfig{
|
||||
|
||||
@@ -77,6 +77,12 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
|
||||
return " redis-pass ", true
|
||||
case EnvRedisDB:
|
||||
return "5", true
|
||||
case EnvTrustedSubjectHeader:
|
||||
return "X-CRM-Authenticated-Subject", true
|
||||
case EnvTrustedProxySecretHeader:
|
||||
return "X-CRM-Trusted-Proxy", true
|
||||
case EnvTrustedProxySecret:
|
||||
return "proxy-secret", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
@@ -112,6 +118,15 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
|
||||
if cfg.RouteRuntime.Redis.DB != 5 {
|
||||
t.Fatalf("RouteRuntime.Redis.DB = %d, want 5", cfg.RouteRuntime.Redis.DB)
|
||||
}
|
||||
if cfg.UserKeyAuth.TrustedSubjectHeader != "X-CRM-Authenticated-Subject" {
|
||||
t.Fatalf("UserKeyAuth.TrustedSubjectHeader = %q, want X-CRM-Authenticated-Subject", cfg.UserKeyAuth.TrustedSubjectHeader)
|
||||
}
|
||||
if cfg.UserKeyAuth.TrustedProxySecretHeader != "X-CRM-Trusted-Proxy" {
|
||||
t.Fatalf("UserKeyAuth.TrustedProxySecretHeader = %q, want X-CRM-Trusted-Proxy", cfg.UserKeyAuth.TrustedProxySecretHeader)
|
||||
}
|
||||
if cfg.UserKeyAuth.TrustedProxySecret != "proxy-secret" {
|
||||
t.Fatalf("UserKeyAuth.TrustedProxySecret = %q, want proxy-secret", cfg.UserKeyAuth.TrustedProxySecret)
|
||||
}
|
||||
})
|
||||
t.Run("default values", func(t *testing.T) {
|
||||
lookup := func(k string) (string, bool) {
|
||||
@@ -142,6 +157,15 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
|
||||
if cfg.RouteRuntime.Redis.Addr != "" || cfg.RouteRuntime.Redis.Password != "" || cfg.RouteRuntime.Redis.DB != 0 {
|
||||
t.Fatalf("RouteRuntime.Redis = %+v, want zero value", cfg.RouteRuntime.Redis)
|
||||
}
|
||||
if cfg.UserKeyAuth.TrustedSubjectHeader != "" {
|
||||
t.Fatalf("UserKeyAuth.TrustedSubjectHeader = %q, want empty by default", cfg.UserKeyAuth.TrustedSubjectHeader)
|
||||
}
|
||||
if cfg.UserKeyAuth.TrustedProxySecretHeader != DefaultTrustedProxySecretHeader {
|
||||
t.Fatalf("UserKeyAuth.TrustedProxySecretHeader = %q, want %q", cfg.UserKeyAuth.TrustedProxySecretHeader, DefaultTrustedProxySecretHeader)
|
||||
}
|
||||
if cfg.UserKeyAuth.TrustedProxySecret != "" {
|
||||
t.Fatalf("UserKeyAuth.TrustedProxySecret = %q, want empty by default", cfg.UserKeyAuth.TrustedProxySecret)
|
||||
}
|
||||
})
|
||||
t.Run("invalid reconcile interval", func(t *testing.T) {
|
||||
lookup := func(k string) (string, bool) {
|
||||
|
||||
Reference in New Issue
Block a user