Files
easyai-ai-gateway/apps/api/cmd/access-rule-audit/main.go
T
wangbo 7376d6fab6 refactor(access): 统一分层白名单权限语义
取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
2026-08-03 15:43:49 +08:00

175 lines
4.8 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/accessruleaudit"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(64)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var err error
switch os.Args[1] {
case "export":
err = runExport(ctx, os.Args[2:])
case "verify":
err = runVerify(ctx, os.Args[2:])
default:
usage()
os.Exit(64)
}
if err != nil {
fmt.Fprintln(os.Stderr, "access-rule audit:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, `Usage:
easyai-ai-gateway-access-rule-audit export --output <before.json>
easyai-ai-gateway-access-rule-audit verify --before <before.json> --output <after.json>
Both commands are database read-only and only emit grouped counts and SHA-256
digests. Use a SELECT-only AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL role.`)
}
func runExport(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("export", flag.ContinueOnError)
output := flags.String("output", "", "secret-safe access-rule audit output")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("unexpected export arguments")
}
snapshot, outputPath, err := exportSnapshot(ctx, *output)
if err != nil {
return err
}
if err := writeSnapshot(outputPath, snapshot); err != nil {
return err
}
fmt.Printf("access_rule_audit_export=PASS total=%d allow=%d deny=%d live_sha256=%s\n", snapshot.Live.Total, snapshot.Live.AllowCount, snapshot.Live.DenyCount, snapshot.Live.SHA256)
return nil
}
func runVerify(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("verify", flag.ContinueOnError)
beforePathValue := flags.String("before", "", "pre-migration audit snapshot")
output := flags.String("output", "", "post-migration audit output")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("unexpected verify arguments")
}
beforePath, err := regularInputPath(*beforePathValue)
if err != nil {
return err
}
after, outputPath, err := exportSnapshot(ctx, *output)
if err != nil {
return err
}
if beforePath == outputPath {
return errors.New("verification output must not overwrite the pre-migration snapshot")
}
payload, err := os.ReadFile(beforePath)
if err != nil {
return err
}
before, err := accessruleaudit.Decode(payload)
if err != nil {
return err
}
if err := accessruleaudit.VerifyMigration(before, after); err != nil {
return err
}
if err := writeSnapshot(outputPath, after); err != nil {
return err
}
fmt.Printf("access_rule_audit_verify=PASS archived_allow=%d live_allow=%d live_deny=%d deny_sha256=%s\n", after.Archive.ArchivedAllowCount, after.Live.AllowCount, after.Live.DenyCount, after.Live.DenySHA256)
return nil
}
func exportSnapshot(ctx context.Context, output string) (accessruleaudit.Snapshot, string, error) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL"))
if databaseURL == "" {
return accessruleaudit.Snapshot{}, "", errors.New("AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL is required")
}
outputPath, err := safeOutputPath(output)
if err != nil {
return accessruleaudit.Snapshot{}, "", err
}
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return accessruleaudit.Snapshot{}, "", err
}
defer pool.Close()
snapshot, err := accessruleaudit.Export(ctx, pool, time.Now())
return snapshot, outputPath, err
}
func writeSnapshot(path string, snapshot accessruleaudit.Snapshot) error {
payload, err := accessruleaudit.Encode(snapshot)
if err != nil {
return err
}
return os.WriteFile(path, payload, 0o600)
}
func regularInputPath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("input path is required")
}
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
info, err := os.Lstat(absolute)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("input must be a regular non-symlink file")
}
return absolute, nil
}
func safeOutputPath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", errors.New("output path is required")
}
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
parent := filepath.Dir(absolute)
if err := os.MkdirAll(parent, 0o700); err != nil {
return "", err
}
if info, err := os.Lstat(absolute); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("output must be a regular non-symlink file")
}
} else if !os.IsNotExist(err) {
return "", err
}
return absolute, nil
}