refactor(access): 统一分层白名单权限语义
取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
This commit is contained in:
@@ -43,43 +43,25 @@ func (s *Store) enabledPlatformModels(ctx context.Context) ([]PlatformModel, []P
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByLayeredAccess(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(models))
|
||||
layers := accessRuleLayers(user, true)
|
||||
rules, err := s.listActiveAccessRulesForLayers(ctx, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baselineRules, apiKeyRules := splitLayeredAccessRules(rules)
|
||||
baseline := filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user))
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
return baseline, nil
|
||||
filtered := filterPlatformModelsByAccessLayers(models, rules, layers, permissionLevel(user))
|
||||
if user != nil && strings.TrimSpace(user.APIKeyID) != "" {
|
||||
filtered = filterPlatformModelsByAPIKeyScopes(filtered, user.APIKeyScopes)
|
||||
}
|
||||
apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterPlatformModelsByLayeredRuleSet(user, baseline, baselineRules, apiKeyRules, apiKeyUsers), nil
|
||||
}
|
||||
|
||||
func filterPlatformModelsByLayeredRuleSet(user *auth.User, baseline []PlatformModel, baselineRules []AccessRule, apiKeyRules []AccessRule, apiKeyUsers map[string]*auth.User) []PlatformModel {
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
return baseline
|
||||
}
|
||||
keyFiltered := make([]PlatformModel, 0, len(baseline))
|
||||
for _, model := range baseline {
|
||||
effectiveRules := effectiveAPIKeyRulesForPlatformModel(apiKeyRules, baselineRules, apiKeyUsers, model)
|
||||
if platformModelAllowedByAccessRules(model, effectiveRules, apiKeyAccessRuleSubjects(user), permissionLevel(user)) {
|
||||
keyFiltered = append(keyFiltered, model)
|
||||
}
|
||||
}
|
||||
return filterPlatformModelsByAPIKeyScopes(keyFiltered, user.APIKeyScopes)
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByBaselineAccess(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(models))
|
||||
layers := accessRuleLayers(user, false)
|
||||
rules, err := s.listActiveAccessRulesForLayers(ctx, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baselineRules, _ := splitLayeredAccessRules(rules)
|
||||
return filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user)), nil
|
||||
return filterPlatformModelsByAccessLayers(models, rules, layers, permissionLevel(user)), nil
|
||||
}
|
||||
|
||||
func (s *Store) filterRuntimeCandidatesByLayeredAccess(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) {
|
||||
@@ -90,31 +72,20 @@ func (s *Store) filterRuntimeCandidatesByLayeredAccess(ctx context.Context, user
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, candidateAccessResources(candidates))
|
||||
layers := accessRuleLayers(accessUser, true)
|
||||
rules, err := s.listActiveAccessRulesForLayers(ctx, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baselineRules, apiKeyRules := splitLayeredAccessRules(rules)
|
||||
baseline := filterCandidatesByRuleSet(candidates, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser))
|
||||
if accessUser == nil || strings.TrimSpace(accessUser.APIKeyID) == "" {
|
||||
return baseline, nil
|
||||
}
|
||||
apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyFiltered := make([]RuntimeModelCandidate, 0, len(baseline))
|
||||
for _, candidate := range baseline {
|
||||
effectiveRules := effectiveAPIKeyRulesForCandidate(apiKeyRules, baselineRules, apiKeyUsers, candidate)
|
||||
if candidateAllowedByAccessRules(candidate, effectiveRules, apiKeyAccessRuleSubjects(accessUser), permissionLevel(accessUser)) {
|
||||
keyFiltered = append(keyFiltered, candidate)
|
||||
}
|
||||
}
|
||||
filtered := make([]RuntimeModelCandidate, 0, len(keyFiltered))
|
||||
for _, candidate := range keyFiltered {
|
||||
if modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) {
|
||||
filtered = append(filtered, candidate)
|
||||
filtered := filterCandidatesByAccessLayers(candidates, rules, layers, permissionLevel(accessUser))
|
||||
if accessUser != nil && strings.TrimSpace(accessUser.APIKeyID) != "" {
|
||||
scoped := make([]RuntimeModelCandidate, 0, len(filtered))
|
||||
for _, candidate := range filtered {
|
||||
if modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) {
|
||||
scoped = append(scoped, candidate)
|
||||
}
|
||||
}
|
||||
filtered = scoped
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
@@ -135,12 +106,10 @@ func (s *Store) ListAPIKeyAssignablePlatformModelsForKey(ctx context.Context, us
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(enabledModels))
|
||||
baseline, err := s.filterPlatformModelsByBaselineAccess(ctx, accessUser, enabledModels)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
baselineRules, _ := splitLayeredAccessRules(rules)
|
||||
baseline := filterPlatformModelsByRuleSet(enabledModels, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser))
|
||||
scoped := filterPlatformModelsByAPIKeyScopes(baseline, accessUser.APIKeyScopes)
|
||||
ownedRules, err := s.ListAPIKeyAccessRules(ctx, user)
|
||||
if err != nil {
|
||||
@@ -185,129 +154,140 @@ WHERE k.id = $1::uuid
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
func effectiveAPIKeyRulesForPlatformModel(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, model PlatformModel) []AccessRule {
|
||||
effective := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
accessUser := users[rule.SubjectID]
|
||||
if accessUser == nil || !accessRuleMatchesPlatformModel(rule, model) ||
|
||||
!platformModelAllowedByAccessRules(model, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) ||
|
||||
len(modelaccess.FilterModelTypes(accessUser.APIKeyScopes, model.ModelType)) == 0 {
|
||||
continue
|
||||
}
|
||||
effective = append(effective, rule)
|
||||
}
|
||||
return effective
|
||||
type accessRuleLayer struct {
|
||||
subjectType string
|
||||
subjectIDs map[string]bool
|
||||
}
|
||||
|
||||
func effectiveAPIKeyRulesForCandidate(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, candidate RuntimeModelCandidate) []AccessRule {
|
||||
effective := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
accessUser := users[rule.SubjectID]
|
||||
if accessUser == nil || !accessRuleMatchesCandidate(rule, candidate) ||
|
||||
!candidateAllowedByAccessRules(candidate, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) ||
|
||||
!modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) {
|
||||
var accessRuleLayerOrder = []string{"tenant", "user_group", "user", "api_key"}
|
||||
|
||||
func accessRuleLayers(user *auth.User, includeAPIKey bool) []accessRuleLayer {
|
||||
subjects := accessRuleSubjects(user)
|
||||
layers := make([]accessRuleLayer, 0, len(accessRuleLayerOrder))
|
||||
for _, subjectType := range accessRuleLayerOrder {
|
||||
if subjectType == "api_key" && !includeAPIKey {
|
||||
continue
|
||||
}
|
||||
effective = append(effective, rule)
|
||||
prefix := subjectType + ":"
|
||||
ids := map[string]bool{}
|
||||
for subject := range subjects {
|
||||
if strings.HasPrefix(subject, prefix) {
|
||||
ids[strings.TrimPrefix(subject, prefix)] = true
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
layers = append(layers, accessRuleLayer{subjectType: subjectType, subjectIDs: ids})
|
||||
}
|
||||
}
|
||||
return effective
|
||||
return layers
|
||||
}
|
||||
|
||||
func (s *Store) apiKeyAccessRuleUsers(ctx context.Context, rules []AccessRule) (map[string]*auth.User, error) {
|
||||
ids := make([]string, 0, len(rules))
|
||||
seen := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
if rule.SubjectType != "api_key" || rule.SubjectID == "" || seen[rule.SubjectID] {
|
||||
continue
|
||||
func (s *Store) listActiveAccessRulesForLayers(ctx context.Context, layers []accessRuleLayer) ([]AccessRule, error) {
|
||||
subjects := make([]string, 0)
|
||||
for _, layer := range layers {
|
||||
for id := range layer.subjectIDs {
|
||||
subjects = append(subjects, layer.subjectType+":"+id)
|
||||
}
|
||||
seen[rule.SubjectID] = true
|
||||
ids = append(ids, rule.SubjectID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[string]*auth.User{}, nil
|
||||
if len(subjects) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT k.id::text, k.scopes, u.id::text,
|
||||
COALESCE(u.gateway_tenant_id::text, ''), COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, ''),
|
||||
u.roles, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''), COALESCE(g.group_key, '')
|
||||
FROM gateway_api_keys k
|
||||
JOIN gateway_users u ON u.id = k.gateway_user_id
|
||||
LEFT JOIN gateway_user_groups g ON g.id = COALESCE(k.user_group_id, u.default_user_group_id)
|
||||
WHERE k.id = ANY($1::uuid[])
|
||||
AND k.status = 'active'
|
||||
AND k.deleted_at IS NULL
|
||||
AND (k.expires_at IS NULL OR k.expires_at > now())
|
||||
AND u.status = 'active'
|
||||
AND u.deleted_at IS NULL`, ids)
|
||||
SELECT `+accessRuleColumns+`
|
||||
FROM gateway_access_rules
|
||||
WHERE status = 'active'
|
||||
AND (subject_type || ':' || subject_id::text) = ANY($1)
|
||||
ORDER BY subject_type ASC, priority ASC, created_at ASC`, subjects)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := map[string]*auth.User{}
|
||||
rules := make([]AccessRule, 0)
|
||||
for rows.Next() {
|
||||
var apiKeyID string
|
||||
var scopesBytes []byte
|
||||
var rolesBytes []byte
|
||||
var gatewayUserID string
|
||||
var gatewayTenantID string
|
||||
var tenantID string
|
||||
var tenantKey string
|
||||
var userGroupID string
|
||||
var userGroupKey string
|
||||
if err := rows.Scan(&apiKeyID, &scopesBytes, &gatewayUserID, &gatewayTenantID, &tenantID, &tenantKey, &rolesBytes, &userGroupID, &userGroupKey); err != nil {
|
||||
item, err := scanAccessRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupKeys := []string(nil)
|
||||
if userGroupKey != "" {
|
||||
groupKeys = []string{userGroupKey}
|
||||
}
|
||||
users[apiKeyID] = &auth.User{
|
||||
GatewayUserID: gatewayUserID,
|
||||
GatewayTenantID: gatewayTenantID,
|
||||
TenantID: tenantID,
|
||||
TenantKey: tenantKey,
|
||||
Roles: decodeStringArray(rolesBytes),
|
||||
UserGroupID: userGroupID,
|
||||
UserGroupKey: userGroupKey,
|
||||
UserGroupKeys: groupKeys,
|
||||
APIKeyID: apiKeyID,
|
||||
APIKeyScopes: decodeStringArray(scopesBytes),
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
return users, rows.Err()
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func splitLayeredAccessRules(rules []AccessRule) ([]AccessRule, []AccessRule) {
|
||||
baseline := make([]AccessRule, 0, len(rules))
|
||||
apiKeys := make([]AccessRule, 0, len(rules))
|
||||
func filterPlatformModelsByAccessLayers(models []PlatformModel, rules []AccessRule, layers []accessRuleLayer, level int) []PlatformModel {
|
||||
filtered := append([]PlatformModel(nil), models...)
|
||||
for _, layer := range layers {
|
||||
layerRules := accessRulesForLayer(rules, layer)
|
||||
if len(layerRules) == 0 {
|
||||
continue
|
||||
}
|
||||
next := make([]PlatformModel, 0, len(filtered))
|
||||
for _, model := range filtered {
|
||||
if platformModelAllowedBySubjectLayer(model, layerRules, level) {
|
||||
next = append(next, model)
|
||||
}
|
||||
}
|
||||
filtered = next
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterCandidatesByAccessLayers(candidates []RuntimeModelCandidate, rules []AccessRule, layers []accessRuleLayer, level int) []RuntimeModelCandidate {
|
||||
filtered := append([]RuntimeModelCandidate(nil), candidates...)
|
||||
for _, layer := range layers {
|
||||
layerRules := accessRulesForLayer(rules, layer)
|
||||
if len(layerRules) == 0 {
|
||||
continue
|
||||
}
|
||||
next := make([]RuntimeModelCandidate, 0, len(filtered))
|
||||
for _, candidate := range filtered {
|
||||
if candidateAllowedBySubjectLayer(candidate, layerRules, level) {
|
||||
next = append(next, candidate)
|
||||
}
|
||||
}
|
||||
filtered = next
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func accessRulesForLayer(rules []AccessRule, layer accessRuleLayer) []AccessRule {
|
||||
filtered := make([]AccessRule, 0)
|
||||
for _, rule := range rules {
|
||||
if rule.SubjectType == "api_key" {
|
||||
apiKeys = append(apiKeys, rule)
|
||||
} else {
|
||||
baseline = append(baseline, rule)
|
||||
}
|
||||
}
|
||||
return baseline, apiKeys
|
||||
}
|
||||
|
||||
func filterPlatformModelsByRuleSet(models []PlatformModel, rules []AccessRule, subjects map[string]bool, level int) []PlatformModel {
|
||||
filtered := make([]PlatformModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if platformModelAllowedByAccessRules(model, rules, subjects, level) {
|
||||
filtered = append(filtered, model)
|
||||
if rule.SubjectType == layer.subjectType && layer.subjectIDs[rule.SubjectID] {
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterCandidatesByRuleSet(candidates []RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, level int) []RuntimeModelCandidate {
|
||||
filtered := make([]RuntimeModelCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidateAllowedByAccessRules(candidate, rules, subjects, level) {
|
||||
filtered = append(filtered, candidate)
|
||||
func platformModelAllowedBySubjectLayer(model PlatformModel, rules []AccessRule, level int) bool {
|
||||
return allowedBySubjectLayer(rules, level, func(rule AccessRule) bool {
|
||||
return accessRuleMatchesPlatformModel(rule, model)
|
||||
})
|
||||
}
|
||||
|
||||
func candidateAllowedBySubjectLayer(candidate RuntimeModelCandidate, rules []AccessRule, level int) bool {
|
||||
return allowedBySubjectLayer(rules, level, func(rule AccessRule) bool {
|
||||
return accessRuleMatchesCandidate(rule, candidate)
|
||||
})
|
||||
}
|
||||
|
||||
func allowedBySubjectLayer(rules []AccessRule, level int, matches func(AccessRule) bool) bool {
|
||||
hasAllow := false
|
||||
matchedAllow := false
|
||||
for _, rule := range rules {
|
||||
switch rule.Effect {
|
||||
case "allow":
|
||||
hasAllow = true
|
||||
if level >= rule.MinPermissionLevel && matches(rule) {
|
||||
matchedAllow = true
|
||||
}
|
||||
case "deny":
|
||||
if matches(rule) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
return !hasAllow || matchedAllow
|
||||
}
|
||||
|
||||
func filterPlatformModelsByAPIKeyScopes(models []PlatformModel, scopes []string) []PlatformModel {
|
||||
@@ -393,22 +373,6 @@ func stringValues(value any) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func baselineAccessRuleSubjects(user *auth.User) map[string]bool {
|
||||
subjects := accessRuleSubjects(user)
|
||||
if user != nil && user.APIKeyID != "" {
|
||||
delete(subjects, "api_key:"+user.APIKeyID)
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
func apiKeyAccessRuleSubjects(user *auth.User) map[string]bool {
|
||||
subjects := map[string]bool{}
|
||||
if user != nil && strings.TrimSpace(user.APIKeyID) != "" {
|
||||
subjects["api_key:"+strings.TrimSpace(user.APIKeyID)] = true
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
func permissionLevel(user *auth.User) int {
|
||||
if user == nil {
|
||||
return 0
|
||||
|
||||
@@ -8,90 +8,136 @@ import (
|
||||
)
|
||||
|
||||
func TestLayeredAccessDoesNotLetAPIKeyExpandBaseline(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", BaseModelID: "base-1"}
|
||||
groupUser := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
baselineRules := []AccessRule{{
|
||||
SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny",
|
||||
}}
|
||||
keyRules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow",
|
||||
}}
|
||||
baseline := filterPlatformModelsByRuleSet([]PlatformModel{model}, baselineRules, baselineAccessRuleSubjects(groupUser), 0)
|
||||
actual := filterPlatformModelsByRuleSet(baseline, keyRules, apiKeyAccessRuleSubjects(groupUser), 0)
|
||||
models := []PlatformModel{{ID: "model-1", PlatformID: "platform-1"}, {ID: "model-2", PlatformID: "platform-1"}}
|
||||
user := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-2", Effect: "allow"},
|
||||
}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if len(actual) != 0 {
|
||||
t.Fatalf("api key allow expanded denied baseline: %+v", actual)
|
||||
t.Fatalf("api key allow expanded its parent whitelist: %+v", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyAllowControlsOnlyMatchingKeys(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1"}
|
||||
func TestNoRulesInheritAllModels(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
user := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
actual := filterPlatformModelsByAccessLayers(models, nil, accessRuleLayers(user, true), 0)
|
||||
if !reflect.DeepEqual(actual, models) {
|
||||
t.Fatalf("models without rules = %+v, want %+v", actual, models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyAllowIsCurrentKeyWhitelistOnly(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
rules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow",
|
||||
}}
|
||||
for _, test := range []struct {
|
||||
keyID string
|
||||
want int
|
||||
}{{"key-a", 1}, {"key-b", 0}} {
|
||||
user := &auth.User{APIKeyID: test.keyID}
|
||||
actual := filterPlatformModelsByRuleSet([]PlatformModel{model}, rules, apiKeyAccessRuleSubjects(user), 0)
|
||||
if len(actual) != test.want {
|
||||
t.Fatalf("key %s received %d models, want %d", test.keyID, len(actual), test.want)
|
||||
want []string
|
||||
}{{"key-a", []string{"model-1"}}, {"key-b", []string{"model-1", "model-2"}}} {
|
||||
user := &auth.User{GatewayUserID: "user-1", APIKeyID: test.keyID}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("key %s received %v, want %v", test.keyID, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredAccessDenyWinsAndNoRulesInherit(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
keyUser := &auth.User{APIKeyID: "key-a", APIKeyScopes: []string{"chat"}}
|
||||
keyUsers := map[string]*auth.User{"key-a": keyUser}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(keyUser, []PlatformModel{model}, nil, nil, keyUsers); len(got) != 1 {
|
||||
t.Fatalf("key without rules did not inherit baseline: %+v", got)
|
||||
func TestUserGroupAllowDoesNotAffectOtherGroups(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
rules := []AccessRule{{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"}}
|
||||
for _, test := range []struct {
|
||||
groupID string
|
||||
want []string
|
||||
}{{"group-a", []string{"model-1"}}, {"group-b", []string{"model-1", "model-2"}}} {
|
||||
user := &auth.User{GatewayUserID: "user-1", UserGroupID: test.groupID}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, false), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("group %s received %v, want %v", test.groupID, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantGroupUserAndKeyWhitelistsIntersect(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"}}
|
||||
user := &auth.User{GatewayTenantID: "tenant-1", GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny"},
|
||||
{SubjectType: "tenant", SubjectID: "tenant-1", ResourceType: "platform_model", ResourceID: "a", Effect: "allow"},
|
||||
{SubjectType: "tenant", SubjectID: "tenant-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "c", Effect: "allow"},
|
||||
{SubjectType: "user", SubjectID: "user-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user", SubjectID: "user-1", ResourceType: "platform_model", ResourceID: "d", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "c", Effect: "allow"},
|
||||
}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(keyUser, []PlatformModel{model}, nil, rules, keyUsers); len(got) != 0 {
|
||||
t.Fatalf("matching deny did not override allow: %+v", got)
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"b"}) {
|
||||
t.Fatalf("layered whitelist result = %v, want [b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectUserIgnoresAPIKeyRules(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
func TestDenyWinsAndPlatformAllowIncludesChildren(t *testing.T) {
|
||||
models := []PlatformModel{
|
||||
{ID: "allowed", PlatformID: "platform-1"},
|
||||
{ID: "denied", PlatformID: "platform-1"},
|
||||
{ID: "other", PlatformID: "platform-2"},
|
||||
}
|
||||
user := &auth.User{GatewayUserID: "user-1", APIKeyID: "key-1"}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform", ResourceID: "platform-1", Effect: "allow"},
|
||||
{SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "denied", Effect: "deny"},
|
||||
}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"allowed"}) {
|
||||
t.Fatalf("platform allow with child deny = %v, want [allowed]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameLayerAllowsUnionAndAnyDenyWins(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "a"}, {ID: "b"}, {ID: "c"}}
|
||||
layers := []accessRuleLayer{{subjectType: "user_group", subjectIDs: map[string]bool{"group-a": true, "group-b": true}}}
|
||||
rules := []AccessRule{
|
||||
{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "a", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-b", ResourceType: "platform_model", ResourceID: "b", Effect: "allow"},
|
||||
{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "b", Effect: "deny"},
|
||||
}
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, layers, 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"a"}) {
|
||||
t.Fatalf("same-layer allow union and deny result = %v, want [a]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModelRuleMatchesEverySource(t *testing.T) {
|
||||
models := []PlatformModel{
|
||||
{ID: "source-a", BaseModelID: "base-1"},
|
||||
{ID: "source-b", BaseModelID: "base-1"},
|
||||
{ID: "source-c", BaseModelID: "base-2"},
|
||||
}
|
||||
user := &auth.User{GatewayUserID: "user-1"}
|
||||
rules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow",
|
||||
SubjectType: "user", SubjectID: "user-1", ResourceType: "base_model", ResourceID: "base-1", Effect: "allow",
|
||||
}}
|
||||
keyUsers := map[string]*auth.User{"key-a": {APIKeyID: "key-a", APIKeyScopes: []string{"chat"}}}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(&auth.User{GatewayUserID: "user-1"}, []PlatformModel{model}, nil, rules, keyUsers); len(got) != 1 {
|
||||
t.Fatalf("direct user was constrained by API key exclusive rule: %+v", got)
|
||||
actual := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, false), 0)
|
||||
if got := platformModelIDs(actual); !reflect.DeepEqual(got, []string{"source-a", "source-b"}) {
|
||||
t.Fatalf("base-model whitelist result = %v, want both base-1 sources", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIneffectiveAPIKeyRuleDoesNotControlAnotherKey(t *testing.T) {
|
||||
model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
staleRule := AccessRule{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"}
|
||||
groupDeny := AccessRule{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny"}
|
||||
keyUsers := map[string]*auth.User{
|
||||
"key-a": {APIKeyID: "key-a", UserGroupID: "group-a", APIKeyScopes: []string{"chat"}},
|
||||
func TestAllowPermissionLevelFailsClosed(t *testing.T) {
|
||||
models := []PlatformModel{{ID: "model-1"}, {ID: "model-2"}}
|
||||
rules := []AccessRule{{
|
||||
SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow", MinPermissionLevel: 2,
|
||||
}}
|
||||
user := &auth.User{GatewayUserID: "user-1", APIKeyID: "key-1"}
|
||||
if got := filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 1); len(got) != 0 {
|
||||
t.Fatalf("insufficient permission inherited models instead of failing closed: %+v", got)
|
||||
}
|
||||
keyB := &auth.User{APIKeyID: "key-b", UserGroupID: "group-b", APIKeyScopes: []string{"chat"}}
|
||||
if got := filterPlatformModelsByLayeredRuleSet(keyB, []PlatformModel{model}, []AccessRule{groupDeny}, []AccessRule{staleRule}, keyUsers); len(got) != 1 {
|
||||
t.Fatalf("stale key-a rule blocked authorized key-b: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformRuleOnlyControlsModelsItsKeyCanAccess(t *testing.T) {
|
||||
allowed := PlatformModel{ID: "allowed", PlatformID: "platform-1", ModelType: StringList{"image_generate"}}
|
||||
denied := PlatformModel{ID: "denied", PlatformID: "platform-1", ModelType: StringList{"text_generate"}}
|
||||
rule := AccessRule{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform", ResourceID: "platform-1", Effect: "allow"}
|
||||
groupDeny := AccessRule{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "denied", Effect: "deny"}
|
||||
ruleUser := &auth.User{APIKeyID: "key-a", UserGroupID: "group-a", APIKeyScopes: []string{"image"}}
|
||||
users := map[string]*auth.User{"key-a": ruleUser}
|
||||
if got := effectiveAPIKeyRulesForPlatformModel([]AccessRule{rule}, []AccessRule{groupDeny}, users, allowed); len(got) != 1 {
|
||||
t.Fatalf("platform rule should control the allowed image model: %+v", got)
|
||||
}
|
||||
if got := effectiveAPIKeyRulesForPlatformModel([]AccessRule{rule}, []AccessRule{groupDeny}, users, denied); len(got) != 0 {
|
||||
t.Fatalf("platform rule controlled a group-denied or scope-denied model: %+v", got)
|
||||
if got := platformModelIDs(filterPlatformModelsByAccessLayers(models, rules, accessRuleLayers(user, true), 2)); !reflect.DeepEqual(got, []string{"model-1"}) {
|
||||
t.Fatalf("sufficient permission result = %v, want [model-1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,3 +194,11 @@ func TestDiagnoseAPIKeyRulesExplainsEachInactiveLayer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func platformModelIDs(models []PlatformModel) []string {
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/accessruleaudit"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestAccessRuleAllowWhitelistMigrationArchivesAndRemovesLegacyAllows(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run access-rule migration PostgreSQL integration tests")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
admin, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect access-rule migration test database: %v", err)
|
||||
}
|
||||
t.Cleanup(admin.Close)
|
||||
var databaseName string
|
||||
if err := admin.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
|
||||
t.Fatalf("read access-rule migration test database name: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(databaseName), "test") {
|
||||
t.Fatalf("refusing to use non-test database %q", databaseName)
|
||||
}
|
||||
if _, err := admin.Exec(ctx, `CREATE EXTENSION IF NOT EXISTS pgcrypto`); err != nil {
|
||||
t.Fatalf("ensure pgcrypto: %v", err)
|
||||
}
|
||||
|
||||
schemaName := "gateway_access_rule_migration_" + strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "")
|
||||
schemaIdentifier := pgx.Identifier{schemaName}.Sanitize()
|
||||
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schemaIdentifier); err != nil {
|
||||
t.Fatalf("create access-rule migration test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if _, err := admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+schemaIdentifier+` CASCADE`); err != nil {
|
||||
t.Errorf("drop access-rule migration test schema: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse access-rule migration test database URL: %v", err)
|
||||
}
|
||||
config.ConnConfig.RuntimeParams["search_path"] = schemaName + ",public"
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
t.Fatalf("connect access-rule migration test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE gateway_access_rules (
|
||||
id uuid PRIMARY KEY,
|
||||
subject_type text NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
resource_id uuid NOT NULL,
|
||||
effect text NOT NULL,
|
||||
priority integer NOT NULL DEFAULT 100,
|
||||
min_permission_level integer NOT NULL DEFAULT 0,
|
||||
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO gateway_access_rules (
|
||||
id, subject_type, subject_id, resource_type, resource_id, effect,
|
||||
priority, min_permission_level, conditions, metadata, status,
|
||||
created_at, updated_at
|
||||
) VALUES
|
||||
(
|
||||
'10000000-0000-0000-0000-000000000001', 'user_group',
|
||||
'20000000-0000-0000-0000-000000000001', 'platform_model',
|
||||
'30000000-0000-0000-0000-000000000001', 'allow', 10, 0,
|
||||
'{"region":"cn"}', '{"source":"legacy"}', 'active',
|
||||
'2026-08-01T00:00:00Z', '2026-08-01T01:00:00Z'
|
||||
),
|
||||
(
|
||||
'10000000-0000-0000-0000-000000000002', 'api_key',
|
||||
'20000000-0000-0000-0000-000000000002', 'platform',
|
||||
'30000000-0000-0000-0000-000000000002', 'allow', 20, 1,
|
||||
'{}', '{"source":"disabled-legacy"}', 'disabled',
|
||||
'2026-08-02T00:00:00Z', '2026-08-02T01:00:00Z'
|
||||
),
|
||||
(
|
||||
'10000000-0000-0000-0000-000000000003', 'user',
|
||||
'20000000-0000-0000-0000-000000000003', 'base_model',
|
||||
'30000000-0000-0000-0000-000000000003', 'deny', 30, 2,
|
||||
'{"reason":"blocked"}', '{"source":"deny-must-remain"}', 'active',
|
||||
'2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z'
|
||||
);`); err != nil {
|
||||
t.Fatalf("seed pre-migration access rules: %v", err)
|
||||
}
|
||||
beforeAudit, err := accessruleaudit.Export(ctx, pool, time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("export pre-migration access-rule audit: %v", err)
|
||||
}
|
||||
|
||||
_, currentFile, _, _ := runtime.Caller(0)
|
||||
migrationPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations", "0102_access_rule_allow_whitelist_semantics.sql")
|
||||
migration, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read access-rule whitelist migration: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(migration)); err != nil {
|
||||
t.Fatalf("apply access-rule whitelist migration: %v", err)
|
||||
}
|
||||
afterAudit, err := accessruleaudit.Export(ctx, pool, time.Date(2026, 8, 3, 0, 1, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("export post-migration access-rule audit: %v", err)
|
||||
}
|
||||
if err := accessruleaudit.VerifyMigration(beforeAudit, afterAudit); err != nil {
|
||||
t.Fatalf("verify pre/post access-rule audit: %v", err)
|
||||
}
|
||||
|
||||
var liveAllowCount, liveDenyCount, archivedAllowCount int64
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FILTER (WHERE effect = 'allow'), COUNT(*) FILTER (WHERE effect = 'deny') FROM gateway_access_rules`).Scan(&liveAllowCount, &liveDenyCount); err != nil {
|
||||
t.Fatalf("read migrated live rule counts: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_access_rule_allow_archive WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics'`).Scan(&archivedAllowCount); err != nil {
|
||||
t.Fatalf("read archived allow count: %v", err)
|
||||
}
|
||||
if liveAllowCount != 0 || liveDenyCount != 1 || archivedAllowCount != 2 {
|
||||
t.Fatalf("unexpected migration counts: liveAllow=%d liveDeny=%d archivedAllow=%d", liveAllowCount, liveDenyCount, archivedAllowCount)
|
||||
}
|
||||
|
||||
var expectedAllowCount int64
|
||||
var expectedAllowSHA, archivedAllowSHA string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT allow_count, allow_sha256
|
||||
FROM gateway_access_rule_migration_batches
|
||||
WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics'`).Scan(&expectedAllowCount, &expectedAllowSHA); err != nil {
|
||||
t.Fatalf("read access-rule migration batch: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT encode(digest(COALESCE(string_agg(row_sha256, E'\n' ORDER BY rule_id), ''), 'sha256'), 'hex')
|
||||
FROM gateway_access_rule_allow_archive
|
||||
WHERE migration_batch = '0102_access_rule_allow_whitelist_semantics'`).Scan(&archivedAllowSHA); err != nil {
|
||||
t.Fatalf("hash archived allow rules: %v", err)
|
||||
}
|
||||
if expectedAllowCount != archivedAllowCount || expectedAllowSHA != archivedAllowSHA {
|
||||
t.Fatalf("archive manifest mismatch: count=%d/%d sha=%s/%s", expectedAllowCount, archivedAllowCount, expectedAllowSHA, archivedAllowSHA)
|
||||
}
|
||||
|
||||
var denyMetadata, denyConditions, denyStatus string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT metadata::text, conditions::text, status
|
||||
FROM gateway_access_rules
|
||||
WHERE id = '10000000-0000-0000-0000-000000000003'`).Scan(&denyMetadata, &denyConditions, &denyStatus); err != nil {
|
||||
t.Fatalf("read preserved deny rule: %v", err)
|
||||
}
|
||||
if denyMetadata != `{"source": "deny-must-remain"}` || denyConditions != `{"reason": "blocked"}` || denyStatus != "active" {
|
||||
t.Fatalf("deny rule changed: metadata=%s conditions=%s status=%s", denyMetadata, denyConditions, denyStatus)
|
||||
}
|
||||
|
||||
// Reapplying without new allows is idempotent. If new whitelist rules have
|
||||
// been written, the recorded legacy batch checksum makes reapplication fail
|
||||
// before any new rule can be deleted.
|
||||
if _, err := pool.Exec(ctx, string(migration)); err != nil {
|
||||
t.Fatalf("reapply access-rule whitelist migration: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO gateway_access_rules (
|
||||
id, subject_type, subject_id, resource_type, resource_id, effect
|
||||
) VALUES (
|
||||
'10000000-0000-0000-0000-000000000004', 'api_key',
|
||||
'20000000-0000-0000-0000-000000000004', 'platform_model',
|
||||
'30000000-0000-0000-0000-000000000004', 'allow'
|
||||
)`); err != nil {
|
||||
t.Fatalf("insert post-migration whitelist rule: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(migration)); err == nil {
|
||||
t.Fatal("reapplying the legacy cleanup after new whitelist writes must fail")
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_access_rules WHERE id = '10000000-0000-0000-0000-000000000004'`).Scan(&liveAllowCount); err != nil {
|
||||
t.Fatalf("read post-migration whitelist rule: %v", err)
|
||||
}
|
||||
if liveAllowCount != 1 {
|
||||
t.Fatalf("failed migration reapply removed a new whitelist rule: count=%d", liveAllowCount)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,6 @@ type AccessRuleBatchInput struct {
|
||||
DeleteResources []AccessRuleResourceInput `json:"deleteResources"`
|
||||
}
|
||||
|
||||
type accessRuleResource struct {
|
||||
Type string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (s *Store) ListAccessRules(ctx context.Context) ([]AccessRule, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+accessRuleColumns+`
|
||||
@@ -368,191 +363,6 @@ WHERE u.id = $1::uuid
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByAccessRules(
|
||||
ctx context.Context,
|
||||
user *auth.User,
|
||||
models []PlatformModel,
|
||||
excludedSubjectTypes map[string]bool,
|
||||
) ([]PlatformModel, error) {
|
||||
if len(models) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
resources := platformModelAccessResources(models)
|
||||
if len(resources) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, resources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
if len(excludedSubjectTypes) > 0 {
|
||||
rules = filterAccessRulesBySubjectType(rules, excludedSubjectTypes)
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
}
|
||||
subjects := accessRuleSubjects(user)
|
||||
level := 0
|
||||
if user != nil {
|
||||
level = auth.PermissionLevel(user.Roles)
|
||||
}
|
||||
filtered := models[:0]
|
||||
for _, model := range models {
|
||||
if platformModelAllowedByAccessRules(model, rules, subjects, level) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func filterAccessRulesBySubjectType(rules []AccessRule, excludedSubjectTypes map[string]bool) []AccessRule {
|
||||
filtered := make([]AccessRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if excludedSubjectTypes[rule.SubjectType] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, rule)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) {
|
||||
values := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.Type == "" || resource.ID == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, resource.Type+":"+resource.ID)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+accessRuleColumns+`
|
||||
FROM gateway_access_rules
|
||||
WHERE status = 'active'
|
||||
AND (resource_type || ':' || resource_id::text) = ANY($1)
|
||||
ORDER BY priority ASC, created_at ASC`, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
rules := make([]AccessRule, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanAccessRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func candidateAccessResources(candidates []RuntimeModelCandidate) []accessRuleResource {
|
||||
seen := map[string]bool{}
|
||||
out := make([]accessRuleResource, 0, len(candidates)*3)
|
||||
add := func(resourceType string, resourceID string) {
|
||||
key := resourceType + ":" + resourceID
|
||||
if resourceID == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, accessRuleResource{Type: resourceType, ID: resourceID})
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
add("platform", candidate.PlatformID)
|
||||
add("platform_model", candidate.PlatformModelID)
|
||||
add("base_model", candidate.BaseModelID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func platformModelAccessResources(models []PlatformModel) []accessRuleResource {
|
||||
seen := map[string]bool{}
|
||||
out := make([]accessRuleResource, 0, len(models)*3)
|
||||
add := func(resourceType string, resourceID string) {
|
||||
key := resourceType + ":" + resourceID
|
||||
if resourceID == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, accessRuleResource{Type: resourceType, ID: resourceID})
|
||||
}
|
||||
for _, model := range models {
|
||||
add("platform", model.PlatformID)
|
||||
add("platform_model", model.ID)
|
||||
add("base_model", model.BaseModelID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func candidateAllowedByAccessRules(candidate RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool {
|
||||
resourceKeys := map[string]bool{
|
||||
"platform:" + candidate.PlatformID: true,
|
||||
"platform_model:" + candidate.PlatformModelID: true,
|
||||
"base_model:" + candidate.BaseModelID: candidate.BaseModelID != "",
|
||||
}
|
||||
allowByResource := map[string]bool{}
|
||||
matchedAllowByResource := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
resourceKey := rule.ResourceType + ":" + rule.ResourceID
|
||||
if !resourceKeys[resourceKey] {
|
||||
continue
|
||||
}
|
||||
subjectKey := rule.SubjectType + ":" + rule.SubjectID
|
||||
if rule.Effect == "deny" && subjects[subjectKey] {
|
||||
return false
|
||||
}
|
||||
if rule.Effect == "allow" {
|
||||
allowByResource[resourceKey] = true
|
||||
if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel {
|
||||
matchedAllowByResource[resourceKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for resourceKey := range allowByResource {
|
||||
if !matchedAllowByResource[resourceKey] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func platformModelAllowedByAccessRules(model PlatformModel, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool {
|
||||
resourceKeys := map[string]bool{
|
||||
"platform:" + model.PlatformID: true,
|
||||
"platform_model:" + model.ID: true,
|
||||
"base_model:" + model.BaseModelID: model.BaseModelID != "",
|
||||
}
|
||||
allowByResource := map[string]bool{}
|
||||
matchedAllowByResource := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
resourceKey := rule.ResourceType + ":" + rule.ResourceID
|
||||
if !resourceKeys[resourceKey] {
|
||||
continue
|
||||
}
|
||||
subjectKey := rule.SubjectType + ":" + rule.SubjectID
|
||||
if rule.Effect == "deny" && subjects[subjectKey] {
|
||||
return false
|
||||
}
|
||||
if rule.Effect == "allow" {
|
||||
allowByResource[resourceKey] = true
|
||||
if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel {
|
||||
matchedAllowByResource[resourceKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for resourceKey := range allowByResource {
|
||||
if !matchedAllowByResource[resourceKey] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func accessRuleSubjects(user *auth.User) map[string]bool {
|
||||
subjects := map[string]bool{}
|
||||
if user == nil {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterAccessRulesBySubjectTypeExcludesAPIKeyRulesOnly(t *testing.T) {
|
||||
rules := []AccessRule{
|
||||
{ID: "api-key-allow", SubjectType: "api_key", Effect: "allow"},
|
||||
{ID: "api-key-deny", SubjectType: "api_key", Effect: "deny"},
|
||||
{ID: "user-group-allow", SubjectType: "user_group", Effect: "allow"},
|
||||
{ID: "user-deny", SubjectType: "user", Effect: "deny"},
|
||||
{ID: "tenant-allow", SubjectType: "tenant", Effect: "allow"},
|
||||
}
|
||||
|
||||
filtered := filterAccessRulesBySubjectType(rules, map[string]bool{"api_key": true})
|
||||
if len(filtered) != 3 {
|
||||
t.Fatalf("filtered rule count = %d, want 3: %+v", len(filtered), filtered)
|
||||
}
|
||||
for _, rule := range filtered {
|
||||
if rule.SubjectType == "api_key" {
|
||||
t.Fatalf("api-key rule should not affect the owning user's assignable resources: %+v", rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user