refactor(access): 统一分层白名单权限语义
取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user