refactor(access): 统一分层白名单权限语义

取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
This commit is contained in:
2026-08-03 15:43:49 +08:00
parent c9393af43a
commit 7376d6fab6
20 changed files with 1490 additions and 513 deletions
+174
View File
@@ -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
}
+323
View File
@@ -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 // listAccessRules godoc
// @Summary 列出访问规则 // @Summary 列出访问规则
// @Description 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。 // @Description 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的分层访问规则。主体当前层无 allow 时继承上级,存在 allow 时仅允许白名单,deny 始终优先。
// @Tags access-rules // @Tags access-rules
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -33,7 +33,7 @@ func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) {
// listAPIKeyAccessRules godoc // listAPIKeyAccessRules godoc
// @Summary 列出 API Key 访问规则 // @Summary 列出 API Key 访问规则
// @Description 返回当前本地用户可管理的 API Key 访问规则。 // @Description 返回当前本地用户拥有的 API Key 访问规则;不会混入其他用户或其他 API Key 的规则
// @Tags api-keys // @Tags api-keys
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -60,7 +60,7 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
// listAPIKeyAssignableModels godoc // listAPIKeyAssignableModels godoc
// @Summary 列出 API Key 可分配模型 // @Summary 列出 API Key 可分配模型
// @Description 按当前用户自身的户、租户和用户组权限返回可分配给 API Key 的启用模型,不任何 API Key 权限规则影响 // @Description 按当前用户自身的户、用户组和用户分层白名单返回可分配给 API Key 的启用模型,不应用任何 API Key 层规则
// @Tags api-keys // @Tags api-keys
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -88,7 +88,7 @@ func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Reque
// listAPIKeyAssignableModelsForKey godoc // listAPIKeyAssignableModelsForKey godoc
// @Summary 列出指定 API Key 可分配模型 // @Summary 列出指定 API Key 可分配模型
// @Description 返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。 // @Description 返回全局启用、命中指定 API Key 的租户/用户组/用户基线且符合 Key scope 的可分配平台来源;当前 Key 的 allow/deny 不缩减候选,仅作为已有规则有效性诊断返回
// @Tags api-keys // @Tags api-keys
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -123,7 +123,7 @@ func (s *Server) listAPIKeyAssignableModelsForKey(w http.ResponseWriter, r *http
// createAccessRule godoc // createAccessRule godoc
// @Summary 创建访问规则 // @Summary 创建访问规则
// @Description 管理端创建一条访问控制规则。 // @Description 管理端创建一条访问控制规则;同一主体层存在任意有效 allow 后该层启用白名单,deny 始终优先
// @Tags access-rules // @Tags access-rules
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -161,7 +161,7 @@ func (s *Server) createAccessRule(w http.ResponseWriter, r *http.Request) {
// batchAccessRules godoc // batchAccessRules godoc
// @Summary 批量写入访问规则 // @Summary 批量写入访问规则
// @Description 管理端为同一主体批量新增、更新或删除资源访问规则。 // @Description 管理端为同一主体批量新增、更新或删除资源访问规则。清空该主体全部 allow 会恢复上级继承。
// @Tags access-rules // @Tags access-rules
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -194,7 +194,7 @@ func (s *Server) batchAccessRules(w http.ResponseWriter, r *http.Request) {
// batchAPIKeyAccessRules godoc // batchAPIKeyAccessRules godoc
// @Summary 批量写入 API Key 访问规则 // @Summary 批量写入 API Key 访问规则
// @Description 当前本地用户为自己的 API Key 批量新增、更新或删除可访问资源 // @Description 当前本地用户为自己的 API Key 批量新增、更新或删除白名单/拒绝资源;Key 无 allow 时继承父级范围,存在 allow 后仅允许命中项
// @Tags api-keys // @Tags api-keys
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -222,9 +222,16 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
batchKeyRule(adminToken, keyLegacyID, legacyTextModel.ID, "allow", http.StatusOK) batchKeyRule(adminToken, keyLegacyID, legacyTextModel.ID, "allow", http.StatusOK)
batchKeyRule(adminToken, keyScopeID, scopeTextModel.ID, "allow", http.StatusOK) batchKeyRule(adminToken, keyScopeID, scopeTextModel.ID, "allow", http.StatusOK)
createRule("user_group", groupAID, imageModel.ID, "allow") 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, textModel.ID, "deny")
createRule("user_group", groupAID, legacyTextModel.ID, "deny") createRule("user_group", groupAID, legacyTextModel.ID, "deny")
createRule("user_group", groupBID, groupBImageModel.ID, "allow") 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{ doJSON(t, server.URL, http.MethodPatch, "/api/v1/api-keys/"+keyScopeID+"/scopes", adminToken, map[string]any{
"scopes": []string{"image"}, "scopes": []string{"image"},
}, http.StatusOK, nil) }, http.StatusOK, nil)
@@ -233,6 +240,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
createRule("api_key", keyAID, textModel.ID, "allow") createRule("api_key", keyAID, textModel.ID, "allow")
createRule("api_key", keyScopeID, disabledImageModel.ID, "allow") createRule("api_key", keyScopeID, disabledImageModel.ID, "allow")
batchKeyRule(adminToken, keyAID, imageModel.ID, "allow", http.StatusOK) batchKeyRule(adminToken, keyAID, imageModel.ID, "allow", http.StatusOK)
batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "allow", http.StatusOK)
batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "deny", http.StatusOK) batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "deny", http.StatusOK)
loadAssignable := func(token string, keyID string) modelAccessAssignableResponse { 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) 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) { 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 { for _, model := range assignable.Items {
if model.ID == multiModel.ID && (len(model.ModelType) != 1 || model.ModelType[0] != "image_generate") { 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) { if containsModelID(platformModels.Items, textModel.ID) {
t.Fatalf("group-denied text model leaked into key model list: %+v", platformModels.Items) 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) 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) { 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 { var inheritedPlatformModels struct {
Items []modelAccessFixture `json:"items"` Items []modelAccessFixture `json:"items"`
} }
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyAInheritedSecret, nil, http.StatusOK, &inheritedPlatformModels) 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) { if !containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) || !containsModelID(inheritedPlatformModels.Items, multiModel.ID) {
t.Fatalf("key exclusive/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items) t.Fatalf("key whitelist/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items)
} }
groupBAssignable := loadAssignable(userBToken, keyBImageID) groupBAssignable := loadAssignable(userBToken, keyBImageID)
if containsModelID(groupBAssignable.Items, imageModel.ID) || !containsModelID(groupBAssignable.Items, groupBImageModel.ID) { 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 { var groupBChatModels struct {
Items []modelAccessFixture `json:"items"` Items []modelAccessFixture `json:"items"`
@@ -326,7 +334,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
ID string `json:"id"` ID string `json:"id"`
} `json:"data"` } `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 { if openAIList.Object != "list" || countOpenAIModel(openAIList.Data, multiName) != 1 {
t.Fatalf("openai model list is not deduplicated: %+v", openAIList) 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") t.Logf("多来源模型:初始来源=2,排除一个后 OpenAI 逻辑模型=1,全部排除后=0;调用状态=200/404")
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{ 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) }, http.StatusOK, nil)
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyAInheritedSecret, map[string]any{ doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyAInheritedSecret, map[string]any{
"model": imageName, "prompt": "sibling key", "runMode": "simulation", "simulation": true, "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{ doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
"model": keyDenyImageName, "prompt": "key deny", "runMode": "simulation", "simulation": true, "model": keyDenyImageName, "prompt": "key deny", "runMode": "simulation", "simulation": true,
}, http.StatusNotFound, nil) }, http.StatusNotFound, nil)
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyBImageSecret, map[string]any{ 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) }, http.StatusOK, nil)
t.Logf("实际调用:KEY 专属 200,兄弟 KEY 404KEY 排除 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 不受影响 200KEY deny 404,组 B 白名单 200;拒绝请求未创建任务")
// Group B can still use text: stale allows whose owners lost group access or // Group B can still use text: group A and stale API-key allows are never
// scope are removed from the global exclusive set. // evaluated for this group's keys.
if !containsModelID(loadAssignable(userBToken, keyBID).Items, textModel.ID) { if !containsModelID(loadAssignable(userBToken, keyBID).Items, textModel.ID) {
t.Fatalf("group B chat key candidate list lost the group-A denied model") 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, "minPermissionLevel": 0,
"status": "active", "status": "active",
}, http.StatusCreated, nil) }, http.StatusCreated, nil)
var deniedTask struct { var deniedTaskCountBefore, deniedTaskCountAfter int
Task struct { if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&deniedTaskCountBefore); err != nil {
Status string `json:"status"` t.Fatalf("count tasks before denied model request: %v", err)
ErrorCode string `json:"errorCode"`
} `json:"task"`
} }
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, "model": deniedModel,
"runMode": "simulation", "runMode": "simulation",
"simulation": true, "simulation": true,
"simulationDurationMs": 5, "simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "permission deny"}}, "messages": []map[string]any{{"role": "user", "content": "permission deny"}},
}, "permission-deny-"+suffixText, http.StatusNotFound, nil, &deniedTask.Task) }, http.StatusNotFound, nil)
if deniedTask.Task.Status != "failed" || deniedTask.Task.ErrorCode != "no_model_candidate" { if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&deniedTaskCountAfter); err != nil {
t.Fatalf("deny access rule should hide denied model from runtime candidates: %+v", deniedTask.Task) 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 { var restrictedModels struct {
Items []struct { Items []struct {
@@ -873,38 +874,27 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
"minPermissionLevel": 0, "minPermissionLevel": 0,
"status": "active", "status": "active",
}, http.StatusCreated, nil) }, http.StatusCreated, nil)
var blockedControlledTask struct { var siblingControlledTask 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 {
Task struct { Task struct {
Status string `json:"status"` Status string `json:"status"`
} `json:"task"` } `json:"task"`
} }
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{ 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, "model": controlledModel,
"runMode": "simulation", "runMode": "simulation",
"simulation": true, "simulation": true,
@@ -912,7 +902,7 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
"messages": []map[string]any{{"role": "user", "content": "allow should pass"}}, "messages": []map[string]any{{"role": "user", "content": "allow should pass"}},
}, "permission-allow-pass-"+suffixText, http.StatusOK, nil, &allowedControlledTask.Task) }, "permission-allow-pass-"+suffixText, http.StatusOK, nil, &allowedControlledTask.Task)
if allowedControlledTask.Task.Status != "succeeded" { 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 { var customPricingRuleSet struct {
+11 -1
View File
@@ -632,7 +632,7 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
// listPlayableModels godoc // listPlayableModels godoc
// @Summary 列出可调用模型 // @Summary 列出可调用模型
// @Description 按当前用户权限返回可用于 Playground 或 API 调用的模型列表 // @Description 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API 调用的平台来源;其他主体规则不参与求值
// @Tags playground // @Tags playground
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -1201,6 +1201,16 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeTaskError(status, err.Error(), nil, clients.ErrorCode(err)) writeTaskError(status, err.Error(), nil, clients.ErrorCode(err))
return 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) runMode, err := s.admittedTaskRunMode(admission, prepared.Body)
if err != nil { if err != nil {
@@ -26,7 +26,7 @@ func (s *Server) listLegacyPlayableModels(w http.ResponseWriter, r *http.Request
// listOpenAIModels godoc // listOpenAIModels godoc
// @Summary 列出 OpenAI 兼容模型 // @Summary 列出 OpenAI 兼容模型
// @Description 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。 // @Description 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回去重后的逻辑模型列表;其他主体规则不参与求值
// @Tags openai-compatible // @Tags openai-compatible
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
+32 -15
View File
@@ -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) { func (s *Service) Estimate(ctx context.Context, kind string, model string, body map[string]any, user *auth.User) (EstimateResult, error) {
body = normalizeRequest(kind, body) candidates, body, err := s.candidatesForRequest(ctx, kind, model, body, user)
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)
if err != nil { if err != nil {
return EstimateResult{}, err 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)) 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 { 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)} usage := clients.Usage{InputTokens: estimateRequestTokens(body)}
if isTextGenerationKind(kind) { if isTextGenerationKind(kind) {
+125 -161
View File
@@ -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) { 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 { if err != nil {
return nil, err return nil, err
} }
baselineRules, apiKeyRules := splitLayeredAccessRules(rules) filtered := filterPlatformModelsByAccessLayers(models, rules, layers, permissionLevel(user))
baseline := filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user)) if user != nil && strings.TrimSpace(user.APIKeyID) != "" {
if user == nil || strings.TrimSpace(user.APIKeyID) == "" { filtered = filterPlatformModelsByAPIKeyScopes(filtered, user.APIKeyScopes)
return baseline, nil
} }
apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules) return filtered, nil
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)
} }
func (s *Store) filterPlatformModelsByBaselineAccess(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) { 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 { if err != nil {
return nil, err return nil, err
} }
baselineRules, _ := splitLayeredAccessRules(rules) return filterPlatformModelsByAccessLayers(models, rules, layers, permissionLevel(user)), nil
return filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user)), nil
} }
func (s *Store) filterRuntimeCandidatesByLayeredAccess(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) { 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 { if err != nil {
return nil, err return nil, err
} }
rules, err := s.listActiveAccessRulesForResources(ctx, candidateAccessResources(candidates)) layers := accessRuleLayers(accessUser, true)
rules, err := s.listActiveAccessRulesForLayers(ctx, layers)
if err != nil { if err != nil {
return nil, err return nil, err
} }
baselineRules, apiKeyRules := splitLayeredAccessRules(rules) filtered := filterCandidatesByAccessLayers(candidates, rules, layers, permissionLevel(accessUser))
baseline := filterCandidatesByRuleSet(candidates, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) if accessUser != nil && strings.TrimSpace(accessUser.APIKeyID) != "" {
if accessUser == nil || strings.TrimSpace(accessUser.APIKeyID) == "" { scoped := make([]RuntimeModelCandidate, 0, len(filtered))
return baseline, nil for _, candidate := range filtered {
} if modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) {
apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules) scoped = append(scoped, candidate)
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 = scoped
} }
return filtered, nil return filtered, nil
} }
@@ -135,12 +106,10 @@ func (s *Store) ListAPIKeyAssignablePlatformModelsForKey(ctx context.Context, us
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(enabledModels)) baseline, err := s.filterPlatformModelsByBaselineAccess(ctx, accessUser, enabledModels)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
baselineRules, _ := splitLayeredAccessRules(rules)
baseline := filterPlatformModelsByRuleSet(enabledModels, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser))
scoped := filterPlatformModelsByAPIKeyScopes(baseline, accessUser.APIKeyScopes) scoped := filterPlatformModelsByAPIKeyScopes(baseline, accessUser.APIKeyScopes)
ownedRules, err := s.ListAPIKeyAccessRules(ctx, user) ownedRules, err := s.ListAPIKeyAccessRules(ctx, user)
if err != nil { if err != nil {
@@ -185,129 +154,140 @@ WHERE k.id = $1::uuid
return &next, nil return &next, nil
} }
func effectiveAPIKeyRulesForPlatformModel(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, model PlatformModel) []AccessRule { type accessRuleLayer struct {
effective := make([]AccessRule, 0, len(rules)) subjectType string
for _, rule := range rules { subjectIDs map[string]bool
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
} }
func effectiveAPIKeyRulesForCandidate(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, candidate RuntimeModelCandidate) []AccessRule { var accessRuleLayerOrder = []string{"tenant", "user_group", "user", "api_key"}
effective := make([]AccessRule, 0, len(rules))
for _, rule := range rules { func accessRuleLayers(user *auth.User, includeAPIKey bool) []accessRuleLayer {
accessUser := users[rule.SubjectID] subjects := accessRuleSubjects(user)
if accessUser == nil || !accessRuleMatchesCandidate(rule, candidate) || layers := make([]accessRuleLayer, 0, len(accessRuleLayerOrder))
!candidateAllowedByAccessRules(candidate, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) || for _, subjectType := range accessRuleLayerOrder {
!modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) { if subjectType == "api_key" && !includeAPIKey {
continue 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) { func (s *Store) listActiveAccessRulesForLayers(ctx context.Context, layers []accessRuleLayer) ([]AccessRule, error) {
ids := make([]string, 0, len(rules)) subjects := make([]string, 0)
seen := map[string]bool{} for _, layer := range layers {
for _, rule := range rules { for id := range layer.subjectIDs {
if rule.SubjectType != "api_key" || rule.SubjectID == "" || seen[rule.SubjectID] { subjects = append(subjects, layer.subjectType+":"+id)
continue
} }
seen[rule.SubjectID] = true
ids = append(ids, rule.SubjectID)
} }
if len(ids) == 0 { if len(subjects) == 0 {
return map[string]*auth.User{}, nil return nil, nil
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT k.id::text, k.scopes, u.id::text, SELECT `+accessRuleColumns+`
COALESCE(u.gateway_tenant_id::text, ''), COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, ''), FROM gateway_access_rules
u.roles, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''), COALESCE(g.group_key, '') WHERE status = 'active'
FROM gateway_api_keys k AND (subject_type || ':' || subject_id::text) = ANY($1)
JOIN gateway_users u ON u.id = k.gateway_user_id ORDER BY subject_type ASC, priority ASC, created_at ASC`, subjects)
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)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
users := map[string]*auth.User{} rules := make([]AccessRule, 0)
for rows.Next() { for rows.Next() {
var apiKeyID string item, err := scanAccessRule(rows)
var scopesBytes []byte if err != nil {
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 {
return nil, err return nil, err
} }
groupKeys := []string(nil) rules = append(rules, item)
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),
}
} }
return users, rows.Err() return rules, rows.Err()
} }
func splitLayeredAccessRules(rules []AccessRule) ([]AccessRule, []AccessRule) { func filterPlatformModelsByAccessLayers(models []PlatformModel, rules []AccessRule, layers []accessRuleLayer, level int) []PlatformModel {
baseline := make([]AccessRule, 0, len(rules)) filtered := append([]PlatformModel(nil), models...)
apiKeys := make([]AccessRule, 0, len(rules)) 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 { for _, rule := range rules {
if rule.SubjectType == "api_key" { if rule.SubjectType == layer.subjectType && layer.subjectIDs[rule.SubjectID] {
apiKeys = append(apiKeys, rule) filtered = append(filtered, 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)
} }
} }
return filtered return filtered
} }
func filterCandidatesByRuleSet(candidates []RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, level int) []RuntimeModelCandidate { func platformModelAllowedBySubjectLayer(model PlatformModel, rules []AccessRule, level int) bool {
filtered := make([]RuntimeModelCandidate, 0, len(candidates)) return allowedBySubjectLayer(rules, level, func(rule AccessRule) bool {
for _, candidate := range candidates { return accessRuleMatchesPlatformModel(rule, model)
if candidateAllowedByAccessRules(candidate, rules, subjects, level) { })
filtered = append(filtered, candidate) }
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 { 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 { func permissionLevel(user *auth.User) int {
if user == nil { if user == nil {
return 0 return 0
+113 -59
View File
@@ -8,90 +8,136 @@ import (
) )
func TestLayeredAccessDoesNotLetAPIKeyExpandBaseline(t *testing.T) { func TestLayeredAccessDoesNotLetAPIKeyExpandBaseline(t *testing.T) {
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", BaseModelID: "base-1"} models := []PlatformModel{{ID: "model-1", PlatformID: "platform-1"}, {ID: "model-2", PlatformID: "platform-1"}}
groupUser := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"} user := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
baselineRules := []AccessRule{{ rules := []AccessRule{
SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny", {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"},
keyRules := []AccessRule{{ }
SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow", actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
}}
baseline := filterPlatformModelsByRuleSet([]PlatformModel{model}, baselineRules, baselineAccessRuleSubjects(groupUser), 0)
actual := filterPlatformModelsByRuleSet(baseline, keyRules, apiKeyAccessRuleSubjects(groupUser), 0)
if len(actual) != 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) { func TestNoRulesInheritAllModels(t *testing.T) {
model := PlatformModel{ID: "model-1", PlatformID: "platform-1"} 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{{ 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: "allow",
}} }}
for _, test := range []struct { for _, test := range []struct {
keyID string keyID string
want int want []string
}{{"key-a", 1}, {"key-b", 0}} { }{{"key-a", []string{"model-1"}}, {"key-b", []string{"model-1", "model-2"}}} {
user := &auth.User{APIKeyID: test.keyID} user := &auth.User{GatewayUserID: "user-1", APIKeyID: test.keyID}
actual := filterPlatformModelsByRuleSet([]PlatformModel{model}, rules, apiKeyAccessRuleSubjects(user), 0) actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
if len(actual) != test.want { if got := platformModelIDs(actual); !reflect.DeepEqual(got, test.want) {
t.Fatalf("key %s received %d models, want %d", test.keyID, len(actual), test.want) t.Fatalf("key %s received %v, want %v", test.keyID, got, test.want)
} }
} }
} }
func TestLayeredAccessDenyWinsAndNoRulesInherit(t *testing.T) { func TestUserGroupAllowDoesNotAffectOtherGroups(t *testing.T) {
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}} models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
keyUser := &auth.User{APIKeyID: "key-a", APIKeyScopes: []string{"chat"}} rules := []AccessRule{{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"}}
keyUsers := map[string]*auth.User{"key-a": keyUser} for _, test := range []struct {
if got := filterPlatformModelsByLayeredRuleSet(keyUser, []PlatformModel{model}, nil, nil, keyUsers); len(got) != 1 { groupID string
t.Fatalf("key without rules did not inherit baseline: %+v", got) 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{ rules := []AccessRule{
{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"}, {SubjectType: "tenant", SubjectID: "tenant-1", ResourceType: "platform_model", ResourceID: "a", 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: "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 { actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
t.Fatalf("matching deny did not override allow: %+v", got) if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"b"}) {
t.Fatalf("layered whitelist result = %v, want [b]", got)
} }
} }
func TestDirectUserIgnoresAPIKeyRules(t *testing.T) { func TestDenyWinsAndPlatformAllowIncludesChildren(t *testing.T) {
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}} 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{{ 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"}}} actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, false), 0)
if got := filterPlatformModelsByLayeredRuleSet(&auth.User{GatewayUserID: "user-1"}, []PlatformModel{model}, nil, rules, keyUsers); len(got) != 1 { if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"source-a", "source-b"}) {
t.Fatalf("direct user was constrained by API key exclusive rule: %+v", got) t.Fatalf("base-model whitelist result = %v, want both base-1 sources", got)
} }
} }
func TestIneffectiveAPIKeyRuleDoesNotControlAnotherKey(t *testing.T) { func TestAllowPermissionLevelFailsClosed(t *testing.T) {
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}} models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
staleRule := AccessRule{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"} rules := []AccessRule{{
groupDeny := AccessRule{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny"} SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow", MinPermissionLevel: 2,
keyUsers := map[string]*auth.User{ }}
"key-a": {APIKeyID: "key-a", UserGroupID: "group-a", APIKeyScopes: []string{"chat"}}, 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 := platformModelIDs(filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 2)); !reflect.DeepEqual(got, []string{"model-1"}) {
if got := filterPlatformModelsByLayeredRuleSet(keyB, []PlatformModel{model}, []AccessRule{groupDeny}, []AccessRule{staleRule}, keyUsers); len(got) != 1 { t.Fatalf("sufficient permission result = %v, want [model-1]", got)
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)
} }
} }
@@ -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)
}
}
-190
View File
@@ -41,11 +41,6 @@ type AccessRuleBatchInput struct {
DeleteResources []AccessRuleResourceInput `json:"deleteResources"` DeleteResources []AccessRuleResourceInput `json:"deleteResources"`
} }
type accessRuleResource struct {
Type string
ID string
}
func (s *Store) ListAccessRules(ctx context.Context) ([]AccessRule, error) { func (s *Store) ListAccessRules(ctx context.Context) ([]AccessRule, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT `+accessRuleColumns+` SELECT `+accessRuleColumns+`
@@ -368,191 +363,6 @@ WHERE u.id = $1::uuid
return &next, nil 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 { func accessRuleSubjects(user *auth.User) map[string]bool {
subjects := map[string]bool{} subjects := map[string]bool{}
if user == nil { 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
$$;
@@ -1,7 +1,7 @@
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
import { AccessPermissionEditor } from './AccessPermissionEditor'; import { AccessPermissionEditor, ineffectiveRuleCleanupBatches } from './AccessPermissionEditor';
describe('AccessPermissionEditor API Key diagnostics', () => { describe('AccessPermissionEditor API Key diagnostics', () => {
it('shows retained ineffective rules and only the scope-pruned model capabilities', () => { it('shows retained ineffective rules and only the scope-pruned model capabilities', () => {
@@ -54,10 +54,59 @@ describe('AccessPermissionEditor API Key diagnostics', () => {
); );
expect(html).toContain('1 条规则当前不生效'); expect(html).toContain('1 条规则当前不生效');
expect(html).toContain('未配置白名单');
expect(html).toContain('继承上级可用范围');
expect(html).toContain('允许使用(白名单)');
expect(html).toContain('清空允许');
expect(html).toContain('Text Model'); expect(html).toContain('Text Model');
expect(html).toContain('不在当前 API Key 的能力范围内'); expect(html).toContain('不在当前 API Key 的能力范围内');
expect(html).toContain('移除规则'); expect(html).toContain('移除规则');
expect(html).toContain('一键清理');
expect(html).toContain('image_generate'); expect(html).toContain('image_generate');
expect(html).not.toContain('text_generate'); expect(html).not.toContain('text_generate');
}); });
it('shows the restrictive whitelist state when the current subject has an allow', () => {
const html = renderToStaticMarkup(
<AccessPermissionEditor
accessRules={[{
id: 'rule-allow',
subjectType: 'api_key',
subjectId: 'key-1',
resourceType: 'platform_model',
resourceId: 'model-1',
effect: 'allow',
priority: 100,
minPermissionLevel: 0,
conditions: {},
metadata: {},
status: 'active',
createdAt: '2026-08-03T00:00:00Z',
updatedAt: '2026-08-03T00:00:00Z',
}]}
platformModels={[]}
platforms={[]}
state="ready"
subjectId="key-1"
subjectType="api_key"
onBatchAccessRules={vi.fn()}
/>,
);
expect(html).toContain('已配置白名单');
expect(html).toContain('仅允许所选资源');
expect(html).not.toContain('专属使用');
});
it('groups one-click cleanup by effect and deduplicates retained diagnostics', () => {
const batches = ineffectiveRuleCleanupBatches('api_key', 'key-1', [
{ ruleId: 'allow-1', resourceType: 'platform_model', resourceId: 'model-1', effect: 'allow', effective: false, reason: 'scope_not_allowed' },
{ ruleId: 'allow-duplicate', resourceType: 'platform_model', resourceId: 'model-1', effect: 'allow', effective: false, reason: 'scope_not_allowed' },
{ ruleId: 'deny-1', resourceType: 'platform', resourceId: 'platform-1', effect: 'deny', effective: false, reason: 'resource_unavailable' },
{ ruleId: 'effective', resourceType: 'platform_model', resourceId: 'model-2', effect: 'allow', effective: true },
]);
expect(batches).toHaveLength(2);
expect(batches[0]).toMatchObject({ effect: 'allow', deleteResources: [{ resourceType: 'platform_model', resourceId: 'model-1' }] });
expect(batches[1]).toMatchObject({ effect: 'deny', deleteResources: [{ resourceType: 'platform', resourceId: 'platform-1' }] });
});
}); });
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
import type { import type {
GatewayAccessEffect, GatewayAccessEffect,
GatewayAPIKeyAccessRuleDiagnostic, GatewayAPIKeyAccessRuleDiagnostic,
@@ -46,6 +46,7 @@ export function AccessPermissionEditor(props: {
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id))); const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id))); const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const [cleaningIneffectiveRules, setCleaningIneffectiveRules] = useState(false);
const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]); const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]);
const subjectRules = useMemo( const subjectRules = useMemo(
@@ -105,6 +106,21 @@ export function AccessPermissionEditor(props: {
); );
} }
async function clearIneffectiveRules() {
if (!props.subjectId || ineffectiveRules.length === 0) return;
setLocalError('');
setCleaningIneffectiveRules(true);
try {
for (const batch of ineffectiveRuleCleanupBatches(props.subjectType, props.subjectId, ineffectiveRules)) {
await props.onBatchAccessRules(batch);
}
} catch (err) {
setLocalError(err instanceof Error ? err.message : '失效规则一键清理失败');
} finally {
setCleaningIneffectiveRules(false);
}
}
async function clearEffect(effect: Effect) { async function clearEffect(effect: Effect) {
const keys = subjectRules const keys = subjectRules
.filter((rule) => rule.effect === effect) .filter((rule) => rule.effect === effect)
@@ -143,6 +159,7 @@ export function AccessPermissionEditor(props: {
const allowSummary = countEffectRules(subjectRules, 'allow'); const allowSummary = countEffectRules(subjectRules, 'allow');
const denySummary = countEffectRules(subjectRules, 'deny'); const denySummary = countEffectRules(subjectRules, 'deny');
const allowConfigured = allowSummary.platforms + allowSummary.models > 0;
if (!props.subjectId) { if (!props.subjectId) {
return ( return (
@@ -160,23 +177,38 @@ export function AccessPermissionEditor(props: {
<div> <div>
<strong>{ineffectiveRules.length} </strong> <strong>{ineffectiveRules.length} </strong>
<span> API Key</span> <span> API Key</span>
<Button
type="button"
className="accessRuleCleanupButton"
variant="outline"
size="sm"
disabled={props.state === 'loading' || cleaningIneffectiveRules}
onClick={() => void clearIneffectiveRules()}
>
<Trash2 size={14} />{cleaningIneffectiveRules ? '清理中' : '一键清理'}
</Button>
</div> </div>
{ineffectiveRules.map((diagnostic) => ( {ineffectiveRules.map((diagnostic) => (
<div className="accessRuleDiagnosticRow" key={diagnostic.ruleId}> <div className="accessRuleDiagnosticRow" key={diagnostic.ruleId}>
<span> <span>
<Badge variant="outline">{diagnostic.effect === 'allow' ? '专属' : '排除'}</Badge> <Badge variant="outline">{diagnostic.effect === 'allow' ? '允许' : '拒绝'}</Badge>
<strong>{diagnostic.resourceName || diagnostic.resourceId}</strong> <strong>{diagnostic.resourceName || diagnostic.resourceId}</strong>
<small>{accessRuleDiagnosticReason(diagnostic.reason)}</small> <small>{accessRuleDiagnosticReason(diagnostic.reason)}</small>
</span> </span>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => void removeIneffectiveRule(diagnostic)}> <Button type="button" variant="outline" size="sm" disabled={props.state === 'loading' || cleaningIneffectiveRules} onClick={() => void removeIneffectiveRule(diagnostic)}>
</Button> </Button>
</div> </div>
))} ))}
</section> </section>
)} )}
<div className="accessGroupHint accessWhitelistHint">
<strong>{allowConfigured ? '已配置白名单' : '未配置白名单'}</strong>
<span>{allowConfigured ? '仅允许所选资源;拒绝规则始终优先。' : '继承上级可用范围;拒绝规则仍会从中排除资源。'}</span>
</div>
<section className="accessPermissionGrid"> <section className="accessPermissionGrid">
<PermissionTreePanel <PermissionTreePanel
clearLabel="清空允许"
emptyText="暂无可维护的平台模型" emptyText="暂无可维护的平台模型"
effect="allow" effect="allow"
expanded={allowExpanded} expanded={allowExpanded}
@@ -185,7 +217,7 @@ export function AccessPermissionEditor(props: {
search={allowSearch} search={allowSearch}
state={props.state} state={props.state}
summary={allowSummary} summary={allowSummary}
title="专属使用(平台/模型" title="允许使用(白名单"
tree={allowTree} tree={allowTree}
onClear={() => void clearEffect('allow')} onClear={() => void clearEffect('allow')}
onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))} onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))}
@@ -198,6 +230,7 @@ export function AccessPermissionEditor(props: {
onTogglePermission={setPermission} onTogglePermission={setPermission}
/> />
<PermissionTreePanel <PermissionTreePanel
clearLabel="清空拒绝"
emptyText="暂无可维护的平台模型" emptyText="暂无可维护的平台模型"
effect="deny" effect="deny"
expanded={denyExpanded} expanded={denyExpanded}
@@ -223,7 +256,25 @@ export function AccessPermissionEditor(props: {
); );
} }
export function ineffectiveRuleCleanupBatches(
subjectType: Extract<GatewayAccessSubjectType, 'user_group' | 'api_key'>,
subjectId: string,
diagnostics: GatewayAPIKeyAccessRuleDiagnostic[],
): GatewayAccessRuleBatchRequest[] {
const batches: GatewayAccessRuleBatchRequest[] = [];
for (const effect of ['allow', 'deny'] as Effect[]) {
const keys = diagnostics
.filter((diagnostic) => !diagnostic.effective && diagnostic.effect === effect)
.map((diagnostic) => makeResourceKey(diagnostic.resourceType as ResourceType, diagnostic.resourceId));
const deleteResources = dedupeResourceKeys(keys).map(resourceRequestFromKey);
if (deleteResources.length === 0) continue;
batches.push({ subjectType, subjectId, effect, upsertResources: [], deleteResources });
}
return batches;
}
function PermissionTreePanel(props: { function PermissionTreePanel(props: {
clearLabel: string;
effect: Effect; effect: Effect;
emptyText: string; emptyText: string;
expanded: Set<string>; expanded: Set<string>;
@@ -259,7 +310,7 @@ function PermissionTreePanel(props: {
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}></Button> <Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}></Button> <Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}></Button> <Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}></Button> <Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}>{props.clearLabel}</Button>
</div> </div>
<div className="accessTreeBox"> <div className="accessTreeBox">
{props.tree.length ? props.tree.map((platform) => ( {props.tree.length ? props.tree.map((platform) => (
@@ -0,0 +1,64 @@
# 访问规则白名单语义迁移
迁移 `0102_access_rule_allow_whitelist_semantics` 将旧版跨主体“专属占用”
规则切换为分层白名单。迁移会归档并删除已有的全部 `allow`,保留 `deny`
不变;发布后新建的 `allow` 将按白名单解释。
## 发布前
1. 暂停管理端和用户工作台的权限配置写入,并停止会创建访问规则的
acceptance、smoke 或运维脚本。仅依赖数据库表锁不足以覆盖迁移前快照到
应用切换之间的时间窗口。
2. 使用只读数据库账号导出脱敏基线:
```bash
AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='<只读数据库连接>' \
scripts/acceptance/export-access-rule-audit.sh export \
--output dist/access-rule-audit/before.json
```
3. 保存命令输出中的总规则数、`allow`/`deny` 数量和 SHA-256。快照仅包含按
主体类型、效果、资源类型、状态聚合的计数和摘要,不包含主体 ID、资源 ID、
API Key Secret 或规则元数据。
4. 确认权限写入仍处于暂停状态,再开始应用发布和数据库迁移。
## 迁移后校验
继续使用只读数据库账号执行:
```bash
AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='<只读数据库连接>' \
scripts/acceptance/export-access-rule-audit.sh verify \
--before dist/access-rule-audit/before.json \
--output dist/access-rule-audit/after.json
```
只有命令返回 `access_rule_audit_verify=PASS` 才能恢复权限配置写入。该校验同时
证明:
- 迁移前 `allow` 数量和摘要与归档表一致;
- 当前表中的旧 `allow` 数量为零;
- `deny` 数量和内容摘要与迁移前一致。
随后按生产验收清单创建隔离用户组和 API Key,验证父级继承、分层白名单交集、
同层并集、`deny` 优先及兄弟主体隔离。验收结束后删除隔离身份、Key 和规则,
并确认没有遗留任务、队列项或并发租约。
## 回滚限制
应用回滚不会恢复旧版“专属占用”规则。新版本一旦写入白名单 `allow`,禁止直接
切回旧应用,否则旧代码会把新白名单解释成跨主体排他规则。
必须回滚到旧应用时:
1. 再次暂停所有访问规则写入和 acceptance/smoke
2. 导出当前脱敏快照,并单独备份数据库;
3. 明确识别并清理迁移完成后创建的 `allow`,不得删除 `deny`,也不得直接恢复
归档中的旧 `allow`
4. 校验当前 `allow=0``deny` 摘要未变化后,才允许切换旧应用;
5. 恢复旧规则必须作为独立、受审的数据恢复操作处理,不能包含在应用自动回滚
中。
迁移本身会在事务中锁定 `gateway_access_rules` 的写入,校验归档数量和摘要后才
删除旧 `allow`。若检测到归档不一致,事务会失败;若迁移完成后出现新白名单,
直接重复执行迁移也会失败,不会误删新规则。
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='postgresql://...' \
scripts/acceptance/export-access-rule-audit.sh export --output <before.json>
AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='postgresql://...' \
scripts/acceptance/export-access-rule-audit.sh verify \
--before <before.json> --output <after.json>
Use a SELECT-only database role. The output contains grouped counts and
SHA-256 digests only; API Key secrets and subject/resource identifiers are not
exported.
EOF
}
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repository_root=$(cd "$script_dir/../.." && pwd)
[[ $# -ge 1 ]] || {
usage >&2
exit 64
}
[[ -n ${AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL:-} ]] || {
echo 'AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL is required' >&2
exit 64
}
case $1 in
export)
[[ ${2:-} == --output && $# -eq 3 ]] || {
usage >&2
exit 64
}
;;
verify)
[[ ${2:-} == --before && ${4:-} == --output && $# -eq 5 ]] || {
usage >&2
exit 64
}
;;
*)
usage >&2
exit 64
;;
esac
umask 077
(
cd "$repository_root/apps/api"
go run ./cmd/access-rule-audit "$@"
)
+8
View File
@@ -37,6 +37,14 @@
"non-null column addition" "non-null column addition"
], ],
"reason": "The adaptive Worker columns use constant defaults on gateway_worker_instances, so PostgreSQL 18 can add them without rewriting the table. Production preflight on 2026-08-03 found 96 rows and a 180224-byte relation; the explicit backfill and constraint validation are therefore bounded. The only change to the 31899648-byte gateway_concurrency_leases relation is a nullable numeric column and a NOT VALID check followed by validation." "reason": "The adaptive Worker columns use constant defaults on gateway_worker_instances, so PostgreSQL 18 can add them without rewriting the table. Production preflight on 2026-08-03 found 96 rows and a 180224-byte relation; the explicit backfill and constraint validation are therefore bounded. The only change to the 31899648-byte gateway_concurrency_leases relation is a nullable numeric column and a NOT VALID check followed by validation."
},
"apps/api/migrations/0102_access_rule_allow_whitelist_semantics.sql": {
"sha256": "7f65855237b7e5ae38b6d80228bf446e5c28eb22956272ced15c9f492b9aad40",
"allowedViolations": [
"procedural SQL body",
"DELETE FROM operation"
],
"reason": "This one-time semantic migration takes a write-blocking/read-compatible lock, records count and SHA-256 manifests, archives every legacy allow row, verifies the archive, and only then deletes legacy allows. Deny rows are checksummed before cleanup and verified unchanged afterward. The procedural blocks make any count or checksum mismatch abort the transaction; reapplying after new whitelist writes also fails before deleting them."
} }
} }
} }