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
+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)
}
}
}