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