取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
175 lines
4.8 KiB
Go
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
|
|
}
|