Files
Agent-System-Backend/internal/store/store.go
T
weichen 9a545b1e69 Add admin-issued accounts with forced first-login password change
Why: the user ruled out self-registration (2026-09-30): accounts are
created only by admins who set an initial password; first login (and
first login after any admin reset) must force a password change.

Evidence:
- migration v10 auth_accounts (users password/must_change/is_admin +
  sessions table; upgrade test walks v1<->v10 both directions).
- argon2id hash/verify with PHC encoding; store tests cover hash
  roundtrip, duplicate-owner conflict, must-change lifecycle, session
  keep/except semantics.
- e2e TestAdminAccountFlow: static login page reachable without auth,
  wrong password 401, login sets HttpOnly cookie + mustChangePassword,
  admin creates user (409 on duplicate), non-admin create 403, forced
  change clears flag and old password dies, admin reset revokes sessions
  and re-arms forced change, API-key channel untouched, 5-fail backoff
  429, logout kills session. Full go test ./... green.

Impact: middleware now accepts Bearer key OR session cookie and exempts
static + /api/auth/login (also fixes the latent case where the login
page itself could 401). Embedded no-auth mode unchanged.

Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (admin-issued
variant, user decision 2026-09-30)
2026-09-30 11:15:22 +00:00

150 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package store 是持久化层唯一入口(存储档接口,设计 4.1/D5/D16)。
// 两档实现共用同一方法面:
// - 嵌入式档 SQLite(默认:单文件 + 本地对象目录,零外部服务);
// - PostgreSQL 档(多用户/多实例;DSN 配置,pgx 纯 Go 驱动,静态构建兼容)。
//
// 方言差异(占位符、UPSERT、全文检索、备份、咨询锁)收敛在 dialect 与方言文件;
// 业务层只依赖本包公开方法。schema 演进走版本化迁移(migrate.go,双方言、可回滚)。
// 租户硬边界:租户域查询一律带 owner 过滤,跨租户访问返回 ErrNotFound。
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"agent-system-backend/internal/blobstore"
_ "github.com/jackc/pgx/v5/stdlib" // PG 档驱动(注册名 "pgx")
_ "modernc.org/sqlite" // 嵌入式档驱动(注册名 "sqlite",CGO-free)
)
var ErrNotFound = errors.New("store: not found")
var ErrConflict = errors.New("store: conflict")
// OwnerDefault 是单租户默认 owner(嵌入式档 / 未启用认证时的隐式身份)。
const OwnerDefault = "local"
type Store struct {
db *sql.DB
dialect dialect
dataDir string // 工件对象目录(备份路径等诊断用)
blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储)
}
// OpenSQLite 打开(必要时创建)嵌入式存储并执行迁移。
func OpenSQLite(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
db.SetMaxOpenConns(1)
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil)
}
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移。
// DSN 例:postgres://user:pass@host:5432/agent_system?sslmode=disable
// artifactsDir 为工件对象目录(多实例部署时指向共享卷;单实例默认本地)。
func OpenPostgres(dsn, artifactsDir string) (*Store, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(8)
db.SetConnMaxIdleTime(5 * time.Minute)
return finishOpen(db, postgresDialect{}, artifactsDir, nil)
}
// OpenPostgresWithBlobs 是注入自定义对象存储的 PG 档入口(多实例共享卷/对象存储)。
func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*Store, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(8)
db.SetConnMaxIdleTime(5 * time.Minute)
return finishOpen(db, postgresDialect{}, artifactsDir, blobs)
}
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store) (*Store, error) {
if blobs == nil {
var err error
blobs, err = blobstore.NewLocal(artifactsDir)
if err != nil {
db.Close()
return nil, err
}
}
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
// Dialect 返回当前存储档名(sqlite | postgres;诊断/健康检查用)。
func (s *Store) Dialect() string { return s.dialect.name() }
// DataDir 返回工件对象目录。
func (s *Store) DataDir() string { return s.dataDir }
// BlobStore 返回工件对象存储(诊断/运维)。
func (s *Store) BlobStore() blobstore.Store { return s.blobs }
// NotifyPG 发送 PostgreSQL NOTIFY(PG 档事件扇出用;SQLite 档返回未执行)。
func (s *Store) NotifyPG(ctx context.Context, channel, payload string) (bool, error) {
if s.dialect.name() != "postgres" {
return false, nil
}
_, err := s.db.ExecContext(ctx, `SELECT pg_notify($1, $2)`, channel, payload)
return true, err
}
// q 按方言重绑占位符(查询统一用 ? 书写,PG 执行前转 $n)。
// 本包 SQL 不含字符串字面量 "?",重绑安全。
func (s *Store) q(query string) string { return s.dialect.rebind(query) }
// Backup 生成一致性备份文件。SQLite 档用 VACUUM INTO(在线安全,含 WAL 内容);
// PG 档备份走 pg_dump / WAL 归档(运维路径,见 usage 指南),此处返回指引错误。
func (s *Store) Backup(destPath string) error {
if s.dialect.name() != "sqlite" {
return errors.New("store: PG 档备份走 pg_dump/WAL 归档(见 docs/guides/usage.md 存储档一节)")
}
if _, err := s.db.Exec(`VACUUM INTO ?`, destPath); err != nil {
return fmt.Errorf("store: backup: %w", err)
}
return nil
}
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
func marshalJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func unmarshalJSON[T any](raw string) (T, error) {
var out T
err := json.Unmarshal([]byte(raw), &out)
return out, err
}