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 easyai-ai-gateway-access-rule-audit verify --before --output 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 }