diff --git a/apps/api/cmd/access-rule-audit/main.go b/apps/api/cmd/access-rule-audit/main.go new file mode 100644 index 0000000..1b036b9 --- /dev/null +++ b/apps/api/cmd/access-rule-audit/main.go @@ -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 + easyai-ai-gateway-access-rule-audit verify --before --output + +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 +} diff --git a/apps/api/internal/accessruleaudit/audit.go b/apps/api/internal/accessruleaudit/audit.go new file mode 100644 index 0000000..fce313e --- /dev/null +++ b/apps/api/internal/accessruleaudit/audit.go @@ -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) +} diff --git a/apps/api/internal/accessruleaudit/audit_test.go b/apps/api/internal/accessruleaudit/audit_test.go new file mode 100644 index 0000000..99d166b --- /dev/null +++ b/apps/api/internal/accessruleaudit/audit_test.go @@ -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, + } +} diff --git a/apps/api/internal/httpapi/access_rule_handlers.go b/apps/api/internal/httpapi/access_rule_handlers.go index e77e9ab..81066be 100644 --- a/apps/api/internal/httpapi/access_rule_handlers.go +++ b/apps/api/internal/httpapi/access_rule_handlers.go @@ -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 diff --git a/apps/api/internal/httpapi/api_key_model_access_integration_test.go b/apps/api/internal/httpapi/api_key_model_access_integration_test.go index a57f5b9..46dcda6 100644 --- a/apps/api/internal/httpapi/api_key_model_access_integration_test.go +++ b/apps/api/internal/httpapi/api_key_model_access_integration_test.go @@ -222,9 +222,16 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) { batchKeyRule(adminToken, keyLegacyID, legacyTextModel.ID, "allow", http.StatusOK) batchKeyRule(adminToken, keyScopeID, scopeTextModel.ID, "allow", http.StatusOK) createRule("user_group", groupAID, imageModel.ID, "allow") + createRule("user_group", groupAID, multiModel.ID, "allow") + createRule("user_group", groupAID, multiSecondModel.ID, "allow") + createRule("user_group", groupAID, keyDenyImageModel.ID, "allow") + createRule("user_group", groupAID, scopeTextModel.ID, "allow") createRule("user_group", groupAID, textModel.ID, "deny") createRule("user_group", groupAID, legacyTextModel.ID, "deny") createRule("user_group", groupBID, groupBImageModel.ID, "allow") + createRule("user_group", groupBID, textModel.ID, "allow") + createRule("user_group", groupBID, scopeTextModel.ID, "allow") + createRule("user_group", groupBID, legacyTextModel.ID, "allow") doJSON(t, server.URL, http.MethodPatch, "/api/v1/api-keys/"+keyScopeID+"/scopes", adminToken, map[string]any{ "scopes": []string{"image"}, }, http.StatusOK, nil) @@ -233,6 +240,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) { createRule("api_key", keyAID, textModel.ID, "allow") createRule("api_key", keyScopeID, disabledImageModel.ID, "allow") batchKeyRule(adminToken, keyAID, imageModel.ID, "allow", http.StatusOK) + batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "allow", http.StatusOK) batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "deny", http.StatusOK) loadAssignable := func(token string, keyID string) modelAccessAssignableResponse { @@ -252,7 +260,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) { t.Fatalf("key rules incorrectly removed a model from the assignable set: %+v", assignable.Items) } if containsModelID(assignable.Items, disabledImageModel.ID) || containsModelID(assignable.Items, groupBImageModel.ID) { - t.Fatalf("disabled or another-group-exclusive source leaked into assignable models: %+v", assignable.Items) + t.Fatalf("disabled or group-A-whitelist-excluded source leaked into assignable models: %+v", assignable.Items) } for _, model := range assignable.Items { if model.ID == multiModel.ID && (len(model.ModelType) != 1 || model.ModelType[0] != "image_generate") { @@ -292,22 +300,22 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) { if containsModelID(platformModels.Items, textModel.ID) { t.Fatalf("group-denied text model leaked into key model list: %+v", platformModels.Items) } - if !containsModelID(platformModels.Items, imageModel.ID) || containsModelID(platformModels.Items, keyDenyImageModel.ID) || containsModelID(platformModels.Items, disabledImageModel.ID) { + if !containsModelID(platformModels.Items, imageModel.ID) || containsModelID(platformModels.Items, multiModel.ID) || containsModelID(platformModels.Items, keyDenyImageModel.ID) || containsModelID(platformModels.Items, disabledImageModel.ID) { t.Fatalf("key allow/deny or global availability was not reflected in rich list: %+v", platformModels.Items) } if !containsModelID(loadAssignable(adminToken, keyAInheritedID).Items, imageModel.ID) { - t.Fatalf("assignable list must ignore another key's exclusive rule") + t.Fatalf("assignable list must ignore another key's whitelist") } var inheritedPlatformModels struct { Items []modelAccessFixture `json:"items"` } doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyAInheritedSecret, nil, http.StatusOK, &inheritedPlatformModels) - if containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) { - t.Fatalf("key exclusive/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items) + if !containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) || !containsModelID(inheritedPlatformModels.Items, multiModel.ID) { + t.Fatalf("key whitelist/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items) } groupBAssignable := loadAssignable(userBToken, keyBImageID) if containsModelID(groupBAssignable.Items, imageModel.ID) || !containsModelID(groupBAssignable.Items, groupBImageModel.ID) { - t.Fatalf("group exclusive rules were not applied to key candidates: %+v", groupBAssignable.Items) + t.Fatalf("group whitelist rules were not applied to key candidates: %+v", groupBAssignable.Items) } var groupBChatModels struct { Items []modelAccessFixture `json:"items"` @@ -326,7 +334,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) { ID string `json:"id"` } `json:"data"` } - doJSON(t, server.URL, http.MethodGet, "/v1/models", keyASecret, nil, http.StatusOK, &openAIList) + doJSON(t, server.URL, http.MethodGet, "/v1/models", keyAInheritedSecret, nil, http.StatusOK, &openAIList) if openAIList.Object != "list" || countOpenAIModel(openAIList.Data, multiName) != 1 { t.Fatalf("openai model list is not deduplicated: %+v", openAIList) } @@ -373,21 +381,34 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) { t.Logf("多来源模型:初始来源=2,排除一个后 OpenAI 逻辑模型=1,全部排除后=0;调用状态=200/404") doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{ - "model": imageName, "prompt": "exclusive key", "runMode": "simulation", "simulation": true, + "model": imageName, "prompt": "key whitelist", "runMode": "simulation", "simulation": true, }, http.StatusOK, nil) doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyAInheritedSecret, map[string]any{ "model": imageName, "prompt": "sibling key", "runMode": "simulation", "simulation": true, - }, http.StatusNotFound, nil) + }, http.StatusOK, nil) doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{ "model": keyDenyImageName, "prompt": "key deny", "runMode": "simulation", "simulation": true, }, http.StatusNotFound, nil) doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyBImageSecret, map[string]any{ - "model": groupBImageName, "prompt": "group B exclusive", "runMode": "simulation", "simulation": true, + "model": groupBImageName, "prompt": "group B whitelist", "runMode": "simulation", "simulation": true, }, http.StatusOK, nil) - t.Logf("实际调用:KEY 专属 200,兄弟 KEY 404,KEY 排除 404,组 B 专属 200") + var taskCountBefore, taskCountAfter int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountBefore); err != nil { + t.Fatalf("count tasks before rejected whitelist request: %v", err) + } + doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{ + "model": multiName, "prompt": "not in current key whitelist", "runMode": "simulation", "simulation": true, + }, http.StatusNotFound, nil) + if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountAfter); err != nil { + t.Fatalf("count tasks after rejected whitelist request: %v", err) + } + if taskCountAfter != taskCountBefore { + t.Fatalf("rejected whitelist request created a task: before=%d after=%d", taskCountBefore, taskCountAfter) + } + t.Logf("实际调用:KEY 白名单允许 200,兄弟 KEY 不受影响 200,KEY deny 404,组 B 白名单 200;拒绝请求未创建任务") - // Group B can still use text: stale allows whose owners lost group access or - // scope are removed from the global exclusive set. + // Group B can still use text: group A and stale API-key allows are never + // evaluated for this group's keys. if !containsModelID(loadAssignable(userBToken, keyBID).Items, textModel.ID) { t.Fatalf("group B chat key candidate list lost the group-A denied model") } diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index 325517c..a8120b4 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -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 { diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 85e206c..453989f 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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 { diff --git a/apps/api/internal/httpapi/model_list_handlers.go b/apps/api/internal/httpapi/model_list_handlers.go index 49d70b4..1748e24 100644 --- a/apps/api/internal/httpapi/model_list_handlers.go +++ b/apps/api/internal/httpapi/model_list_handlers.go @@ -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 diff --git a/apps/api/internal/runner/pricing.go b/apps/api/internal/runner/pricing.go index 6bb6ef3..9641d5a 100644 --- a/apps/api/internal/runner/pricing.go +++ b/apps/api/internal/runner/pricing.go @@ -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) { diff --git a/apps/api/internal/store/access_policy.go b/apps/api/internal/store/access_policy.go index 8459355..87ab671 100644 --- a/apps/api/internal/store/access_policy.go +++ b/apps/api/internal/store/access_policy.go @@ -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 diff --git a/apps/api/internal/store/access_policy_test.go b/apps/api/internal/store/access_policy_test.go index 889242d..73cea8d 100644 --- a/apps/api/internal/store/access_policy_test.go +++ b/apps/api/internal/store/access_policy_test.go @@ -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 +} diff --git a/apps/api/internal/store/access_rule_allow_migration_integration_test.go b/apps/api/internal/store/access_rule_allow_migration_integration_test.go new file mode 100644 index 0000000..2befb53 --- /dev/null +++ b/apps/api/internal/store/access_rule_allow_migration_integration_test.go @@ -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) + } +} diff --git a/apps/api/internal/store/access_rules.go b/apps/api/internal/store/access_rules.go index 00d7166..aaf7f79 100644 --- a/apps/api/internal/store/access_rules.go +++ b/apps/api/internal/store/access_rules.go @@ -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 { diff --git a/apps/api/internal/store/access_rules_test.go b/apps/api/internal/store/access_rules_test.go deleted file mode 100644 index 6458c82..0000000 --- a/apps/api/internal/store/access_rules_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/apps/api/migrations/0102_access_rule_allow_whitelist_semantics.sql b/apps/api/migrations/0102_access_rule_allow_whitelist_semantics.sql new file mode 100644 index 0000000..006f323 --- /dev/null +++ b/apps/api/migrations/0102_access_rule_allow_whitelist_semantics.sql @@ -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 +$$; diff --git a/apps/web/src/pages/admin/AccessPermissionEditor.test.tsx b/apps/web/src/pages/admin/AccessPermissionEditor.test.tsx index 6adf893..80a4d7d 100644 --- a/apps/web/src/pages/admin/AccessPermissionEditor.test.tsx +++ b/apps/web/src/pages/admin/AccessPermissionEditor.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import type { IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; -import { AccessPermissionEditor } from './AccessPermissionEditor'; +import { AccessPermissionEditor, ineffectiveRuleCleanupBatches } from './AccessPermissionEditor'; describe('AccessPermissionEditor API Key diagnostics', () => { it('shows retained ineffective rules and only the scope-pruned model capabilities', () => { @@ -54,10 +54,59 @@ describe('AccessPermissionEditor API Key diagnostics', () => { ); expect(html).toContain('1 条规则当前不生效'); + expect(html).toContain('未配置白名单'); + expect(html).toContain('继承上级可用范围'); + expect(html).toContain('允许使用(白名单)'); + expect(html).toContain('清空允许'); expect(html).toContain('Text Model'); expect(html).toContain('不在当前 API Key 的能力范围内'); expect(html).toContain('移除规则'); + expect(html).toContain('一键清理'); expect(html).toContain('image_generate'); expect(html).not.toContain('text_generate'); }); + + it('shows the restrictive whitelist state when the current subject has an allow', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('已配置白名单'); + expect(html).toContain('仅允许所选资源'); + expect(html).not.toContain('专属使用'); + }); + + it('groups one-click cleanup by effect and deduplicates retained diagnostics', () => { + const batches = ineffectiveRuleCleanupBatches('api_key', 'key-1', [ + { ruleId: 'allow-1', resourceType: 'platform_model', resourceId: 'model-1', effect: 'allow', effective: false, reason: 'scope_not_allowed' }, + { ruleId: 'allow-duplicate', resourceType: 'platform_model', resourceId: 'model-1', effect: 'allow', effective: false, reason: 'scope_not_allowed' }, + { ruleId: 'deny-1', resourceType: 'platform', resourceId: 'platform-1', effect: 'deny', effective: false, reason: 'resource_unavailable' }, + { ruleId: 'effective', resourceType: 'platform_model', resourceId: 'model-2', effect: 'allow', effective: true }, + ]); + expect(batches).toHaveLength(2); + expect(batches[0]).toMatchObject({ effect: 'allow', deleteResources: [{ resourceType: 'platform_model', resourceId: 'model-1' }] }); + expect(batches[1]).toMatchObject({ effect: 'deny', deleteResources: [{ resourceType: 'platform', resourceId: 'platform-1' }] }); + }); }); diff --git a/apps/web/src/pages/admin/AccessPermissionEditor.tsx b/apps/web/src/pages/admin/AccessPermissionEditor.tsx index 0014753..f7112d8 100644 --- a/apps/web/src/pages/admin/AccessPermissionEditor.tsx +++ b/apps/web/src/pages/admin/AccessPermissionEditor.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { ChevronDown, ChevronRight } from 'lucide-react'; +import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; import type { GatewayAccessEffect, GatewayAPIKeyAccessRuleDiagnostic, @@ -46,6 +46,7 @@ export function AccessPermissionEditor(props: { const [allowExpanded, setAllowExpanded] = useState>(() => new Set(props.platforms.map((item) => item.id))); const [denyExpanded, setDenyExpanded] = useState>(() => new Set(props.platforms.map((item) => item.id))); const [localError, setLocalError] = useState(''); + const [cleaningIneffectiveRules, setCleaningIneffectiveRules] = useState(false); const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]); const subjectRules = useMemo( @@ -105,6 +106,21 @@ export function AccessPermissionEditor(props: { ); } + async function clearIneffectiveRules() { + if (!props.subjectId || ineffectiveRules.length === 0) return; + setLocalError(''); + setCleaningIneffectiveRules(true); + try { + for (const batch of ineffectiveRuleCleanupBatches(props.subjectType, props.subjectId, ineffectiveRules)) { + await props.onBatchAccessRules(batch); + } + } catch (err) { + setLocalError(err instanceof Error ? err.message : '失效规则一键清理失败'); + } finally { + setCleaningIneffectiveRules(false); + } + } + async function clearEffect(effect: Effect) { const keys = subjectRules .filter((rule) => rule.effect === effect) @@ -143,6 +159,7 @@ export function AccessPermissionEditor(props: { const allowSummary = countEffectRules(subjectRules, 'allow'); const denySummary = countEffectRules(subjectRules, 'deny'); + const allowConfigured = allowSummary.platforms + allowSummary.models > 0; if (!props.subjectId) { return ( @@ -160,23 +177,38 @@ export function AccessPermissionEditor(props: {
{ineffectiveRules.length} 条规则当前不生效 规则已保留,不会扩大权限或阻断其他 API Key。 +
{ineffectiveRules.map((diagnostic) => (
- {diagnostic.effect === 'allow' ? '专属' : '排除'} + {diagnostic.effect === 'allow' ? '允许' : '拒绝'} {diagnostic.resourceName || diagnostic.resourceId} {accessRuleDiagnosticReason(diagnostic.reason)} -
))} )} +
+ {allowConfigured ? '已配置白名单' : '未配置白名单'} + {allowConfigured ? '仅允许所选资源;拒绝规则始终优先。' : '继承上级可用范围;拒绝规则仍会从中排除资源。'} +
void clearEffect('allow')} onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))} @@ -198,6 +230,7 @@ export function AccessPermissionEditor(props: { onTogglePermission={setPermission} /> , + subjectId: string, + diagnostics: GatewayAPIKeyAccessRuleDiagnostic[], +): GatewayAccessRuleBatchRequest[] { + const batches: GatewayAccessRuleBatchRequest[] = []; + for (const effect of ['allow', 'deny'] as Effect[]) { + const keys = diagnostics + .filter((diagnostic) => !diagnostic.effective && diagnostic.effect === effect) + .map((diagnostic) => makeResourceKey(diagnostic.resourceType as ResourceType, diagnostic.resourceId)); + const deleteResources = dedupeResourceKeys(keys).map(resourceRequestFromKey); + if (deleteResources.length === 0) continue; + batches.push({ subjectType, subjectId, effect, upsertResources: [], deleteResources }); + } + return batches; +} + function PermissionTreePanel(props: { + clearLabel: string; effect: Effect; emptyText: string; expanded: Set; @@ -259,7 +310,7 @@ function PermissionTreePanel(props: { - +
{props.tree.length ? props.tree.map((platform) => ( diff --git a/docs/runbooks/access-rule-whitelist-migration.md b/docs/runbooks/access-rule-whitelist-migration.md new file mode 100644 index 0000000..8d05dcc --- /dev/null +++ b/docs/runbooks/access-rule-whitelist-migration.md @@ -0,0 +1,64 @@ +# 访问规则白名单语义迁移 + +迁移 `0102_access_rule_allow_whitelist_semantics` 将旧版跨主体“专属占用” +规则切换为分层白名单。迁移会归档并删除已有的全部 `allow`,保留 `deny` +不变;发布后新建的 `allow` 将按白名单解释。 + +## 发布前 + +1. 暂停管理端和用户工作台的权限配置写入,并停止会创建访问规则的 + acceptance、smoke 或运维脚本。仅依赖数据库表锁不足以覆盖迁移前快照到 + 应用切换之间的时间窗口。 +2. 使用只读数据库账号导出脱敏基线: + + ```bash + AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='<只读数据库连接>' \ + scripts/acceptance/export-access-rule-audit.sh export \ + --output dist/access-rule-audit/before.json + ``` + +3. 保存命令输出中的总规则数、`allow`/`deny` 数量和 SHA-256。快照仅包含按 + 主体类型、效果、资源类型、状态聚合的计数和摘要,不包含主体 ID、资源 ID、 + API Key Secret 或规则元数据。 +4. 确认权限写入仍处于暂停状态,再开始应用发布和数据库迁移。 + +## 迁移后校验 + +继续使用只读数据库账号执行: + +```bash +AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='<只读数据库连接>' \ + scripts/acceptance/export-access-rule-audit.sh verify \ + --before dist/access-rule-audit/before.json \ + --output dist/access-rule-audit/after.json +``` + +只有命令返回 `access_rule_audit_verify=PASS` 才能恢复权限配置写入。该校验同时 +证明: + +- 迁移前 `allow` 数量和摘要与归档表一致; +- 当前表中的旧 `allow` 数量为零; +- `deny` 数量和内容摘要与迁移前一致。 + +随后按生产验收清单创建隔离用户组和 API Key,验证父级继承、分层白名单交集、 +同层并集、`deny` 优先及兄弟主体隔离。验收结束后删除隔离身份、Key 和规则, +并确认没有遗留任务、队列项或并发租约。 + +## 回滚限制 + +应用回滚不会恢复旧版“专属占用”规则。新版本一旦写入白名单 `allow`,禁止直接 +切回旧应用,否则旧代码会把新白名单解释成跨主体排他规则。 + +必须回滚到旧应用时: + +1. 再次暂停所有访问规则写入和 acceptance/smoke; +2. 导出当前脱敏快照,并单独备份数据库; +3. 明确识别并清理迁移完成后创建的 `allow`,不得删除 `deny`,也不得直接恢复 + 归档中的旧 `allow`; +4. 校验当前 `allow=0` 且 `deny` 摘要未变化后,才允许切换旧应用; +5. 恢复旧规则必须作为独立、受审的数据恢复操作处理,不能包含在应用自动回滚 + 中。 + +迁移本身会在事务中锁定 `gateway_access_rules` 的写入,校验归档数量和摘要后才 +删除旧 `allow`。若检测到归档不一致,事务会失败;若迁移完成后出现新白名单, +直接重复执行迁移也会失败,不会误删新规则。 diff --git a/scripts/acceptance/export-access-rule-audit.sh b/scripts/acceptance/export-access-rule-audit.sh new file mode 100755 index 0000000..e1c46cb --- /dev/null +++ b/scripts/acceptance/export-access-rule-audit.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='postgresql://...' \ + scripts/acceptance/export-access-rule-audit.sh export --output + + AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL='postgresql://...' \ + scripts/acceptance/export-access-rule-audit.sh verify \ + --before --output + +Use a SELECT-only database role. The output contains grouped counts and +SHA-256 digests only; API Key secrets and subject/resource identifiers are not +exported. +EOF +} + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repository_root=$(cd "$script_dir/../.." && pwd) + +[[ $# -ge 1 ]] || { + usage >&2 + exit 64 +} +[[ -n ${AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL:-} ]] || { + echo 'AI_GATEWAY_ACCESS_RULE_AUDIT_DATABASE_URL is required' >&2 + exit 64 +} + +case $1 in + export) + [[ ${2:-} == --output && $# -eq 3 ]] || { + usage >&2 + exit 64 + } + ;; + verify) + [[ ${2:-} == --before && ${4:-} == --output && $# -eq 5 ]] || { + usage >&2 + exit 64 + } + ;; + *) + usage >&2 + exit 64 + ;; +esac + +umask 077 +( + cd "$repository_root/apps/api" + go run ./cmd/access-rule-audit "$@" +) diff --git a/scripts/migration-safety-reviewed.json b/scripts/migration-safety-reviewed.json index 213e9e9..2b9bc89 100644 --- a/scripts/migration-safety-reviewed.json +++ b/scripts/migration-safety-reviewed.json @@ -37,6 +37,14 @@ "non-null column addition" ], "reason": "The adaptive Worker columns use constant defaults on gateway_worker_instances, so PostgreSQL 18 can add them without rewriting the table. Production preflight on 2026-08-03 found 96 rows and a 180224-byte relation; the explicit backfill and constraint validation are therefore bounded. The only change to the 31899648-byte gateway_concurrency_leases relation is a nullable numeric column and a NOT VALID check followed by validation." + }, + "apps/api/migrations/0102_access_rule_allow_whitelist_semantics.sql": { + "sha256": "7f65855237b7e5ae38b6d80228bf446e5c28eb22956272ced15c9f492b9aad40", + "allowedViolations": [ + "procedural SQL body", + "DELETE FROM operation" + ], + "reason": "This one-time semantic migration takes a write-blocking/read-compatible lock, records count and SHA-256 manifests, archives every legacy allow row, verifies the archive, and only then deletes legacy allows. Deny rows are checksummed before cleanup and verified unchanged afterward. The procedural blocks make any count or checksum mismatch abort the transaction; reapplying after new whitelist writes also fails before deleting them." } } }