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
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package accessruleaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = "access-rule-audit/v1"
|
||||
MigrationBatch = "0102_access_rule_allow_whitelist_semantics"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
SecretSafe bool `json:"secretSafe"`
|
||||
Live RuleSet `json:"live"`
|
||||
Archive *ArchiveManifest `json:"archive,omitempty"`
|
||||
}
|
||||
|
||||
type RuleSet struct {
|
||||
Total int64 `json:"total"`
|
||||
SHA256 string `json:"sha256"`
|
||||
AllowCount int64 `json:"allowCount"`
|
||||
AllowSHA256 string `json:"allowSha256"`
|
||||
DenyCount int64 `json:"denyCount"`
|
||||
DenySHA256 string `json:"denySha256"`
|
||||
Counts []RuleCount `json:"counts"`
|
||||
}
|
||||
|
||||
type RuleCount struct {
|
||||
SubjectType string `json:"subjectType"`
|
||||
Effect string `json:"effect"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
Status string `json:"status"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type ArchiveManifest struct {
|
||||
MigrationBatch string `json:"migrationBatch"`
|
||||
ManifestAllowCount int64 `json:"manifestAllowCount"`
|
||||
ManifestAllowSHA string `json:"manifestAllowSha256"`
|
||||
ArchivedAllowCount int64 `json:"archivedAllowCount"`
|
||||
ArchivedAllowSHA string `json:"archivedAllowSha256"`
|
||||
ManifestDenyCount int64 `json:"manifestDenyCount"`
|
||||
ManifestDenySHA string `json:"manifestDenySha256"`
|
||||
Consistent bool `json:"consistent"`
|
||||
}
|
||||
|
||||
type ruleDigestRow struct {
|
||||
id string
|
||||
subjectType string
|
||||
subjectID string
|
||||
resourceType string
|
||||
resourceID string
|
||||
effect string
|
||||
priority string
|
||||
minPermissionLevel string
|
||||
conditions string
|
||||
metadata string
|
||||
status string
|
||||
createdEpoch string
|
||||
updatedEpoch string
|
||||
}
|
||||
|
||||
func Export(ctx context.Context, pool *pgxpool.Pool, now time.Time) (Snapshot, error) {
|
||||
live, err := loadLiveRules(ctx, pool)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
archive, err := loadArchiveManifest(ctx, pool)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
snapshot := Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
GeneratedAt: now.UTC(),
|
||||
SecretSafe: true,
|
||||
Live: live,
|
||||
Archive: archive,
|
||||
}
|
||||
if err := Validate(snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func Validate(snapshot Snapshot) error {
|
||||
if snapshot.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("unsupported access-rule audit schema %q", snapshot.SchemaVersion)
|
||||
}
|
||||
if !snapshot.SecretSafe {
|
||||
return errors.New("access-rule audit snapshot is not marked secret-safe")
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"live": snapshot.Live.SHA256, "allow": snapshot.Live.AllowSHA256, "deny": snapshot.Live.DenySHA256,
|
||||
} {
|
||||
if !validSHA256(value) {
|
||||
return fmt.Errorf("%s SHA-256 is invalid", name)
|
||||
}
|
||||
}
|
||||
if snapshot.Live.Total != snapshot.Live.AllowCount+snapshot.Live.DenyCount {
|
||||
return errors.New("live access-rule counts are inconsistent")
|
||||
}
|
||||
var groupedTotal int64
|
||||
for _, count := range snapshot.Live.Counts {
|
||||
if count.SubjectType == "" || count.Effect == "" || count.ResourceType == "" || count.Status == "" || count.Count < 1 {
|
||||
return errors.New("access-rule grouped count is invalid")
|
||||
}
|
||||
groupedTotal += count.Count
|
||||
}
|
||||
if groupedTotal != snapshot.Live.Total {
|
||||
return errors.New("access-rule grouped counts do not match total")
|
||||
}
|
||||
if snapshot.Archive != nil {
|
||||
archive := snapshot.Archive
|
||||
if archive.MigrationBatch != MigrationBatch || !validSHA256(archive.ManifestAllowSHA) || !validSHA256(archive.ArchivedAllowSHA) || !validSHA256(archive.ManifestDenySHA) {
|
||||
return errors.New("access-rule archive manifest is invalid")
|
||||
}
|
||||
consistent := archive.ManifestAllowCount == archive.ArchivedAllowCount && archive.ManifestAllowSHA == archive.ArchivedAllowSHA
|
||||
if archive.Consistent != consistent {
|
||||
return errors.New("access-rule archive consistency marker is incorrect")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func VerifyMigration(before Snapshot, after Snapshot) error {
|
||||
if err := Validate(before); err != nil {
|
||||
return fmt.Errorf("before snapshot: %w", err)
|
||||
}
|
||||
if err := Validate(after); err != nil {
|
||||
return fmt.Errorf("after snapshot: %w", err)
|
||||
}
|
||||
if after.Archive == nil || !after.Archive.Consistent {
|
||||
return errors.New("verified allow archive is unavailable")
|
||||
}
|
||||
if after.Live.AllowCount != 0 {
|
||||
return fmt.Errorf("live legacy allow count=%d, want 0", after.Live.AllowCount)
|
||||
}
|
||||
if before.Live.AllowCount != after.Archive.ManifestAllowCount || before.Live.AllowSHA256 != after.Archive.ManifestAllowSHA {
|
||||
return errors.New("pre-migration allow snapshot does not match archived manifest")
|
||||
}
|
||||
if before.Live.DenyCount != after.Live.DenyCount || before.Live.DenySHA256 != after.Live.DenySHA256 {
|
||||
return errors.New("deny rules changed during allow migration")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Encode(snapshot Snapshot) ([]byte, error) {
|
||||
if err := Validate(snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(payload, '\n'), nil
|
||||
}
|
||||
|
||||
func Decode(payload []byte) (Snapshot, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(string(payload)))
|
||||
decoder.DisallowUnknownFields()
|
||||
var snapshot Snapshot
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := Validate(snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func loadLiveRules(ctx context.Context, pool *pgxpool.Pool) (RuleSet, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT id::text, subject_type, subject_id::text, resource_type,
|
||||
resource_id::text, effect, priority::text,
|
||||
min_permission_level::text, conditions::text, metadata::text,
|
||||
status, extract(epoch FROM created_at)::text,
|
||||
extract(epoch FROM updated_at)::text
|
||||
FROM gateway_access_rules
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return RuleSet{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
allDigests := make([]string, 0)
|
||||
allowDigests := make([]string, 0)
|
||||
denyDigests := make([]string, 0)
|
||||
counts := map[string]int64{}
|
||||
for rows.Next() {
|
||||
var row ruleDigestRow
|
||||
if err := rows.Scan(
|
||||
&row.id, &row.subjectType, &row.subjectID, &row.resourceType,
|
||||
&row.resourceID, &row.effect, &row.priority,
|
||||
&row.minPermissionLevel, &row.conditions, &row.metadata,
|
||||
&row.status, &row.createdEpoch, &row.updatedEpoch,
|
||||
); err != nil {
|
||||
return RuleSet{}, err
|
||||
}
|
||||
digest := rowSHA256(row)
|
||||
allDigests = append(allDigests, digest)
|
||||
if row.effect == "allow" {
|
||||
allowDigests = append(allowDigests, digest)
|
||||
}
|
||||
if row.effect == "deny" {
|
||||
denyDigests = append(denyDigests, digest)
|
||||
}
|
||||
counts[strings.Join([]string{row.subjectType, row.effect, row.resourceType, row.status}, "\x00")]++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return RuleSet{}, err
|
||||
}
|
||||
grouped := make([]RuleCount, 0, len(counts))
|
||||
for key, count := range counts {
|
||||
parts := strings.Split(key, "\x00")
|
||||
grouped = append(grouped, RuleCount{SubjectType: parts[0], Effect: parts[1], ResourceType: parts[2], Status: parts[3], Count: count})
|
||||
}
|
||||
sort.Slice(grouped, func(i, j int) bool {
|
||||
left := grouped[i]
|
||||
right := grouped[j]
|
||||
return strings.Join([]string{left.SubjectType, left.Effect, left.ResourceType, left.Status}, "\x00") <
|
||||
strings.Join([]string{right.SubjectType, right.Effect, right.ResourceType, right.Status}, "\x00")
|
||||
})
|
||||
return RuleSet{
|
||||
Total: int64(len(allDigests)),
|
||||
SHA256: aggregateSHA256(allDigests),
|
||||
AllowCount: int64(len(allowDigests)),
|
||||
AllowSHA256: aggregateSHA256(allowDigests),
|
||||
DenyCount: int64(len(denyDigests)),
|
||||
DenySHA256: aggregateSHA256(denyDigests),
|
||||
Counts: grouped,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadArchiveManifest(ctx context.Context, pool *pgxpool.Pool) (*ArchiveManifest, error) {
|
||||
var available bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT to_regclass('gateway_access_rule_migration_batches') IS NOT NULL
|
||||
AND to_regclass('gateway_access_rule_allow_archive') IS NOT NULL`).Scan(&available); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, nil
|
||||
}
|
||||
manifest := &ArchiveManifest{MigrationBatch: MigrationBatch}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT allow_count, allow_sha256, deny_count, deny_sha256
|
||||
FROM gateway_access_rule_migration_batches
|
||||
WHERE migration_batch = $1`, MigrationBatch).Scan(
|
||||
&manifest.ManifestAllowCount,
|
||||
&manifest.ManifestAllowSHA,
|
||||
&manifest.ManifestDenyCount,
|
||||
&manifest.ManifestDenySHA,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT row_sha256
|
||||
FROM gateway_access_rule_allow_archive
|
||||
WHERE migration_batch = $1
|
||||
ORDER BY rule_id`, MigrationBatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
digests := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var digest string
|
||||
if err := rows.Scan(&digest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digests = append(digests, digest)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifest.ArchivedAllowCount = int64(len(digests))
|
||||
manifest.ArchivedAllowSHA = aggregateSHA256(digests)
|
||||
manifest.Consistent = manifest.ManifestAllowCount == manifest.ArchivedAllowCount && manifest.ManifestAllowSHA == manifest.ArchivedAllowSHA
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func rowSHA256(row ruleDigestRow) string {
|
||||
return sha256Hex(strings.Join([]string{
|
||||
row.id, row.subjectType, row.subjectID, row.resourceType,
|
||||
row.resourceID, row.effect, row.priority, row.minPermissionLevel,
|
||||
row.conditions, row.metadata, row.status, row.createdEpoch, row.updatedEpoch,
|
||||
}, "\x1f"))
|
||||
}
|
||||
|
||||
func aggregateSHA256(rowDigests []string) string {
|
||||
return sha256Hex(strings.Join(rowDigests, "\n"))
|
||||
}
|
||||
|
||||
func sha256Hex(value string) string {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil && value == strings.ToLower(value)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package accessruleaudit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerifyMigrationAcceptsMatchingArchiveAndUnchangedDeny(t *testing.T) {
|
||||
before := auditSnapshot(3, "allow-before", 2, "deny-before", nil)
|
||||
after := auditSnapshot(0, "", 2, "deny-before", &ArchiveManifest{
|
||||
MigrationBatch: MigrationBatch,
|
||||
ManifestAllowCount: 3,
|
||||
ManifestAllowSHA: sha256Hex("allow-before"),
|
||||
ArchivedAllowCount: 3,
|
||||
ArchivedAllowSHA: sha256Hex("allow-before"),
|
||||
ManifestDenyCount: 2,
|
||||
ManifestDenySHA: sha256Hex("deny-before"),
|
||||
Consistent: true,
|
||||
})
|
||||
if err := VerifyMigration(before, after); err != nil {
|
||||
t.Fatalf("verify matching migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMigrationRejectsChangedDenyOrMissingArchive(t *testing.T) {
|
||||
before := auditSnapshot(1, "allow", 1, "deny", nil)
|
||||
withoutArchive := auditSnapshot(0, "", 1, "deny", nil)
|
||||
if err := VerifyMigration(before, withoutArchive); err == nil {
|
||||
t.Fatal("missing archive must fail verification")
|
||||
}
|
||||
changedDeny := auditSnapshot(0, "", 1, "changed-deny", &ArchiveManifest{
|
||||
MigrationBatch: MigrationBatch,
|
||||
ManifestAllowCount: 1,
|
||||
ManifestAllowSHA: sha256Hex("allow"),
|
||||
ArchivedAllowCount: 1,
|
||||
ArchivedAllowSHA: sha256Hex("allow"),
|
||||
ManifestDenyCount: 1,
|
||||
ManifestDenySHA: sha256Hex("deny"),
|
||||
Consistent: true,
|
||||
})
|
||||
if err := VerifyMigration(before, changedDeny); err == nil {
|
||||
t.Fatal("changed deny hash must fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func auditSnapshot(allowCount int64, allowSeed string, denyCount int64, denySeed string, archive *ArchiveManifest) Snapshot {
|
||||
counts := make([]RuleCount, 0, 2)
|
||||
if allowCount > 0 {
|
||||
counts = append(counts, RuleCount{SubjectType: "api_key", Effect: "allow", ResourceType: "platform_model", Status: "active", Count: allowCount})
|
||||
}
|
||||
if denyCount > 0 {
|
||||
counts = append(counts, RuleCount{SubjectType: "api_key", Effect: "deny", ResourceType: "platform_model", Status: "active", Count: denyCount})
|
||||
}
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
GeneratedAt: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
SecretSafe: true,
|
||||
Live: RuleSet{
|
||||
Total: allowCount + denyCount,
|
||||
SHA256: sha256Hex("all-" + allowSeed + "-" + denySeed),
|
||||
AllowCount: allowCount,
|
||||
AllowSHA256: sha256Hex(allowSeed),
|
||||
DenyCount: denyCount,
|
||||
DenySHA256: sha256Hex(denySeed),
|
||||
Counts: counts,
|
||||
},
|
||||
Archive: archive,
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// listAccessRules godoc
|
||||
// @Summary 列出访问规则
|
||||
// @Description 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。
|
||||
// @Description 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的分层访问规则。主体当前层无 allow 时继承上级,存在 allow 时仅允许白名单,deny 始终优先。
|
||||
// @Tags access-rules
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -33,7 +33,7 @@ func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// listAPIKeyAccessRules godoc
|
||||
// @Summary 列出 API Key 访问规则
|
||||
// @Description 返回当前本地用户可管理的 API Key 访问规则。
|
||||
// @Description 返回当前本地用户拥有的 API Key 访问规则;不会混入其他用户或其他 API Key 的规则。
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -60,7 +60,7 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// listAPIKeyAssignableModels godoc
|
||||
// @Summary 列出 API Key 可分配模型
|
||||
// @Description 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
||||
// @Description 按当前用户自身的租户、用户组和用户分层白名单返回可分配给 API Key 的启用模型,不应用任何 API Key 层规则。
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -88,7 +88,7 @@ func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// listAPIKeyAssignableModelsForKey godoc
|
||||
// @Summary 列出指定 API Key 可分配模型
|
||||
// @Description 返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。
|
||||
// @Description 返回全局启用、命中指定 API Key 的租户/用户组/用户基线且符合 Key scope 的可分配平台来源;当前 Key 的 allow/deny 不缩减候选,仅作为已有规则有效性诊断返回。
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -123,7 +123,7 @@ func (s *Server) listAPIKeyAssignableModelsForKey(w http.ResponseWriter, r *http
|
||||
|
||||
// createAccessRule godoc
|
||||
// @Summary 创建访问规则
|
||||
// @Description 管理端创建一条访问控制规则。
|
||||
// @Description 管理端创建一条访问控制规则;同一主体层存在任意有效 allow 后该层启用白名单,deny 始终优先。
|
||||
// @Tags access-rules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -161,7 +161,7 @@ func (s *Server) createAccessRule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// batchAccessRules godoc
|
||||
// @Summary 批量写入访问规则
|
||||
// @Description 管理端为同一主体批量新增、更新或删除资源访问规则。
|
||||
// @Description 管理端为同一主体批量新增、更新或删除资源访问规则。清空该主体全部 allow 会恢复上级继承。
|
||||
// @Tags access-rules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -194,7 +194,7 @@ func (s *Server) batchAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// batchAPIKeyAccessRules godoc
|
||||
// @Summary 批量写入 API Key 访问规则
|
||||
// @Description 当前本地用户为自己的 API Key 批量新增、更新或删除可访问资源。
|
||||
// @Description 当前本地用户为自己的 API Key 批量新增、更新或删除白名单/拒绝资源;Key 无 allow 时继承父级范围,存在 allow 后仅允许命中项。
|
||||
// @Tags api-keys
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
||||
@@ -222,9 +222,16 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||
batchKeyRule(adminToken, keyLegacyID, legacyTextModel.ID, "allow", http.StatusOK)
|
||||
batchKeyRule(adminToken, keyScopeID, scopeTextModel.ID, "allow", http.StatusOK)
|
||||
createRule("user_group", groupAID, imageModel.ID, "allow")
|
||||
createRule("user_group", groupAID, multiModel.ID, "allow")
|
||||
createRule("user_group", groupAID, multiSecondModel.ID, "allow")
|
||||
createRule("user_group", groupAID, keyDenyImageModel.ID, "allow")
|
||||
createRule("user_group", groupAID, scopeTextModel.ID, "allow")
|
||||
createRule("user_group", groupAID, textModel.ID, "deny")
|
||||
createRule("user_group", groupAID, legacyTextModel.ID, "deny")
|
||||
createRule("user_group", groupBID, groupBImageModel.ID, "allow")
|
||||
createRule("user_group", groupBID, textModel.ID, "allow")
|
||||
createRule("user_group", groupBID, scopeTextModel.ID, "allow")
|
||||
createRule("user_group", groupBID, legacyTextModel.ID, "allow")
|
||||
doJSON(t, server.URL, http.MethodPatch, "/api/v1/api-keys/"+keyScopeID+"/scopes", adminToken, map[string]any{
|
||||
"scopes": []string{"image"},
|
||||
}, http.StatusOK, nil)
|
||||
@@ -233,6 +240,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||
createRule("api_key", keyAID, textModel.ID, "allow")
|
||||
createRule("api_key", keyScopeID, disabledImageModel.ID, "allow")
|
||||
batchKeyRule(adminToken, keyAID, imageModel.ID, "allow", http.StatusOK)
|
||||
batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "allow", http.StatusOK)
|
||||
batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "deny", http.StatusOK)
|
||||
|
||||
loadAssignable := func(token string, keyID string) modelAccessAssignableResponse {
|
||||
@@ -252,7 +260,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||
t.Fatalf("key rules incorrectly removed a model from the assignable set: %+v", assignable.Items)
|
||||
}
|
||||
if containsModelID(assignable.Items, disabledImageModel.ID) || containsModelID(assignable.Items, groupBImageModel.ID) {
|
||||
t.Fatalf("disabled or another-group-exclusive source leaked into assignable models: %+v", assignable.Items)
|
||||
t.Fatalf("disabled or group-A-whitelist-excluded source leaked into assignable models: %+v", assignable.Items)
|
||||
}
|
||||
for _, model := range assignable.Items {
|
||||
if model.ID == multiModel.ID && (len(model.ModelType) != 1 || model.ModelType[0] != "image_generate") {
|
||||
@@ -292,22 +300,22 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||
if containsModelID(platformModels.Items, textModel.ID) {
|
||||
t.Fatalf("group-denied text model leaked into key model list: %+v", platformModels.Items)
|
||||
}
|
||||
if !containsModelID(platformModels.Items, imageModel.ID) || containsModelID(platformModels.Items, keyDenyImageModel.ID) || containsModelID(platformModels.Items, disabledImageModel.ID) {
|
||||
if !containsModelID(platformModels.Items, imageModel.ID) || containsModelID(platformModels.Items, multiModel.ID) || containsModelID(platformModels.Items, keyDenyImageModel.ID) || containsModelID(platformModels.Items, disabledImageModel.ID) {
|
||||
t.Fatalf("key allow/deny or global availability was not reflected in rich list: %+v", platformModels.Items)
|
||||
}
|
||||
if !containsModelID(loadAssignable(adminToken, keyAInheritedID).Items, imageModel.ID) {
|
||||
t.Fatalf("assignable list must ignore another key's exclusive rule")
|
||||
t.Fatalf("assignable list must ignore another key's whitelist")
|
||||
}
|
||||
var inheritedPlatformModels struct {
|
||||
Items []modelAccessFixture `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyAInheritedSecret, nil, http.StatusOK, &inheritedPlatformModels)
|
||||
if containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) {
|
||||
t.Fatalf("key exclusive/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items)
|
||||
if !containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) || !containsModelID(inheritedPlatformModels.Items, multiModel.ID) {
|
||||
t.Fatalf("key whitelist/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items)
|
||||
}
|
||||
groupBAssignable := loadAssignable(userBToken, keyBImageID)
|
||||
if containsModelID(groupBAssignable.Items, imageModel.ID) || !containsModelID(groupBAssignable.Items, groupBImageModel.ID) {
|
||||
t.Fatalf("group exclusive rules were not applied to key candidates: %+v", groupBAssignable.Items)
|
||||
t.Fatalf("group whitelist rules were not applied to key candidates: %+v", groupBAssignable.Items)
|
||||
}
|
||||
var groupBChatModels struct {
|
||||
Items []modelAccessFixture `json:"items"`
|
||||
@@ -326,7 +334,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyASecret, nil, http.StatusOK, &openAIList)
|
||||
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyAInheritedSecret, nil, http.StatusOK, &openAIList)
|
||||
if openAIList.Object != "list" || countOpenAIModel(openAIList.Data, multiName) != 1 {
|
||||
t.Fatalf("openai model list is not deduplicated: %+v", openAIList)
|
||||
}
|
||||
@@ -373,21 +381,34 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||
t.Logf("多来源模型:初始来源=2,排除一个后 OpenAI 逻辑模型=1,全部排除后=0;调用状态=200/404")
|
||||
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
|
||||
"model": imageName, "prompt": "exclusive key", "runMode": "simulation", "simulation": true,
|
||||
"model": imageName, "prompt": "key whitelist", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusOK, nil)
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyAInheritedSecret, map[string]any{
|
||||
"model": imageName, "prompt": "sibling key", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusNotFound, nil)
|
||||
}, http.StatusOK, nil)
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
|
||||
"model": keyDenyImageName, "prompt": "key deny", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusNotFound, nil)
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyBImageSecret, map[string]any{
|
||||
"model": groupBImageName, "prompt": "group B exclusive", "runMode": "simulation", "simulation": true,
|
||||
"model": groupBImageName, "prompt": "group B whitelist", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusOK, nil)
|
||||
t.Logf("实际调用:KEY 专属 200,兄弟 KEY 404,KEY 排除 404,组 B 专属 200")
|
||||
var taskCountBefore, taskCountAfter int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountBefore); err != nil {
|
||||
t.Fatalf("count tasks before rejected whitelist request: %v", err)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
|
||||
"model": multiName, "prompt": "not in current key whitelist", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusNotFound, nil)
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountAfter); err != nil {
|
||||
t.Fatalf("count tasks after rejected whitelist request: %v", err)
|
||||
}
|
||||
if taskCountAfter != taskCountBefore {
|
||||
t.Fatalf("rejected whitelist request created a task: before=%d after=%d", taskCountBefore, taskCountAfter)
|
||||
}
|
||||
t.Logf("实际调用:KEY 白名单允许 200,兄弟 KEY 不受影响 200,KEY deny 404,组 B 白名单 200;拒绝请求未创建任务")
|
||||
|
||||
// Group B can still use text: stale allows whose owners lost group access or
|
||||
// scope are removed from the global exclusive set.
|
||||
// Group B can still use text: group A and stale API-key allows are never
|
||||
// evaluated for this group's keys.
|
||||
if !containsModelID(loadAssignable(userBToken, keyBID).Items, textModel.ID) {
|
||||
t.Fatalf("group B chat key candidate list lost the group-A denied model")
|
||||
}
|
||||
|
||||
@@ -824,21 +824,22 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
||||
"minPermissionLevel": 0,
|
||||
"status": "active",
|
||||
}, http.StatusCreated, nil)
|
||||
var deniedTask struct {
|
||||
Task struct {
|
||||
Status string `json:"status"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
} `json:"task"`
|
||||
var deniedTaskCountBefore, deniedTaskCountAfter int
|
||||
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&deniedTaskCountBefore); err != nil {
|
||||
t.Fatalf("count tasks before denied model request: %v", err)
|
||||
}
|
||||
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
|
||||
"model": deniedModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"messages": []map[string]any{{"role": "user", "content": "permission deny"}},
|
||||
}, "permission-deny-"+suffixText, http.StatusNotFound, nil, &deniedTask.Task)
|
||||
if deniedTask.Task.Status != "failed" || deniedTask.Task.ErrorCode != "no_model_candidate" {
|
||||
t.Fatalf("deny access rule should hide denied model from runtime candidates: %+v", deniedTask.Task)
|
||||
}, http.StatusNotFound, nil)
|
||||
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&deniedTaskCountAfter); err != nil {
|
||||
t.Fatalf("count tasks after denied model request: %v", err)
|
||||
}
|
||||
if deniedTaskCountAfter != deniedTaskCountBefore {
|
||||
t.Fatalf("deny access rule rejection created a task: before=%d after=%d", deniedTaskCountBefore, deniedTaskCountAfter)
|
||||
}
|
||||
var restrictedModels struct {
|
||||
Items []struct {
|
||||
@@ -873,38 +874,27 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
||||
"minPermissionLevel": 0,
|
||||
"status": "active",
|
||||
}, http.StatusCreated, nil)
|
||||
var blockedControlledTask struct {
|
||||
Task struct {
|
||||
Status string `json:"status"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
|
||||
"model": controlledModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"messages": []map[string]any{{"role": "user", "content": "allow should block other keys"}},
|
||||
}, "permission-allow-block-"+suffixText, http.StatusNotFound, nil, &blockedControlledTask.Task)
|
||||
if blockedControlledTask.Task.Status != "failed" || blockedControlledTask.Task.ErrorCode != "no_model_candidate" {
|
||||
t.Fatalf("allow access rule should make the resource unavailable to unmatched subjects: %+v", blockedControlledTask.Task)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/access-rules", loginResponse.AccessToken, map[string]any{
|
||||
"subjectType": "api_key",
|
||||
"subjectId": chatOnlyAPIKeyResponse.APIKey.ID,
|
||||
"resourceType": "platform_model",
|
||||
"resourceId": controlledPlatformModel.ID,
|
||||
"effect": "allow",
|
||||
"priority": 10,
|
||||
"minPermissionLevel": 0,
|
||||
"status": "active",
|
||||
}, http.StatusCreated, nil)
|
||||
var allowedControlledTask struct {
|
||||
var siblingControlledTask struct {
|
||||
Task struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
|
||||
"model": controlledModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
"messages": []map[string]any{{"role": "user", "content": "another key whitelist must not affect this key"}},
|
||||
}, "permission-allow-sibling-"+suffixText, http.StatusOK, nil, &siblingControlledTask.Task)
|
||||
if siblingControlledTask.Task.Status != "succeeded" {
|
||||
t.Fatalf("another key's allow rule must not restrict this key: %+v", siblingControlledTask.Task)
|
||||
}
|
||||
var allowedControlledTask struct {
|
||||
Task struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"task"`
|
||||
}
|
||||
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
|
||||
"model": controlledModel,
|
||||
"runMode": "simulation",
|
||||
"simulation": true,
|
||||
@@ -912,7 +902,7 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
||||
"messages": []map[string]any{{"role": "user", "content": "allow should pass"}},
|
||||
}, "permission-allow-pass-"+suffixText, http.StatusOK, nil, &allowedControlledTask.Task)
|
||||
if allowedControlledTask.Task.Status != "succeeded" {
|
||||
t.Fatalf("matching allow access rule should make the controlled model usable: %+v", allowedControlledTask.Task)
|
||||
t.Fatalf("matching current-key allow rule should make the controlled model usable: %+v", allowedControlledTask.Task)
|
||||
}
|
||||
|
||||
var customPricingRuleSet struct {
|
||||
|
||||
@@ -632,7 +632,7 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// listPlayableModels godoc
|
||||
// @Summary 列出可调用模型
|
||||
// @Description 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。
|
||||
// @Description 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API 调用的平台来源;其他主体规则不参与求值。
|
||||
// @Tags playground
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -1201,6 +1201,16 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
writeTaskError(status, err.Error(), nil, clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
if err := s.runner.ValidateModelAccess(r.Context(), kind, model, prepared.Body, user); err != nil {
|
||||
if errors.Is(err, store.ErrNoModelCandidate) {
|
||||
applyRunErrorHeaders(w, err)
|
||||
writeTaskError(statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), runErrorCode(err))
|
||||
return
|
||||
}
|
||||
s.logger.Error("validate model access failed", "kind", kind, "error_category", "model_access_validation_failed", "error", err)
|
||||
writeTaskError(http.StatusInternalServerError, "validate model access failed", nil, "model_access_validation_failed")
|
||||
return
|
||||
}
|
||||
|
||||
runMode, err := s.admittedTaskRunMode(admission, prepared.Body)
|
||||
if err != nil {
|
||||
|
||||
@@ -26,7 +26,7 @@ func (s *Server) listLegacyPlayableModels(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// listOpenAIModels godoc
|
||||
// @Summary 列出 OpenAI 兼容模型
|
||||
// @Description 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。
|
||||
// @Description 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回去重后的逻辑模型列表;其他主体规则不参与求值。
|
||||
// @Tags openai-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
|
||||
@@ -25,21 +25,7 @@ type EstimateResult struct {
|
||||
}
|
||||
|
||||
func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
|
||||
body = normalizeRequest(kind, body)
|
||||
modelType := modelTypeFromKind(kind, body)
|
||||
candidates, err := s.store.ListModelCandidates(ctx, model, modelType, user)
|
||||
if err != nil {
|
||||
return EstimateResult{}, err
|
||||
}
|
||||
candidates, err = filterCandidatesByRequestedPlatform(candidates, body)
|
||||
if err != nil {
|
||||
return EstimateResult{}, err
|
||||
}
|
||||
candidates, _, err = filterRuntimeCandidatesByRequest(kind, model, modelType, body, candidates)
|
||||
if err != nil {
|
||||
return EstimateResult{}, err
|
||||
}
|
||||
candidates, _, err = filterRuntimeCandidatesByOutputTokens(kind, model, modelType, body, candidates)
|
||||
candidates, body, err := s.candidatesForRequest(ctx, kind, model, body, user)
|
||||
if err != nil {
|
||||
return EstimateResult{}, err
|
||||
}
|
||||
@@ -58,6 +44,37 @@ func (s *Service) Estimate(ctx context.Context, kind string, model string, body
|
||||
return buildEstimateResult(estimates, pricingRequestFingerprint(kind, model, body))
|
||||
}
|
||||
|
||||
// ValidateModelAccess resolves the same permission-filtered candidates used by
|
||||
// execution before a task row is created. This keeps authorization failures
|
||||
// out of task history while execution still re-resolves candidates to avoid
|
||||
// using stale routing or capacity state.
|
||||
func (s *Service) ValidateModelAccess(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) error {
|
||||
_, _, err := s.candidatesForRequest(ctx, kind, model, body, user)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) candidatesForRequest(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) ([]store.RuntimeModelCandidate, map[string]any, error) {
|
||||
body = normalizeRequest(kind, body)
|
||||
modelType := modelTypeFromKind(kind, body)
|
||||
candidates, err := s.store.ListModelCandidates(ctx, model, modelType, user)
|
||||
if err != nil {
|
||||
return nil, body, err
|
||||
}
|
||||
candidates, err = filterCandidatesByRequestedPlatform(candidates, body)
|
||||
if err != nil {
|
||||
return nil, body, err
|
||||
}
|
||||
candidates, _, err = filterRuntimeCandidatesByRequest(kind, model, modelType, body, candidates)
|
||||
if err != nil {
|
||||
return nil, body, err
|
||||
}
|
||||
candidates, _, err = filterRuntimeCandidatesByOutputTokens(kind, model, modelType, body, candidates)
|
||||
if err != nil {
|
||||
return nil, body, err
|
||||
}
|
||||
return candidates, body, nil
|
||||
}
|
||||
|
||||
func (s *Service) estimatedBillings(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) []any {
|
||||
usage := clients.Usage{InputTokens: estimateRequestTokens(body)}
|
||||
if isTextGenerationKind(kind) {
|
||||
|
||||
@@ -43,43 +43,25 @@ func (s *Store) enabledPlatformModels(ctx context.Context) ([]PlatformModel, []P
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByLayeredAccess(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(models))
|
||||
layers := accessRuleLayers(user, true)
|
||||
rules, err := s.listActiveAccessRulesForLayers(ctx, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baselineRules, apiKeyRules := splitLayeredAccessRules(rules)
|
||||
baseline := filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user))
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
return baseline, nil
|
||||
filtered := filterPlatformModelsByAccessLayers(models, rules, layers, permissionLevel(user))
|
||||
if user != nil && strings.TrimSpace(user.APIKeyID) != "" {
|
||||
filtered = filterPlatformModelsByAPIKeyScopes(filtered, user.APIKeyScopes)
|
||||
}
|
||||
apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterPlatformModelsByLayeredRuleSet(user, baseline, baselineRules, apiKeyRules, apiKeyUsers), nil
|
||||
}
|
||||
|
||||
func filterPlatformModelsByLayeredRuleSet(user *auth.User, baseline []PlatformModel, baselineRules []AccessRule, apiKeyRules []AccessRule, apiKeyUsers map[string]*auth.User) []PlatformModel {
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
return baseline
|
||||
}
|
||||
keyFiltered := make([]PlatformModel, 0, len(baseline))
|
||||
for _, model := range baseline {
|
||||
effectiveRules := effectiveAPIKeyRulesForPlatformModel(apiKeyRules, baselineRules, apiKeyUsers, model)
|
||||
if platformModelAllowedByAccessRules(model, effectiveRules, apiKeyAccessRuleSubjects(user), permissionLevel(user)) {
|
||||
keyFiltered = append(keyFiltered, model)
|
||||
}
|
||||
}
|
||||
return filterPlatformModelsByAPIKeyScopes(keyFiltered, user.APIKeyScopes)
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByBaselineAccess(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(models))
|
||||
layers := accessRuleLayers(user, false)
|
||||
rules, err := s.listActiveAccessRulesForLayers(ctx, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baselineRules, _ := splitLayeredAccessRules(rules)
|
||||
return filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user)), nil
|
||||
return filterPlatformModelsByAccessLayers(models, rules, layers, permissionLevel(user)), nil
|
||||
}
|
||||
|
||||
func (s *Store) filterRuntimeCandidatesByLayeredAccess(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) {
|
||||
@@ -90,31 +72,20 @@ func (s *Store) filterRuntimeCandidatesByLayeredAccess(ctx context.Context, user
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, candidateAccessResources(candidates))
|
||||
layers := accessRuleLayers(accessUser, true)
|
||||
rules, err := s.listActiveAccessRulesForLayers(ctx, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baselineRules, apiKeyRules := splitLayeredAccessRules(rules)
|
||||
baseline := filterCandidatesByRuleSet(candidates, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser))
|
||||
if accessUser == nil || strings.TrimSpace(accessUser.APIKeyID) == "" {
|
||||
return baseline, nil
|
||||
}
|
||||
apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyFiltered := make([]RuntimeModelCandidate, 0, len(baseline))
|
||||
for _, candidate := range baseline {
|
||||
effectiveRules := effectiveAPIKeyRulesForCandidate(apiKeyRules, baselineRules, apiKeyUsers, candidate)
|
||||
if candidateAllowedByAccessRules(candidate, effectiveRules, apiKeyAccessRuleSubjects(accessUser), permissionLevel(accessUser)) {
|
||||
keyFiltered = append(keyFiltered, candidate)
|
||||
}
|
||||
}
|
||||
filtered := make([]RuntimeModelCandidate, 0, len(keyFiltered))
|
||||
for _, candidate := range keyFiltered {
|
||||
if modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) {
|
||||
filtered = append(filtered, candidate)
|
||||
filtered := filterCandidatesByAccessLayers(candidates, rules, layers, permissionLevel(accessUser))
|
||||
if accessUser != nil && strings.TrimSpace(accessUser.APIKeyID) != "" {
|
||||
scoped := make([]RuntimeModelCandidate, 0, len(filtered))
|
||||
for _, candidate := range filtered {
|
||||
if modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) {
|
||||
scoped = append(scoped, candidate)
|
||||
}
|
||||
}
|
||||
filtered = scoped
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
@@ -135,12 +106,10 @@ func (s *Store) ListAPIKeyAssignablePlatformModelsForKey(ctx context.Context, us
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(enabledModels))
|
||||
baseline, err := s.filterPlatformModelsByBaselineAccess(ctx, accessUser, enabledModels)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
baselineRules, _ := splitLayeredAccessRules(rules)
|
||||
baseline := filterPlatformModelsByRuleSet(enabledModels, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser))
|
||||
scoped := filterPlatformModelsByAPIKeyScopes(baseline, accessUser.APIKeyScopes)
|
||||
ownedRules, err := s.ListAPIKeyAccessRules(ctx, user)
|
||||
if err != nil {
|
||||
@@ -185,129 +154,140 @@ WHERE k.id = $1::uuid
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
func effectiveAPIKeyRulesForPlatformModel(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, model PlatformModel) []AccessRule {
|
||||
effective := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
accessUser := users[rule.SubjectID]
|
||||
if accessUser == nil || !accessRuleMatchesPlatformModel(rule, model) ||
|
||||
!platformModelAllowedByAccessRules(model, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) ||
|
||||
len(modelaccess.FilterModelTypes(accessUser.APIKeyScopes, model.ModelType)) == 0 {
|
||||
continue
|
||||
}
|
||||
effective = append(effective, rule)
|
||||
}
|
||||
return effective
|
||||
type accessRuleLayer struct {
|
||||
subjectType string
|
||||
subjectIDs map[string]bool
|
||||
}
|
||||
|
||||
func effectiveAPIKeyRulesForCandidate(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, candidate RuntimeModelCandidate) []AccessRule {
|
||||
effective := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
accessUser := users[rule.SubjectID]
|
||||
if accessUser == nil || !accessRuleMatchesCandidate(rule, candidate) ||
|
||||
!candidateAllowedByAccessRules(candidate, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) ||
|
||||
!modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) {
|
||||
var accessRuleLayerOrder = []string{"tenant", "user_group", "user", "api_key"}
|
||||
|
||||
func accessRuleLayers(user *auth.User, includeAPIKey bool) []accessRuleLayer {
|
||||
subjects := accessRuleSubjects(user)
|
||||
layers := make([]accessRuleLayer, 0, len(accessRuleLayerOrder))
|
||||
for _, subjectType := range accessRuleLayerOrder {
|
||||
if subjectType == "api_key" && !includeAPIKey {
|
||||
continue
|
||||
}
|
||||
effective = append(effective, rule)
|
||||
prefix := subjectType + ":"
|
||||
ids := map[string]bool{}
|
||||
for subject := range subjects {
|
||||
if strings.HasPrefix(subject, prefix) {
|
||||
ids[strings.TrimPrefix(subject, prefix)] = true
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
layers = append(layers, accessRuleLayer{subjectType: subjectType, subjectIDs: ids})
|
||||
}
|
||||
}
|
||||
return effective
|
||||
return layers
|
||||
}
|
||||
|
||||
func (s *Store) apiKeyAccessRuleUsers(ctx context.Context, rules []AccessRule) (map[string]*auth.User, error) {
|
||||
ids := make([]string, 0, len(rules))
|
||||
seen := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
if rule.SubjectType != "api_key" || rule.SubjectID == "" || seen[rule.SubjectID] {
|
||||
continue
|
||||
func (s *Store) listActiveAccessRulesForLayers(ctx context.Context, layers []accessRuleLayer) ([]AccessRule, error) {
|
||||
subjects := make([]string, 0)
|
||||
for _, layer := range layers {
|
||||
for id := range layer.subjectIDs {
|
||||
subjects = append(subjects, layer.subjectType+":"+id)
|
||||
}
|
||||
seen[rule.SubjectID] = true
|
||||
ids = append(ids, rule.SubjectID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[string]*auth.User{}, nil
|
||||
if len(subjects) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT k.id::text, k.scopes, u.id::text,
|
||||
COALESCE(u.gateway_tenant_id::text, ''), COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, ''),
|
||||
u.roles, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''), COALESCE(g.group_key, '')
|
||||
FROM gateway_api_keys k
|
||||
JOIN gateway_users u ON u.id = k.gateway_user_id
|
||||
LEFT JOIN gateway_user_groups g ON g.id = COALESCE(k.user_group_id, u.default_user_group_id)
|
||||
WHERE k.id = ANY($1::uuid[])
|
||||
AND k.status = 'active'
|
||||
AND k.deleted_at IS NULL
|
||||
AND (k.expires_at IS NULL OR k.expires_at > now())
|
||||
AND u.status = 'active'
|
||||
AND u.deleted_at IS NULL`, ids)
|
||||
SELECT `+accessRuleColumns+`
|
||||
FROM gateway_access_rules
|
||||
WHERE status = 'active'
|
||||
AND (subject_type || ':' || subject_id::text) = ANY($1)
|
||||
ORDER BY subject_type ASC, priority ASC, created_at ASC`, subjects)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := map[string]*auth.User{}
|
||||
rules := make([]AccessRule, 0)
|
||||
for rows.Next() {
|
||||
var apiKeyID string
|
||||
var scopesBytes []byte
|
||||
var rolesBytes []byte
|
||||
var gatewayUserID string
|
||||
var gatewayTenantID string
|
||||
var tenantID string
|
||||
var tenantKey string
|
||||
var userGroupID string
|
||||
var userGroupKey string
|
||||
if err := rows.Scan(&apiKeyID, &scopesBytes, &gatewayUserID, &gatewayTenantID, &tenantID, &tenantKey, &rolesBytes, &userGroupID, &userGroupKey); err != nil {
|
||||
item, err := scanAccessRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupKeys := []string(nil)
|
||||
if userGroupKey != "" {
|
||||
groupKeys = []string{userGroupKey}
|
||||
}
|
||||
users[apiKeyID] = &auth.User{
|
||||
GatewayUserID: gatewayUserID,
|
||||
GatewayTenantID: gatewayTenantID,
|
||||
TenantID: tenantID,
|
||||
TenantKey: tenantKey,
|
||||
Roles: decodeStringArray(rolesBytes),
|
||||
UserGroupID: userGroupID,
|
||||
UserGroupKey: userGroupKey,
|
||||
UserGroupKeys: groupKeys,
|
||||
APIKeyID: apiKeyID,
|
||||
APIKeyScopes: decodeStringArray(scopesBytes),
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
return users, rows.Err()
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func splitLayeredAccessRules(rules []AccessRule) ([]AccessRule, []AccessRule) {
|
||||
baseline := make([]AccessRule, 0, len(rules))
|
||||
apiKeys := make([]AccessRule, 0, len(rules))
|
||||
func filterPlatformModelsByAccessLayers(models []PlatformModel, rules []AccessRule, layers []accessRuleLayer, level int) []PlatformModel {
|
||||
filtered := append([]PlatformModel(nil), models...)
|
||||
for _, layer := range layers {
|
||||
layerRules := accessRulesForLayer(rules, layer)
|
||||
if len(layerRules) == 0 {
|
||||
continue
|
||||
}
|
||||
next := make([]PlatformModel, 0, len(filtered))
|
||||
for _, model := range filtered {
|
||||
if platformModelAllowedBySubjectLayer(model, layerRules, level) {
|
||||
next = append(next, model)
|
||||
}
|
||||
}
|
||||
filtered = next
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterCandidatesByAccessLayers(candidates []RuntimeModelCandidate, rules []AccessRule, layers []accessRuleLayer, level int) []RuntimeModelCandidate {
|
||||
filtered := append([]RuntimeModelCandidate(nil), candidates...)
|
||||
for _, layer := range layers {
|
||||
layerRules := accessRulesForLayer(rules, layer)
|
||||
if len(layerRules) == 0 {
|
||||
continue
|
||||
}
|
||||
next := make([]RuntimeModelCandidate, 0, len(filtered))
|
||||
for _, candidate := range filtered {
|
||||
if candidateAllowedBySubjectLayer(candidate, layerRules, level) {
|
||||
next = append(next, candidate)
|
||||
}
|
||||
}
|
||||
filtered = next
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func accessRulesForLayer(rules []AccessRule, layer accessRuleLayer) []AccessRule {
|
||||
filtered := make([]AccessRule, 0)
|
||||
for _, rule := range rules {
|
||||
if rule.SubjectType == "api_key" {
|
||||
apiKeys = append(apiKeys, rule)
|
||||
} else {
|
||||
baseline = append(baseline, rule)
|
||||
}
|
||||
}
|
||||
return baseline, apiKeys
|
||||
}
|
||||
|
||||
func filterPlatformModelsByRuleSet(models []PlatformModel, rules []AccessRule, subjects map[string]bool, level int) []PlatformModel {
|
||||
filtered := make([]PlatformModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if platformModelAllowedByAccessRules(model, rules, subjects, level) {
|
||||
filtered = append(filtered, model)
|
||||
if rule.SubjectType == layer.subjectType && layer.subjectIDs[rule.SubjectID] {
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterCandidatesByRuleSet(candidates []RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, level int) []RuntimeModelCandidate {
|
||||
filtered := make([]RuntimeModelCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidateAllowedByAccessRules(candidate, rules, subjects, level) {
|
||||
filtered = append(filtered, candidate)
|
||||
func platformModelAllowedBySubjectLayer(model PlatformModel, rules []AccessRule, level int) bool {
|
||||
return allowedBySubjectLayer(rules, level, func(rule AccessRule) bool {
|
||||
return accessRuleMatchesPlatformModel(rule, model)
|
||||
})
|
||||
}
|
||||
|
||||
func candidateAllowedBySubjectLayer(candidate RuntimeModelCandidate, rules []AccessRule, level int) bool {
|
||||
return allowedBySubjectLayer(rules, level, func(rule AccessRule) bool {
|
||||
return accessRuleMatchesCandidate(rule, candidate)
|
||||
})
|
||||
}
|
||||
|
||||
func allowedBySubjectLayer(rules []AccessRule, level int, matches func(AccessRule) bool) bool {
|
||||
hasAllow := false
|
||||
matchedAllow := false
|
||||
for _, rule := range rules {
|
||||
switch rule.Effect {
|
||||
case "allow":
|
||||
hasAllow = true
|
||||
if level >= rule.MinPermissionLevel && matches(rule) {
|
||||
matchedAllow = true
|
||||
}
|
||||
case "deny":
|
||||
if matches(rule) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
return !hasAllow || matchedAllow
|
||||
}
|
||||
|
||||
func filterPlatformModelsByAPIKeyScopes(models []PlatformModel, scopes []string) []PlatformModel {
|
||||
@@ -393,22 +373,6 @@ func stringValues(value any) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func baselineAccessRuleSubjects(user *auth.User) map[string]bool {
|
||||
subjects := accessRuleSubjects(user)
|
||||
if user != nil && user.APIKeyID != "" {
|
||||
delete(subjects, "api_key:"+user.APIKeyID)
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
func apiKeyAccessRuleSubjects(user *auth.User) map[string]bool {
|
||||
subjects := map[string]bool{}
|
||||
if user != nil && strings.TrimSpace(user.APIKeyID) != "" {
|
||||
subjects["api_key:"+strings.TrimSpace(user.APIKeyID)] = true
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
func permissionLevel(user *auth.User) int {
|
||||
if user == nil {
|
||||
return 0
|
||||
|
||||
@@ -8,90 +8,136 @@ import (
|
||||
)
|
||||
|
||||
func TestLayeredAccessDoesNotLetAPIKeyExpandBaseline(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", BaseModelID: "base-1"}
|
||||
groupUser := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
baselineRules := []AccessRule{{
|
||||
SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny",
|
||||
}}
|
||||
keyRules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow",
|
||||
}}
|
||||
baseline := filterPlatformModelsByRuleSet([]PlatformModel{model}, baselineRules, baselineAccessRuleSubjects(groupUser), 0)
|
||||
actual := filterPlatformModelsByRuleSet(baseline, keyRules, apiKeyAccessRuleSubjects(groupUser), 0)
|
||||
models := []PlatformModel{{ID: "model-1", PlatformID: "platform-1"}, {ID: "model-2", PlatformID: "platform-1"}}
|
||||
user := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-2", Effect: "allow"},
|
||||
}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if len(actual) != 0 {
|
||||
t.Fatalf("api key allow expanded denied baseline: %+v", actual)
|
||||
t.Fatalf("api key allow expanded its parent whitelist: %+v", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyAllowControlsOnlyMatchingKeys(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1"}
|
||||
func TestNoRulesInheritAllModels(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
user := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
actual := filterPlatformModelsByAccessLayers(models, nil, accessRuleLayers(user, true), 0)
|
||||
if !reflect.DeepEqual(actual, models) {
|
||||
t.Fatalf("models without rules = %+v, want %+v", actual, models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyAllowIsCurrentKeyWhitelistOnly(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
rules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow",
|
||||
}}
|
||||
for _, test := range []struct {
|
||||
keyID string
|
||||
want int
|
||||
}{{"key-a", 1}, {"key-b", 0}} {
|
||||
user := &auth.User{APIKeyID: test.keyID}
|
||||
actual := filterPlatformModelsByRuleSet([]PlatformModel{model}, rules, apiKeyAccessRuleSubjects(user), 0)
|
||||
if len(actual) != test.want {
|
||||
t.Fatalf("key %s received %d models, want %d", test.keyID, len(actual), test.want)
|
||||
want []string
|
||||
}{{"key-a", []string{"model-1"}}, {"key-b", []string{"model-1", "model-2"}}} {
|
||||
user := &auth.User{GatewayUserID: "user-1", APIKeyID: test.keyID}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("key %s received %v, want %v", test.keyID, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredAccessDenyWinsAndNoRulesInherit(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
keyUser := &auth.User{APIKeyID: "key-a", APIKeyScopes: []string{"chat"}}
|
||||
keyUsers := map[string]*auth.User{"key-a": keyUser}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(keyUser, []PlatformModel{model}, nil, nil, keyUsers); len(got) != 1 {
|
||||
t.Fatalf("key without rules did not inherit baseline: %+v", got)
|
||||
func TestUserGroupAllowDoesNotAffectOtherGroups(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
rules := []AccessRule{{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"}}
|
||||
for _, test := range []struct {
|
||||
groupID string
|
||||
want []string
|
||||
}{{"group-a", []string{"model-1"}}, {"group-b", []string{"model-1", "model-2"}}} {
|
||||
user := &auth.User{GatewayUserID: "user-1", UserGroupID: test.groupID}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, false), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("group %s received %v, want %v", test.groupID, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantGroupUserAndKeyWhitelistsIntersect(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"}}
|
||||
user := &auth.User{GatewayTenantID: "tenant-1", GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny"},
|
||||
{SubjectType: "tenant", SubjectID: "tenant-1", ResourceType: "platform_model", ResourceID: "a", Effect: "allow"},
|
||||
{SubjectType: "tenant", SubjectID: "tenant-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "c", Effect: "allow"},
|
||||
{SubjectType: "user", SubjectID: "user-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user", SubjectID: "user-1", ResourceType: "platform_model", ResourceID: "d", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "c", Effect: "allow"},
|
||||
}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(keyUser, []PlatformModel{model}, nil, rules, keyUsers); len(got) != 0 {
|
||||
t.Fatalf("matching deny did not override allow: %+v", got)
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"b"}) {
|
||||
t.Fatalf("layered whitelist result = %v, want [b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectUserIgnoresAPIKeyRules(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
func TestDenyWinsAndPlatformAllowIncludesChildren(t *testing.T) {
|
||||
models := []PlatformModel{
|
||||
{ID: "allowed", PlatformID: "platform-1"},
|
||||
{ID: "denied", PlatformID: "platform-1"},
|
||||
{ID: "other", PlatformID: "platform-2"},
|
||||
}
|
||||
user := &auth.User{GatewayUserID: "user-1", APIKeyID: "key-1"}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform", ResourceID: "platform-1", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "denied", Effect: "deny"},
|
||||
}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"allowed"}) {
|
||||
t.Fatalf("platform allow with child deny = %v, want [allowed]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameLayerAllowsUnionAndAnyDenyWins(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "a"}, {ID: "b"}, {ID: "c"}}
|
||||
layers := []accessRuleLayer{{subjectType: "user_group", subjectIDs: map[string]bool{"group-a": true, "group-b": true}}}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "a", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-b", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "b", Effect: "deny"},
|
||||
}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, layers, 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"a"}) {
|
||||
t.Fatalf("same-layer allow union and deny result = %v, want [a]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModelRuleMatchesEverySource(t *testing.T) {
|
||||
models := []PlatformModel{
|
||||
{ID: "source-a", BaseModelID: "base-1"},
|
||||
{ID: "source-b", BaseModelID: "base-1"},
|
||||
{ID: "source-c", BaseModelID: "base-2"},
|
||||
}
|
||||
user := &auth.User{GatewayUserID: "user-1"}
|
||||
rules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow",
|
||||
SubjectType: "user", SubjectID: "user-1", ResourceType: "base_model", ResourceID: "base-1", Effect: "allow",
|
||||
}}
|
||||
keyUsers := map[string]*auth.User{"key-a": {APIKeyID: "key-a", APIKeyScopes: []string{"chat"}}}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(&auth.User{GatewayUserID: "user-1"}, []PlatformModel{model}, nil, rules, keyUsers); len(got) != 1 {
|
||||
t.Fatalf("direct user was constrained by API key exclusive rule: %+v", got)
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, false), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"source-a", "source-b"}) {
|
||||
t.Fatalf("base-model whitelist result = %v, want both base-1 sources", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIneffectiveAPIKeyRuleDoesNotControlAnotherKey(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
staleRule := AccessRule{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"}
|
||||
groupDeny := AccessRule{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny"}
|
||||
keyUsers := map[string]*auth.User{
|
||||
"key-a": {APIKeyID: "key-a", UserGroupID: "group-a", APIKeyScopes: []string{"chat"}},
|
||||
func TestAllowPermissionLevelFailsClosed(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
rules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow", MinPermissionLevel: 2,
|
||||
}}
|
||||
user := &auth.User{GatewayUserID: "user-1", APIKeyID: "key-1"}
|
||||
if got := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 1); len(got) != 0 {
|
||||
t.Fatalf("insufficient permission inherited models instead of failing closed: %+v", got)
|
||||
}
|
||||
keyB := &auth.User{APIKeyID: "key-b", UserGroupID: "group-b", APIKeyScopes: []string{"chat"}}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(keyB, []PlatformModel{model}, []AccessRule{groupDeny}, []AccessRule{staleRule}, keyUsers); len(got) != 1 {
|
||||
t.Fatalf("stale key-a rule blocked authorized key-b: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformRuleOnlyControlsModelsItsKeyCanAccess(t *testing.T) {
|
||||
allowed := PlatformModel{ID: "allowed", PlatformID: "platform-1", ModelType: StringList{"image_generate"}}
|
||||
denied := PlatformModel{ID: "denied", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
rule := AccessRule{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform", ResourceID: "platform-1", Effect: "allow"}
|
||||
groupDeny := AccessRule{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "denied", Effect: "deny"}
|
||||
ruleUser := &auth.User{APIKeyID: "key-a", UserGroupID: "group-a", APIKeyScopes: []string{"image"}}
|
||||
users := map[string]*auth.User{"key-a": ruleUser}
|
||||
if got := effectiveAPIKeyRulesForPlatformModel([]AccessRule{rule}, []AccessRule{groupDeny}, users, allowed); len(got) != 1 {
|
||||
t.Fatalf("platform rule should control the allowed image model: %+v", got)
|
||||
}
|
||||
if got := effectiveAPIKeyRulesForPlatformModel([]AccessRule{rule}, []AccessRule{groupDeny}, users, denied); len(got) != 0 {
|
||||
t.Fatalf("platform rule controlled a group-denied or scope-denied model: %+v", got)
|
||||
if got := platformModelIDs(filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 2)); !reflect.DeepEqual(got, []string{"model-1"}) {
|
||||
t.Fatalf("sufficient permission result = %v, want [model-1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,3 +194,11 @@ func TestDiagnoseAPIKeyRulesExplainsEachInactiveLayer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func platformModelIDs(models []PlatformModel) []string {
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/accessruleaudit"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestAccessRuleAllowWhitelistMigrationArchivesAndRemovesLegacyAllows(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run access-rule migration PostgreSQL integration tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
admin, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect access-rule migration test database: %v", err)
|
||||
}
|
||||
t.Cleanup(admin.Close)
|
||||
var databaseName string
|
||||
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
|
||||
t.Fatalf("read access-rule migration test database name: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(databaseName), "test") {
|
||||
t.Fatalf("refusing to use non-test database %q", databaseName)
|
||||
}
|
||||
if _, err := admin.Exec(ctx, `CREATE EXTENSION IF NOT EXISTS pgcrypto`); err != nil {
|
||||
t.Fatalf("ensure pgcrypto: %v", err)
|
||||
}
|
||||
|
||||
schemaName := "gateway_access_rule_migration_" + strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "")
|
||||
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
|
||||
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
|
||||
t.Fatalf("create access-rule migration test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
|
||||
t.Errorf("drop access-rule migration test schema: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse access-rule migration test database URL: %v", err)
|
||||
}
|
||||
config.ConnConfig.RuntimeParams["search_path"] = schemaName + ",public"
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
t.Fatalf("connect access-rule migration test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE gateway_access_rules (
|
||||
id uuid PRIMARY KEY,
|
||||
subject_type text NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
resource_id uuid NOT NULL,
|
||||
effect text NOT NULL,
|
||||
priority integer NOT NULL DEFAULT 100,
|
||||
min_permission_level integer NOT NULL DEFAULT 0,
|
||||
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO gateway_access_rules (
|
||||
id, subject_type, subject_id, resource_type, resource_id, effect,
|
||||
priority, min_permission_level, conditions, metadata, status,
|
||||
created_at, updated_at
|
||||
) VALUES
|
||||
(
|
||||
'10000000-0000-0000-0000-000000000001', 'user_group',
|
||||
'20000000-0000-0000-0000-000000000001', 'platform_model',
|
||||
'30000000-0000-0000-0000-000000000001', 'allow', 10, 0,
|
||||
'{"region":"cn"}', '{"source":"legacy"}', 'active',
|
||||
'2026-08-01T00:00:00Z', '2026-08-01T01:00:00Z'
|
||||
),
|
||||
(
|
||||
'10000000-0000-0000-0000-000000000002', 'api_key',
|
||||
'20000000-0000-0000-0000-000000000002', 'platform',
|
||||
'30000000-0000-0000-0000-000000000002', 'allow', 20, 1,
|
||||
'{}', '{"source":"disabled-legacy"}', 'disabled',
|
||||
'2026-08-02T00:00:00Z', '2026-08-02T01:00:00Z'
|
||||
),
|
||||
(
|
||||
'10000000-0000-0000-0000-000000000003', 'user',
|
||||
'20000000-0000-0000-0000-000000000003', 'base_model',
|
||||
'30000000-0000-0000-0000-000000000003', 'deny', 30, 2,
|
||||
'{"reason":"blocked"}', '{"source":"deny-must-remain"}', 'active',
|
||||
'2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z'
|
||||
);`); err != nil {
|
||||
t.Fatalf("seed pre-migration access rules: %v", err)
|
||||
}
|
||||
beforeAudit, err := accessruleaudit.Export(ctx, pool, time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("export pre-migration access-rule audit: %v", err)
|
||||
}
|
||||
|
||||
_, currentFile, _, _ := runtime.Caller(0)
|
||||
migrationPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations", "0102_access_rule_allow_whitelist_semantics.sql")
|
||||
migration, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read access-rule whitelist migration: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(migration)); err != nil {
|
||||
t.Fatalf("apply access-rule whitelist migration: %v", err)
|
||||
}
|
||||
afterAudit, err := accessruleaudit.Export(ctx, pool, time.Date(2026, 8, 3, 0, 1, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("export post-migration access-rule audit: %v", err)
|
||||
}
|
||||
if err := accessruleaudit.VerifyMigration(beforeAudit, afterAudit); err != nil {
|
||||
t.Fatalf("verify pre/post access-rule audit: %v", err)
|
||||
}
|
||||
|
||||
var liveAllowCount, liveDenyCount, archivedAllowCount int64
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FILTER (WHERE effect = 'allow'), COUNT(*) FILTER (WHERE effect = 'deny') FROM gateway_access_rules`).Scan(&liveAllowCount, &liveDenyCount); err != nil {
|
||||
t.Fatalf("read migrated live rule counts: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_access_rule_allow_archive WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics'`).Scan(&archivedAllowCount); err != nil {
|
||||
t.Fatalf("read archived allow count: %v", err)
|
||||
}
|
||||
if liveAllowCount != 0 || liveDenyCount != 1 || archivedAllowCount != 2 {
|
||||
t.Fatalf("unexpected migration counts: liveAllow=%d liveDeny=%d archivedAllow=%d", liveAllowCount, liveDenyCount, archivedAllowCount)
|
||||
}
|
||||
|
||||
var expectedAllowCount int64
|
||||
var expectedAllowSHA, archivedAllowSHA string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT allow_count, allow_sha256
|
||||
FROM gateway_access_rule_migration_batches
|
||||
WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics'`).Scan(&expectedAllowCount, &expectedAllowSHA); err != nil {
|
||||
t.Fatalf("read access-rule migration batch: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT encode(digest(COALESCE(string_agg(row_sha256, E'\n' ORDER BY rule_id), ''), 'sha256'), 'hex')
|
||||
FROM gateway_access_rule_allow_archive
|
||||
WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics'`).Scan(&archivedAllowSHA); err != nil {
|
||||
t.Fatalf("hash archived allow rules: %v", err)
|
||||
}
|
||||
if expectedAllowCount != archivedAllowCount || expectedAllowSHA != archivedAllowSHA {
|
||||
t.Fatalf("archive manifest mismatch: count=%d/%d sha=%s/%s", expectedAllowCount, archivedAllowCount, expectedAllowSHA, archivedAllowSHA)
|
||||
}
|
||||
|
||||
var denyMetadata, denyConditions, denyStatus string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT metadata::text, conditions::text, status
|
||||
FROM gateway_access_rules
|
||||
WHERE id = '10000000-0000-0000-0000-000000000003'`).Scan(&denyMetadata, &denyConditions, &denyStatus); err != nil {
|
||||
t.Fatalf("read preserved deny rule: %v", err)
|
||||
}
|
||||
if denyMetadata != `{"source": "deny-must-remain"}` || denyConditions != `{"reason": "blocked"}` || denyStatus != "active" {
|
||||
t.Fatalf("deny rule changed: metadata=%s conditions=%s status=%s", denyMetadata, denyConditions, denyStatus)
|
||||
}
|
||||
|
||||
// Reapplying without new allows is idempotent. If new whitelist rules have
|
||||
// been written, the recorded legacy batch checksum makes reapplication fail
|
||||
// before any new rule can be deleted.
|
||||
if _, err := pool.Exec(ctx, string(migration)); err != nil {
|
||||
t.Fatalf("reapply access-rule whitelist migration: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_access_rules (
|
||||
id, subject_type, subject_id, resource_type, resource_id, effect
|
||||
) VALUES (
|
||||
'10000000-0000-0000-0000-000000000004', 'api_key',
|
||||
'20000000-0000-0000-0000-000000000004', 'platform_model',
|
||||
'30000000-0000-0000-0000-000000000004', 'allow'
|
||||
)`); err != nil {
|
||||
t.Fatalf("insert post-migration whitelist rule: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(migration)); err == nil {
|
||||
t.Fatal("reapplying the legacy cleanup after new whitelist writes must fail")
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_access_rules WHERE id = '10000000-0000-0000-0000-000000000004'`).Scan(&liveAllowCount); err != nil {
|
||||
t.Fatalf("read post-migration whitelist rule: %v", err)
|
||||
}
|
||||
if liveAllowCount != 1 {
|
||||
t.Fatalf("failed migration reapply removed a new whitelist rule: count=%d", liveAllowCount)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,6 @@ type AccessRuleBatchInput struct {
|
||||
DeleteResources []AccessRuleResourceInput `json:"deleteResources"`
|
||||
}
|
||||
|
||||
type accessRuleResource struct {
|
||||
Type string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (s *Store) ListAccessRules(ctx context.Context) ([]AccessRule, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+accessRuleColumns+`
|
||||
@@ -368,191 +363,6 @@ WHERE u.id = $1::uuid
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByAccessRules(
|
||||
ctx context.Context,
|
||||
user *auth.User,
|
||||
models []PlatformModel,
|
||||
excludedSubjectTypes map[string]bool,
|
||||
) ([]PlatformModel, error) {
|
||||
if len(models) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
resources := platformModelAccessResources(models)
|
||||
if len(resources) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, resources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
if len(excludedSubjectTypes) > 0 {
|
||||
rules = filterAccessRulesBySubjectType(rules, excludedSubjectTypes)
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
}
|
||||
subjects := accessRuleSubjects(user)
|
||||
level := 0
|
||||
if user != nil {
|
||||
level = auth.PermissionLevel(user.Roles)
|
||||
}
|
||||
filtered := models[:0]
|
||||
for _, model := range models {
|
||||
if platformModelAllowedByAccessRules(model, rules, subjects, level) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func filterAccessRulesBySubjectType(rules []AccessRule, excludedSubjectTypes map[string]bool) []AccessRule {
|
||||
filtered := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if excludedSubjectTypes[rule.SubjectType] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) {
|
||||
values := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.Type == "" || resource.ID == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, resource.Type+":"+resource.ID)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+accessRuleColumns+`
|
||||
FROM gateway_access_rules
|
||||
WHERE status = 'active'
|
||||
AND (resource_type || ':' || resource_id::text) = ANY($1)
|
||||
ORDER BY priority ASC, created_at ASC`, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
rules := make([]AccessRule, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanAccessRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func candidateAccessResources(candidates []RuntimeModelCandidate) []accessRuleResource {
|
||||
seen := map[string]bool{}
|
||||
out := make([]accessRuleResource, 0, len(candidates)*3)
|
||||
add := func(resourceType string, resourceID string) {
|
||||
key := resourceType + ":" + resourceID
|
||||
if resourceID == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, accessRuleResource{Type: resourceType, ID: resourceID})
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
add("platform", candidate.PlatformID)
|
||||
add("platform_model", candidate.PlatformModelID)
|
||||
add("base_model", candidate.BaseModelID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func platformModelAccessResources(models []PlatformModel) []accessRuleResource {
|
||||
seen := map[string]bool{}
|
||||
out := make([]accessRuleResource, 0, len(models)*3)
|
||||
add := func(resourceType string, resourceID string) {
|
||||
key := resourceType + ":" + resourceID
|
||||
if resourceID == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, accessRuleResource{Type: resourceType, ID: resourceID})
|
||||
}
|
||||
for _, model := range models {
|
||||
add("platform", model.PlatformID)
|
||||
add("platform_model", model.ID)
|
||||
add("base_model", model.BaseModelID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func candidateAllowedByAccessRules(candidate RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool {
|
||||
resourceKeys := map[string]bool{
|
||||
"platform:" + candidate.PlatformID: true,
|
||||
"platform_model:" + candidate.PlatformModelID: true,
|
||||
"base_model:" + candidate.BaseModelID: candidate.BaseModelID != "",
|
||||
}
|
||||
allowByResource := map[string]bool{}
|
||||
matchedAllowByResource := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
resourceKey := rule.ResourceType + ":" + rule.ResourceID
|
||||
if !resourceKeys[resourceKey] {
|
||||
continue
|
||||
}
|
||||
subjectKey := rule.SubjectType + ":" + rule.SubjectID
|
||||
if rule.Effect == "deny" && subjects[subjectKey] {
|
||||
return false
|
||||
}
|
||||
if rule.Effect == "allow" {
|
||||
allowByResource[resourceKey] = true
|
||||
if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel {
|
||||
matchedAllowByResource[resourceKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for resourceKey := range allowByResource {
|
||||
if !matchedAllowByResource[resourceKey] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func platformModelAllowedByAccessRules(model PlatformModel, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool {
|
||||
resourceKeys := map[string]bool{
|
||||
"platform:" + model.PlatformID: true,
|
||||
"platform_model:" + model.ID: true,
|
||||
"base_model:" + model.BaseModelID: model.BaseModelID != "",
|
||||
}
|
||||
allowByResource := map[string]bool{}
|
||||
matchedAllowByResource := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
resourceKey := rule.ResourceType + ":" + rule.ResourceID
|
||||
if !resourceKeys[resourceKey] {
|
||||
continue
|
||||
}
|
||||
subjectKey := rule.SubjectType + ":" + rule.SubjectID
|
||||
if rule.Effect == "deny" && subjects[subjectKey] {
|
||||
return false
|
||||
}
|
||||
if rule.Effect == "allow" {
|
||||
allowByResource[resourceKey] = true
|
||||
if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel {
|
||||
matchedAllowByResource[resourceKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for resourceKey := range allowByResource {
|
||||
if !matchedAllowByResource[resourceKey] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func accessRuleSubjects(user *auth.User) map[string]bool {
|
||||
subjects := map[string]bool{}
|
||||
if user == nil {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterAccessRulesBySubjectTypeExcludesAPIKeyRulesOnly(t *testing.T) {
|
||||
rules := []AccessRule{
|
||||
{ID: "api-key-allow", SubjectType: "api_key", Effect: "allow"},
|
||||
{ID: "api-key-deny", SubjectType: "api_key", Effect: "deny"},
|
||||
{ID: "user-group-allow", SubjectType: "user_group", Effect: "allow"},
|
||||
{ID: "user-deny", SubjectType: "user", Effect: "deny"},
|
||||
{ID: "tenant-allow", SubjectType: "tenant", Effect: "allow"},
|
||||
}
|
||||
|
||||
filtered := filterAccessRulesBySubjectType(rules, map[string]bool{"api_key": true})
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("filtered rule count = %d, want 3: %+v", len(filtered), filtered)
|
||||
}
|
||||
for _, rule := range filtered {
|
||||
if rule.SubjectType == "api_key" {
|
||||
t.Fatalf("api-key rule should not affect the owning user's assignable resources: %+v", rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
CREATE TABLE IF NOT EXISTS gateway_access_rule_allow_archive (
|
||||
migration_batch text NOT NULL,
|
||||
rule_id uuid NOT NULL,
|
||||
subject_type text NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
resource_id uuid NOT NULL,
|
||||
effect text NOT NULL,
|
||||
priority integer NOT NULL,
|
||||
min_permission_level integer NOT NULL,
|
||||
conditions jsonb NOT NULL,
|
||||
metadata jsonb NOT NULL,
|
||||
status text NOT NULL,
|
||||
rule_created_at timestamptz NOT NULL,
|
||||
rule_updated_at timestamptz NOT NULL,
|
||||
row_sha256 text NOT NULL CHECK (row_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
archived_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (migration_batch, rule_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_access_rule_allow_archive_subject
|
||||
ON gateway_access_rule_allow_archive(migration_batch, subject_type, subject_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway_access_rule_migration_batches (
|
||||
migration_batch text PRIMARY KEY,
|
||||
allow_count bigint NOT NULL,
|
||||
allow_sha256 text NOT NULL CHECK (allow_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
deny_count bigint NOT NULL,
|
||||
deny_sha256 text NOT NULL CHECK (deny_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
archived_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Reads stay available while access-rule writes are paused for the atomic
|
||||
-- snapshot, archive, and cleanup sequence.
|
||||
LOCK TABLE gateway_access_rules IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
INSERT INTO gateway_access_rule_migration_batches (
|
||||
migration_batch, allow_count, allow_sha256, deny_count, deny_sha256
|
||||
)
|
||||
SELECT
|
||||
'0102_access_rule_allow_whitelist_semantics',
|
||||
COUNT(*) FILTER (WHERE effect = 'allow'),
|
||||
encode(digest(COALESCE(string_agg(
|
||||
encode(digest(concat_ws(chr(31),
|
||||
id::text, subject_type, subject_id::text, resource_type,
|
||||
resource_id::text, effect, priority::text,
|
||||
min_permission_level::text, conditions::text, metadata::text,
|
||||
status, extract(epoch FROM created_at)::text,
|
||||
extract(epoch FROM updated_at)::text
|
||||
), 'sha256'), 'hex'), chr(10) ORDER BY id
|
||||
) FILTER (WHERE effect = 'allow'), ''), 'sha256'), 'hex'),
|
||||
COUNT(*) FILTER (WHERE effect = 'deny'),
|
||||
encode(digest(COALESCE(string_agg(
|
||||
encode(digest(concat_ws(chr(31),
|
||||
id::text, subject_type, subject_id::text, resource_type,
|
||||
resource_id::text, effect, priority::text,
|
||||
min_permission_level::text, conditions::text, metadata::text,
|
||||
status, extract(epoch FROM created_at)::text,
|
||||
extract(epoch FROM updated_at)::text
|
||||
), 'sha256'), 'hex'), chr(10) ORDER BY id
|
||||
) FILTER (WHERE effect = 'deny'), ''), 'sha256'), 'hex')
|
||||
FROM gateway_access_rules
|
||||
ON CONFLICT (migration_batch) DO NOTHING;
|
||||
|
||||
INSERT INTO gateway_access_rule_allow_archive (
|
||||
migration_batch, rule_id, subject_type, subject_id, resource_type,
|
||||
resource_id, effect, priority, min_permission_level, conditions,
|
||||
metadata, status, rule_created_at, rule_updated_at, row_sha256
|
||||
)
|
||||
SELECT
|
||||
'0102_access_rule_allow_whitelist_semantics', id, subject_type, subject_id,
|
||||
resource_type, resource_id, effect, priority, min_permission_level,
|
||||
conditions, metadata, status, created_at, updated_at,
|
||||
encode(digest(concat_ws(chr(31),
|
||||
id::text, subject_type, subject_id::text, resource_type,
|
||||
resource_id::text, effect, priority::text,
|
||||
min_permission_level::text, conditions::text, metadata::text,
|
||||
status, extract(epoch FROM created_at)::text,
|
||||
extract(epoch FROM updated_at)::text
|
||||
), 'sha256'), 'hex')
|
||||
FROM gateway_access_rules
|
||||
WHERE effect = 'allow'
|
||||
ON CONFLICT (migration_batch, rule_id) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
expected_count bigint;
|
||||
expected_sha256 text;
|
||||
archived_count bigint;
|
||||
archived_sha256 text;
|
||||
BEGIN
|
||||
SELECT allow_count, allow_sha256
|
||||
INTO expected_count, expected_sha256
|
||||
FROM gateway_access_rule_migration_batches
|
||||
WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics';
|
||||
|
||||
SELECT COUNT(*), encode(digest(COALESCE(string_agg(
|
||||
row_sha256, chr(10) ORDER BY rule_id
|
||||
), ''), 'sha256'), 'hex')
|
||||
INTO archived_count, archived_sha256
|
||||
FROM gateway_access_rule_allow_archive
|
||||
WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics';
|
||||
|
||||
IF archived_count <> expected_count OR archived_sha256 <> expected_sha256 THEN
|
||||
RAISE EXCEPTION 'access-rule allow archive verification failed';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DELETE FROM gateway_access_rules
|
||||
WHERE effect = 'allow';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
expected_deny_count bigint;
|
||||
expected_deny_sha256 text;
|
||||
actual_allow_count bigint;
|
||||
actual_deny_count bigint;
|
||||
actual_deny_sha256 text;
|
||||
BEGIN
|
||||
SELECT deny_count, deny_sha256
|
||||
INTO expected_deny_count, expected_deny_sha256
|
||||
FROM gateway_access_rule_migration_batches
|
||||
WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics';
|
||||
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE effect = 'allow'),
|
||||
COUNT(*) FILTER (WHERE effect = 'deny'),
|
||||
encode(digest(COALESCE(string_agg(
|
||||
encode(digest(concat_ws(chr(31),
|
||||
id::text, subject_type, subject_id::text, resource_type,
|
||||
resource_id::text, effect, priority::text,
|
||||
min_permission_level::text, conditions::text, metadata::text,
|
||||
status, extract(epoch FROM created_at)::text,
|
||||
extract(epoch FROM updated_at)::text
|
||||
), 'sha256'), 'hex'), chr(10) ORDER BY id
|
||||
) FILTER (WHERE effect = 'deny'), ''), 'sha256'), 'hex')
|
||||
INTO actual_allow_count, actual_deny_count, actual_deny_sha256
|
||||
FROM gateway_access_rules;
|
||||
|
||||
IF actual_allow_count <> 0 OR
|
||||
actual_deny_count <> expected_deny_count OR
|
||||
actual_deny_sha256 <> expected_deny_sha256 THEN
|
||||
RAISE EXCEPTION 'access-rule whitelist migration verification failed';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
Reference in New Issue
Block a user