Files
Agent-System-Backend/internal/config/superadmin.go

154 lines
5.4 KiB
Go

package config
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"regexp"
"strings"
"unicode"
)
// 超级管理员启动凭据(R1):进程环境优先,未设置的键从受保护的 dotenv 文件补齐。
const (
EnvSuperAdminOwner = "SUPER_ADMIN_OWNER"
EnvSuperAdminPassword = "SUPER_ADMIN_PASSWORD"
EnvFileKey = "AGENT_SYSTEM_ENV_FILE"
)
// ErrSuperAdminConfig 标记缺失、不完整、非法或不可读的超级管理员配置;
// 错误文本只含键名与文件路径,绝不含取值。
var ErrSuperAdminConfig = errors.New("super admin config")
// SuperAdmin 持有启动期超级管理员身份与口令。口令只在进程内存中,
// 格式化输出与 JSON 序列化一律脱敏。
type SuperAdmin struct {
Owner string
password string
}
// NewSuperAdmin 供测试与组合根直接构造(不做校验,校验见 Validate)。
func NewSuperAdmin(owner, password string) SuperAdmin {
return SuperAdmin{Owner: owner, password: password}
}
// Password 返回配置口令;调用方不得记录或回传。
func (s SuperAdmin) Password() string { return s.password }
func (s SuperAdmin) String() string {
if s.password == "" {
return fmt.Sprintf("SuperAdmin{owner=%q password=<unset>}", s.Owner)
}
return fmt.Sprintf("SuperAdmin{owner=%q password=<redacted>}", s.Owner)
}
func (s SuperAdmin) GoString() string { return s.String() }
func (s SuperAdmin) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`{"owner":%q,"passwordConfigured":%t}`, s.Owner, s.password != "")), nil
}
// Validate 检查身份与口令的结构有效性;passwordPolicy 为认证边界的口令策略
// (由调用方注入,避免在配置层复制策略)。
func (s SuperAdmin) Validate(passwordPolicy func(string) error) error {
switch {
case s.Owner == "" && s.password == "":
return fmt.Errorf("%w: %s and %s are required when authentication is enabled", ErrSuperAdminConfig, EnvSuperAdminOwner, EnvSuperAdminPassword)
case s.Owner == "":
return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminOwner)
case s.password == "":
return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminPassword)
}
if strings.TrimSpace(s.Owner) != s.Owner {
return fmt.Errorf("%w: %s must not have surrounding whitespace", ErrSuperAdminConfig, EnvSuperAdminOwner)
}
for _, r := range s.Owner {
if unicode.IsControl(r) {
return fmt.Errorf("%w: %s contains control characters", ErrSuperAdminConfig, EnvSuperAdminOwner)
}
}
if passwordPolicy != nil {
if err := passwordPolicy(s.password); err != nil {
return fmt.Errorf("%w: %s violates password policy: %v", ErrSuperAdminConfig, EnvSuperAdminPassword, err)
}
}
return nil
}
// LoadSuperAdmin 读取超级管理员凭据:envLookup 中非空的键优先;未设置或空的键从
// envFile(为空时取 AGENT_SYSTEM_ENV_FILE)补齐。文件必须存在且不得对组/其他
// 用户开放任何权限。未开认证时不读取也不要求(嵌入式单用户档不暴露账号管理)。
func LoadSuperAdmin(envLookup func(string) (string, bool), envFile string, authEnabled bool) (SuperAdmin, error) {
if !authEnabled {
return SuperAdmin{}, nil
}
owner, _ := envLookup(EnvSuperAdminOwner)
password, _ := envLookup(EnvSuperAdminPassword)
ownerSet, passwordSet := owner != "", password != ""
if envFile == "" {
envFile, _ = envLookup(EnvFileKey)
}
if (!ownerSet || !passwordSet) && envFile != "" {
values, err := ReadEnvFile(envFile)
if err != nil {
return SuperAdmin{}, err
}
if !ownerSet {
owner = values[EnvSuperAdminOwner]
}
if !passwordSet {
password = values[EnvSuperAdminPassword]
}
}
return SuperAdmin{Owner: owner, password: password}, nil
}
var envFileLine = regexp.MustCompile(`^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$`)
// ReadEnvFile 按 svc.sh 使用的 dotenv 格式安全解析:
// KEY=VALUE、# 注释、export 前缀、剥成对引号;非法行忽略;不执行内容。
// 文件对组/其他用户有任何权限位即拒绝(受保护 .env 约定)。
func ReadEnvFile(path string) (map[string]string, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%w: env file %s is not a regular file", ErrSuperAdminConfig, path)
}
if perm := info.Mode().Perm(); perm&0o077 != 0 {
return nil, fmt.Errorf("%w: env file %s is accessible by group/other (mode %04o; chmod 600)", ErrSuperAdminConfig, path, perm)
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err)
}
values := map[string]string{}
scanner := bufio.NewScanner(bytes.NewReader(raw))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
m := envFileLine.FindStringSubmatch(line)
if m == nil {
continue
}
values[m[1]] = unquoteEnvValue(strings.TrimLeft(m[2], " \t"))
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("%w: env file %s: unreadable line", ErrSuperAdminConfig, path)
}
return values, nil
}
func unquoteEnvValue(v string) string {
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
return v[1 : len(v)-1]
}
return v
}