From 945fb444375092ae244b9f9245471e24c9d022ca Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 00:33:28 +0800 Subject: [PATCH 1/3] feat(storage): allow replacing initial policy topology --- CHANGELOG.md | 4 + developer-docs/en/api/admin.md | 4 +- developer-docs/zh-CN/api/admin.md | 4 +- .../content/docs/admin/storage-policies.md | 4 +- .../content/docs/en/admin/storage-policies.md | 4 +- .../admin/PolicyGroupsTable.test.tsx | 8 +- .../components/admin/PolicyGroupsTable.tsx | 7 +- .../PoliciesTable.test.tsx | 4 +- .../admin-policies-page/PoliciesTable.tsx | 13 +- .../policyPresentation.test.ts | 6 +- .../admin-policies-page/policyPresentation.ts | 2 - .../src/i18n/locales/en/admin/policies.json | 7 +- .../i18n/locales/en/admin/policy-groups.json | 3 +- .../src/i18n/locales/zh/admin/policies.json | 7 +- .../i18n/locales/zh/admin/policy-groups.json | 3 +- .../admin/AdminPolicyGroupsPage.test.tsx | 13 +- .../useStoragePolicyListController.ts | 3 - src/db/repository/policy_group_repo.rs | 5 +- .../repository/system_initialization_repo.rs | 29 ++- src/db/repository/team_repo.rs | 3 +- src/services/storage_policy/policy/groups.rs | 25 +- .../storage_policy/policy/policies.rs | 49 ++-- src/services/storage_policy/policy/shared.rs | 9 +- tests/storage/policies.rs | 228 ++++++++++++++++-- 24 files changed, 307 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1ca5b28..5100903d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **存储策略与策略组生命周期** — 首次 setup 创建的存储策略和默认策略组不再按固定 ID 作为永久系统对象;解除 blob、上传 session、策略组项以及用户/团队绑定等引用后可删除首条或最后一条默认策略,删除最后一个默认策略组会使系统回到 `needs_storage`,重新配置默认存储拓扑后恢复 `ready`,不会静默清空业务绑定。默认切换、删除与重新 setup 使用稳定数据库锁协调多 Primary,现有数据保护保持不变。 + ## [v0.5.0] - 2026-08-20 ### Changed diff --git a/developer-docs/en/api/admin.md b/developer-docs/en/api/admin.md index 7c482f034..ccd5536eb 100644 --- a/developer-docs/en/api/admin.md +++ b/developer-docs/en/api/admin.md @@ -113,7 +113,7 @@ Current notes: - `POST /admin/policies/{id}/promote-s3-driver` currently supports promoting a generic `s3` policy to `tencent_cos`. The body must include the target driver and current endpoint / bucket, for example `{ "target_driver_type": "tencent_cos", "endpoint": "https://bucket-1250000000.cos.ap-guangzhou.myqcloud.com", "bucket": "bucket-1250000000" }`. Promotion is rejected unless the bucket stays unchanged, there are no active upload sessions for the policy, and the target driver validates the endpoint / bucket combination. - `GET /admin/policies` supports `limit`, `offset`, `sort_by`, `sort_order` - `GET /admin/policies/{id}/capacity` returns `StoragePolicyCapacityInfo`; local returns filesystem capacity, S3-compatible and Azure Blob are explicitly unsupported, OneDrive reads Microsoft Graph drive quota, and remote forwards follower capacity status -- `DELETE /admin/policies/{id}?force=true` only cleans upload sessions that still reference the policy. Existing blobs or policy-group references still block deletion. If temp objects or multipart uploads need delayed cleanup, a `storage_policy_temp_cleanup` task is created. +- `DELETE /admin/policies/{id}?force=true` only cleans upload sessions that still reference the policy. Existing blobs or policy-group references still block deletion. Deleting the last default policy returns the system to `needs_storage`; if temp objects or multipart uploads need delayed cleanup, a `storage_policy_temp_cleanup` task is created. ### Storage connection tests @@ -500,7 +500,7 @@ The admin folder-policy request is `{ "policy_id": 12 }`; `{ "policy_id": null } | `DELETE` | `/admin/policy-groups/{id}` | Delete policy group | | `POST` | `/admin/policy-groups/{id}/migrate-assignments` | Migrate user and team policy group bindings by updating `policy_group_id` | -Policy groups define storage policy selection for users and teams. They are rejected from deletion while still referenced. Migration responses report `affected_users`, `affected_teams`, and `migrated_assignments`. +Policy groups define storage policy selection for users and teams. A group configuration still requires at least one policy item. Deleting the last default group clears its user/team bindings and returns the system to `needs_storage`; other referenced groups remain protected. Migration responses report `affected_users`, `affected_teams`, and `migrated_assignments`. ## Users diff --git a/developer-docs/zh-CN/api/admin.md b/developer-docs/zh-CN/api/admin.md index d34330478..fdecdb8fd 100644 --- a/developer-docs/zh-CN/api/admin.md +++ b/developer-docs/zh-CN/api/admin.md @@ -132,7 +132,7 @@ - S3-compatible 和 Azure Blob 驱动明确返回 `StorageErrorKind::Unsupported`,服务层转换成 `unsupported` 状态,不伪造 bucket / account 容量 - OneDrive 驱动通过 Microsoft Graph drive quota 返回容量信息 - Remote 驱动通过 follower 内部协议 `/internal/storage/capacity` 转发当前远端存储目标的容量能力 -- `DELETE /admin/policies/{id}` 支持 `?force=true`;这只会强制清理仍引用该策略的上传 session,仍有 blob 或策略组项引用时照样拒绝删除。若清理后还有临时对象或 multipart upload 需要延后处理,会创建 `storage_policy_temp_cleanup` 后台任务 +- `DELETE /admin/policies/{id}` 支持 `?force=true`;这只会强制清理仍引用该策略的上传 session,仍有 blob 或策略组项引用时照样拒绝删除。删除最后一个默认策略后系统会回到 `needs_storage`;若清理后还有临时对象或 multipart upload 需要延后处理,会创建 `storage_policy_temp_cleanup` 后台任务 ### 存储连接测试 @@ -538,7 +538,7 @@ POST /api/v1/admin/policies/action 当前实现注意点: -- 策略组至少要包含一个策略项 +- 策略组配置仍需至少包含一个策略项;删除最后一个默认策略组会使系统回到 `needs_storage` - 同一组里 `policy_id` 和 `priority` 都不能重复 - `is_default = true` 的组必须保持启用 - 已被用户或团队绑定的策略组不能直接删掉;被绑定时也不能随便禁用 diff --git a/docs/src/content/docs/admin/storage-policies.md b/docs/src/content/docs/admin/storage-policies.md index de4688ee5..c05ef5e38 100644 --- a/docs/src/content/docs/admin/storage-policies.md +++ b/docs/src/content/docs/admin/storage-policies.md @@ -194,8 +194,8 @@ AsterDrive 会缓存缩略图和媒体信息等派生结果,避免每次查看 ## 日常维护 -- 至少保留一条可用的默认存储策略 -- 至少保留一个启用中的默认策略组 +- 删除最后一个默认策略或策略组后,系统会回到 `needs_storage`;重新创建并配置默认对象后才能恢复上传 +- 已有文件仍按原策略读取;需要搬迁数据时使用存储迁移流程,不要直接改动已有策略的落点 - 保存前先做一次连接测试 - 给不同用户/团队分配不同存储路线时,到 `管理 -> 用户` 或 `管理 -> 团队` 里绑策略组 - 接入外部后端时优先看 [存储后端](/admin/storage-backends/) 里的具体教程 diff --git a/docs/src/content/docs/en/admin/storage-policies.md b/docs/src/content/docs/en/admin/storage-policies.md index 3a4e1e455..14f53849b 100644 --- a/docs/src/content/docs/en/admin/storage-policies.md +++ b/docs/src/content/docs/en/admin/storage-policies.md @@ -194,8 +194,8 @@ Migration moves file objects and reference relationships known to AsterDrive; it ## Routine Maintenance -- Keep at least one usable default storage policy -- Keep at least one enabled default policy group +- Deleting the last default policy or policy group returns the system to `needs_storage`; create and configure a new default before uploads resume +- Existing files remain bound to their original policy; use the storage migration workflow when moving data instead of changing an existing policy's location - Run a connection test before saving - To assign different storage routes to different users/teams, bind policy groups under `Admin -> Users` or `Admin -> Teams` - When connecting an external backend, start with the matching tutorial under [Storage Backends](/en/admin/storage-backends/) diff --git a/frontend-panel/src/components/admin/PolicyGroupsTable.test.tsx b/frontend-panel/src/components/admin/PolicyGroupsTable.test.tsx index 869aae801..3ec840702 100644 --- a/frontend-panel/src/components/admin/PolicyGroupsTable.test.tsx +++ b/frontend-panel/src/components/admin/PolicyGroupsTable.test.tsx @@ -351,7 +351,7 @@ describe("PolicyGroupsTable", () => { expect(mockState.onOpenEdit).toHaveBeenNthCalledWith(2, defaultGroup); expect(mockState.onOpenMigration).toHaveBeenCalledWith(primaryGroup); expect(mockState.onRequestDelete).toHaveBeenCalledWith(1); - expect(deleteButtons[1]).toBeDisabled(); + expect(deleteButtons[1]).toBeEnabled(); }); it("keeps disabled action tooltips on fixed-size triggers", () => { @@ -378,7 +378,7 @@ describe("PolicyGroupsTable", () => { }); expect(migrationButton).toBeDisabled(); - expect(deleteButton).toBeDisabled(); + expect(deleteButton).toBeEnabled(); expect(migrationButton.parentElement).toHaveClass( "inline-flex", "size-8", @@ -393,8 +393,8 @@ describe("PolicyGroupsTable", () => { screen.getByText("policy_group_migration_unavailable"), ).toBeInTheDocument(); expect( - screen.getByText("policy_group_delete_default_blocked"), - ).toBeInTheDocument(); + screen.queryByText("policy_group_delete_default_blocked"), + ).toBeNull(); }); it("updates pagination state through the footer controls", () => { diff --git a/frontend-panel/src/components/admin/PolicyGroupsTable.tsx b/frontend-panel/src/components/admin/PolicyGroupsTable.tsx index 14c926d46..836a90fb5 100644 --- a/frontend-panel/src/components/admin/PolicyGroupsTable.tsx +++ b/frontend-panel/src/components/admin/PolicyGroupsTable.tsx @@ -275,7 +275,7 @@ function PolicyGroupActions({ onClick={() => onRequestDelete(group.id)} aria-label={deleteLabel} title={deleteLabel} - disabled={group.is_default || isDeleting} + disabled={isDeleting} > - {group.is_default ? ( - - {t("policy_group_delete_default_blocked")} - - ) : null} diff --git a/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.test.tsx b/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.test.tsx index 39ef540a1..637d6dc84 100644 --- a/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.test.tsx +++ b/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.test.tsx @@ -217,7 +217,7 @@ describe("PoliciesTable", () => { expect(screen.getByText(/mode: mode/)).toBeVisible(); }); - it("supports row keyboard/edit/delete behavior and protected/deleting states", () => { + it("supports row keyboard/edit/delete behavior and deleting states", () => { const { props } = renderTable({ deletingPolicyId: 2 }); const rows = screen.getAllByRole("row"); fireEvent.click(rows[1]); @@ -229,7 +229,7 @@ describe("PoliciesTable", () => { const deleteButtons = buttons.filter((button) => button.getAttribute("aria-label"), ); - expect(deleteButtons[0]).toBeDisabled(); + expect(deleteButtons[0]).toBeEnabled(); expect(deleteButtons[1]).toBeDisabled(); }); diff --git a/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.tsx b/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.tsx index 0966e17be..f092d43d1 100644 --- a/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.tsx +++ b/frontend-panel/src/components/admin/admin-policies-page/PoliciesTable.tsx @@ -25,10 +25,7 @@ import type { AdminPolicySortBy } from "@/types/adminSort"; import type { StorageConnectorDescriptor, StoragePolicy } from "@/types/api"; import { policyConnectorSelection } from "../storage-policy-dialog/connectionNormalization"; import type { ConnectorFormValue } from "../storage-policy-dialog/formTypes"; -import { - getStorageConnectorBadgePresentation, - PROTECTED_POLICY_ID, -} from "./policyPresentation"; +import { getStorageConnectorBadgePresentation } from "./policyPresentation"; interface PoliciesTableProps { deletingPolicyId: number | null; @@ -216,12 +213,8 @@ export function PoliciesTable({ className={`${ADMIN_ICON_BUTTON_CLASS} text-destructive`} onClick={() => onDeletePolicy(policy.id)} aria-label={deleteLabel} - title={ - policy.id === PROTECTED_POLICY_ID - ? t("initial_policy_delete_blocked") - : deleteLabel - } - disabled={policy.id === PROTECTED_POLICY_ID || isDeleting} + title={deleteLabel} + disabled={isDeleting} > { it("renders a connector-owned RGB color without connector id branches", () => { - expect(PROTECTED_POLICY_ID).toBe(1); const presentation = getStorageConnectorBadgePresentation({ red: 16, green: 185, diff --git a/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts b/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts index 36b7653eb..c5950247f 100644 --- a/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts +++ b/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts @@ -1,8 +1,6 @@ import type { CSSProperties } from "react"; import type { StorageConnectorBadgeRgb } from "@/types/api"; -export const PROTECTED_POLICY_ID = 1; - const DEFAULT_BADGE_RGB: StorageConnectorBadgeRgb = { red: 113, green: 113, diff --git a/frontend-panel/src/i18n/locales/en/admin/policies.json b/frontend-panel/src/i18n/locales/en/admin/policies.json index 13c7738d5..bf88846c3 100644 --- a/frontend-panel/src/i18n/locales/en/admin/policies.json +++ b/frontend-panel/src/i18n/locales/en/admin/policies.json @@ -78,7 +78,7 @@ "unset_default": "Unset default", "undo_changes": "Discard Changes", "save_changes": "Save Changes", - "delete_policy_desc": "This will not delete stored files, but new uploads using this policy will fail.", + "delete_policy_desc": "Stored files remain untouched. If this is the last default policy, storage setup returns to incomplete and new uploads pause until a new default is configured.", "force_delete_policy": "Force Delete Policy", "force_delete_policy_desc": "This policy is still referenced by unfinished uploads. Continuing will clean up those upload sessions and temporary objects; deletion will still be rejected if stored file blobs use this policy.", "force_delete_policy_confirm": "Clean Up and Delete", @@ -128,11 +128,10 @@ "policy_assigned": "Policy assigned", "assignment_removed": "Assignment removed", "storage_policy_assignments_desc": "Choose the default policy group for this user's uploads. Only one group can be bound at a time.", - "initial_policy_delete_blocked": "The built-in policy with ID 1 cannot be deleted.", "assignment_updated": "Assignment updated", "default_selected": "Default", - "default_policy_required": "A default policy must always exist while assignments remain.", - "default_policy_remove_blocked": "Set another policy as default before removing the current default policy.", + "default_policy_required": "Create and assign a default policy before uploads resume.", + "default_policy_remove_blocked": "Deleting the current default policy pauses uploads until storage setup is completed again.", "access_key": "Access Key", "base_url": "Base URL", "namespace": "Namespace", diff --git a/frontend-panel/src/i18n/locales/en/admin/policy-groups.json b/frontend-panel/src/i18n/locales/en/admin/policy-groups.json index f522ec1a7..ddfe15f26 100644 --- a/frontend-panel/src/i18n/locales/en/admin/policy-groups.json +++ b/frontend-panel/src/i18n/locales/en/admin/policy-groups.json @@ -56,7 +56,7 @@ "create_policy_group": "Create Policy Group", "edit_policy_group": "Edit Policy Group", "delete_policy_group": "Delete Policy Group", - "delete_policy_group_desc": "This will remove the group and its routing rules. Assigned users and teams must be moved first.", + "delete_policy_group_desc": "This will remove the group and its routing rules. Migrate assigned users and teams first, including for the last default group; deleting it reopens storage setup without clearing bindings.", "policy_group_dialog_desc": "Define an ordered rule set that maps uploads to storage policies by file size.", "policy_group_name_required": "Policy group name is required.", "policy_group_default_requires_enabled": "A default policy group must remain enabled.", @@ -67,6 +67,5 @@ "policy_group_rule_policy_duplicate": "A storage policy can only appear once in the same group.", "policy_group_rule_size_invalid": "File size limits must be valid non-negative numbers.", "policy_group_rule_range_invalid": "The max file size must be greater than the min file size.", - "policy_group_delete_default_blocked": "Set another group as default before deleting the current default group.", "policy_group_no_policies_available": "Create at least one storage policy before creating a policy group." } diff --git a/frontend-panel/src/i18n/locales/zh/admin/policies.json b/frontend-panel/src/i18n/locales/zh/admin/policies.json index 01bce575c..7670b01b6 100644 --- a/frontend-panel/src/i18n/locales/zh/admin/policies.json +++ b/frontend-panel/src/i18n/locales/zh/admin/policies.json @@ -78,7 +78,7 @@ "unset_default": "取消默认", "undo_changes": "撤销更改", "save_changes": "保存更改", - "delete_policy_desc": "不会删除已存储的文件,但使用此策略的新上传将失败。", + "delete_policy_desc": "已存储文件不会受影响。如果这是最后一个默认策略,存储 setup 会回到未完成状态,重新配置默认策略前新上传会暂停。", "force_delete_policy": "强制删除策略", "force_delete_policy_desc": "该策略仍有关联的未完成上传。继续后会清理这些上传会话和临时对象;如果已有文件 blob 仍在使用该策略,删除仍会被拒绝。", "force_delete_policy_confirm": "清理并删除", @@ -128,11 +128,10 @@ "policy_assigned": "已分配存储策略", "assignment_removed": "已移除策略分配", "storage_policy_assignments_desc": "为用户指定上传默认使用的策略组;当前仅支持绑定一个策略组。", - "initial_policy_delete_blocked": "ID 为 1 的内置策略不可删除。", "assignment_updated": "策略分配已更新", "default_selected": "默认策略", - "default_policy_required": "存在策略分配时必须始终保留一个默认策略。", - "default_policy_remove_blocked": "请先将其他策略设为默认,再删除当前默认策略。", + "default_policy_required": "请先创建并分配默认策略,上传流程才会恢复。", + "default_policy_remove_blocked": "删除当前默认策略后,上传会暂停,直到重新完成存储 setup。", "access_key": "访问密钥", "base_url": "基础地址", "namespace": "命名空间", diff --git a/frontend-panel/src/i18n/locales/zh/admin/policy-groups.json b/frontend-panel/src/i18n/locales/zh/admin/policy-groups.json index 89c8d751e..069c20de9 100644 --- a/frontend-panel/src/i18n/locales/zh/admin/policy-groups.json +++ b/frontend-panel/src/i18n/locales/zh/admin/policy-groups.json @@ -56,7 +56,7 @@ "create_policy_group": "创建策略组", "edit_policy_group": "编辑策略组", "delete_policy_group": "删除策略组", - "delete_policy_group_desc": "这会删除整个策略组及其路由规则。若仍有用户绑定,需要先迁移绑定关系。", + "delete_policy_group_desc": "这会删除整个策略组及其路由规则。包括最后一个默认策略组在内,都需要先迁移用户和团队绑定;删除默认组只会重新打开存储 setup,不会清空绑定。", "policy_group_dialog_desc": "定义一组有序规则,按文件大小把上传分配到不同存储策略。", "policy_group_name_required": "必须填写策略组名称。", "policy_group_default_requires_enabled": "默认策略组必须保持启用。", @@ -67,6 +67,5 @@ "policy_group_rule_policy_duplicate": "同一个存储策略在一个策略组里只能出现一次。", "policy_group_rule_size_invalid": "文件大小限制必须是合法的非负数字。", "policy_group_rule_range_invalid": "最大文件大小必须大于最小文件大小。", - "policy_group_delete_default_blocked": "请先把其他策略组设为默认,再删除当前默认策略组。", "policy_group_no_policies_available": "请先创建至少一个存储策略,再创建策略组。" } diff --git a/frontend-panel/src/pages/admin/AdminPolicyGroupsPage.test.tsx b/frontend-panel/src/pages/admin/AdminPolicyGroupsPage.test.tsx index 1329eebb2..915bfe213 100644 --- a/frontend-panel/src/pages/admin/AdminPolicyGroupsPage.test.tsx +++ b/frontend-panel/src/pages/admin/AdminPolicyGroupsPage.test.tsx @@ -620,8 +620,8 @@ describe("AdminPolicyGroupsPage", () => { screen.getByText("formatted:2026-03-28T00:00:00Z"), ).toBeInTheDocument(); expect( - screen.getByText("policy_group_delete_default_blocked"), - ).toBeInTheDocument(); + screen.queryByText("policy_group_delete_default_blocked"), + ).toBeNull(); fireEvent.click(screen.getByText("Default Group")); @@ -710,7 +710,7 @@ describe("AdminPolicyGroupsPage", () => { ).toBeInTheDocument(); }); - it("prevents deleting the default group from the table", async () => { + it("allows deleting the default group from the table", async () => { mockState.groupItems = [ createGroup({ name: "System Default", @@ -723,11 +723,14 @@ describe("AdminPolicyGroupsPage", () => { const deleteButton = await screen.findByRole("button", { name: "delete_policy_group", }); - expect(deleteButton).toBeDisabled(); + expect(deleteButton).toBeEnabled(); fireEvent.click(deleteButton); - expect(mockState.deleteGroup).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "core:delete" })); + await waitFor(() => { + expect(mockState.deleteGroup).toHaveBeenCalledWith(1); + }); expect( screen.queryByText('delete_policy_group "System Default"?'), ).not.toBeInTheDocument(); diff --git a/frontend-panel/src/pages/admin/admin-policies-page/useStoragePolicyListController.ts b/frontend-panel/src/pages/admin/admin-policies-page/useStoragePolicyListController.ts index 87df5307b..fb1da8de6 100644 --- a/frontend-panel/src/pages/admin/admin-policies-page/useStoragePolicyListController.ts +++ b/frontend-panel/src/pages/admin/admin-policies-page/useStoragePolicyListController.ts @@ -1,7 +1,6 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; -import { PROTECTED_POLICY_ID } from "@/components/admin/admin-policies-page/policyPresentation"; import { handleApiError } from "@/hooks/useApiError"; import { useConfirmDialog } from "@/hooks/useConfirmDialog"; import { @@ -130,7 +129,6 @@ export function useStoragePolicyListController() { }; const handleDelete = async (id: number, options?: DeletePolicyQuery) => { - if (id === PROTECTED_POLICY_ID) return; await runWithDeletingPolicy(id, async () => { try { if (options) { @@ -170,7 +168,6 @@ export function useStoragePolicyListController() { await handleDelete(id, { force: true }); }); const requestDeleteConfirm = (id: number) => { - if (id === PROTECTED_POLICY_ID) return; requestConfirm(id); }; diff --git a/src/db/repository/policy_group_repo.rs b/src/db/repository/policy_group_repo.rs index 34762d442..a4719a551 100644 --- a/src/db/repository/policy_group_repo.rs +++ b/src/db/repository/policy_group_repo.rs @@ -182,7 +182,10 @@ pub async fn delete_group_items_by_group(db: &C, group_id: i Ok(result.rows_affected) } -pub async fn count_group_items_by_policy(db: &DatabaseConnection, policy_id: i64) -> Result { +pub async fn count_group_items_by_policy( + db: &C, + policy_id: i64, +) -> Result { StoragePolicyGroupItem::find() .filter(storage_policy_group_item::Column::PolicyId.eq(policy_id)) .count(db) diff --git a/src/db/repository/system_initialization_repo.rs b/src/db/repository/system_initialization_repo.rs index f0a9ad669..3441016bf 100644 --- a/src/db/repository/system_initialization_repo.rs +++ b/src/db/repository/system_initialization_repo.rs @@ -1,16 +1,17 @@ //! Database coordination for the one-time system initialization flow. use crate::config::definitions::AUTH_ALLOW_USER_REGISTRATION_KEY; +use crate::db::repository::config_repo; use crate::errors::{AsterError, Result}; use aster_forge_db::system_config::{self, Entity as SystemConfig}; use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, sea_query::Expr}; -/// Serializes setup attempts using an existing, non-deletable system configuration row. -/// -/// The no-op update acquires a row lock on PostgreSQL/MySQL and a write lock on SQLite until the -/// surrounding transaction completes. After this returns, the caller can safely re-check the user -/// table and create the initial administrator in the same transaction. -pub async fn acquire_setup_lock(db: &C) -> Result<()> { +async fn acquire_system_config_lock(db: &C, purpose: &str) -> Result<()> { + config_repo::ensure_system_value_if_missing(db, AUTH_ALLOW_USER_REGISTRATION_KEY, "true") + .await?; + + // The no-op update acquires a row lock on PostgreSQL/MySQL and a write lock on SQLite until + // the surrounding transaction completes. SystemConfig::update_many() .col_expr( system_config::Column::Value, @@ -31,8 +32,22 @@ pub async fn acquire_setup_lock(db: &C) -> Result<()> { .is_some(); if !guard_exists { return Err(AsterError::internal_error(format!( - "setup lock config '{AUTH_ALLOW_USER_REGISTRATION_KEY}' is missing" + "{purpose} lock config '{AUTH_ALLOW_USER_REGISTRATION_KEY}' is missing" ))); } Ok(()) } + +/// Serializes setup attempts using an existing, non-deletable system configuration row. +/// +/// The no-op update acquires a row lock on PostgreSQL/MySQL and a write lock on SQLite until the +/// surrounding transaction completes. After this returns, the caller can safely re-check the user +/// table and create the initial administrator in the same transaction. +pub async fn acquire_setup_lock(db: &C) -> Result<()> { + acquire_system_config_lock(db, "setup").await +} + +/// Serializes storage topology mutations using the same non-deletable system configuration row. +pub async fn acquire_storage_topology_lock(db: &C) -> Result<()> { + acquire_system_config_lock(db, "storage topology").await +} diff --git a/src/db/repository/team_repo.rs b/src/db/repository/team_repo.rs index b1b986031..dcc7b7168 100644 --- a/src/db/repository/team_repo.rs +++ b/src/db/repository/team_repo.rs @@ -262,12 +262,11 @@ pub async fn delete(db: &C, id: i64) -> Result<()> { Ok(()) } -pub async fn count_active_by_policy_group( +pub async fn count_by_policy_group( db: &C, policy_group_id: i64, ) -> Result { Team::find() - .filter(team::Column::ArchivedAt.is_null()) .filter(team::Column::PolicyGroupId.eq(policy_group_id)) .count(db) .await diff --git a/src/services/storage_policy/policy/groups.rs b/src/services/storage_policy/policy/groups.rs index 4baff7514..0f185f02f 100644 --- a/src/services/storage_policy/policy/groups.rs +++ b/src/services/storage_policy/policy/groups.rs @@ -224,7 +224,7 @@ pub async fn update_group( if existing.is_enabled { let user_assignment_count = policy_group_repo::count_user_group_assignments(&txn, id).await?; - let team_assignment_count = team_repo::count_active_by_policy_group(&txn, id).await?; + let team_assignment_count = team_repo::count_by_policy_group(&txn, id).await?; if let Some(message) = format_group_assignment_blocker( "disable", user_assignment_count, @@ -301,7 +301,9 @@ pub async fn update_group( } pub async fn delete_group(state: &impl SharedRuntimeState, id: i64) -> Result<()> { - let group = policy_group_repo::find_group_by_id(state.writer_db(), id).await?; + let txn = transaction::begin(state.writer_db()).await?; + lock_default_group_assignment(&txn).await?; + let group = policy_group_repo::find_group_by_id(&txn, id).await?; tracing::debug!( policy_group_id = id, policy_group_name = %group.name, @@ -309,27 +311,16 @@ pub async fn delete_group(state: &impl SharedRuntimeState, id: i64) -> Result<() "deleting storage policy group" ); - if group.is_default { - let all = policy_group_repo::find_all_groups(state.writer_db()).await?; - let default_count = all.iter().filter(|item| item.is_default).count(); - if default_count <= 1 { - return Err(AsterError::validation_error( - "cannot delete the only default storage policy group", - )); - } - } - - let user_assignment_count = - policy_group_repo::count_user_group_assignments(state.writer_db(), id).await?; - let team_assignment_count = - team_repo::count_active_by_policy_group(state.writer_db(), id).await?; + let user_assignment_count = policy_group_repo::count_user_group_assignments(&txn, id).await?; + let team_assignment_count = team_repo::count_by_policy_group(&txn, id).await?; if let Some(message) = format_group_assignment_blocker("delete", user_assignment_count, team_assignment_count) { return Err(AsterError::validation_error(message)); } - policy_group_repo::delete_group(state.writer_db(), id).await?; + policy_group_repo::delete_group(&txn, id).await?; + transaction::commit(txn).await?; state .driver_registry() .reload_policy_snapshot(state.policy_snapshot(), state.writer_db()) diff --git a/src/services/storage_policy/policy/policies.rs b/src/services/storage_policy/policy/policies.rs index e1b428305..7c35b22e0 100644 --- a/src/services/storage_policy/policy/policies.rs +++ b/src/services/storage_policy/policy/policies.rs @@ -21,7 +21,7 @@ use super::models::{ StoragePolicyDiagnostic, UpdateStoragePolicyInput, }; use super::shared::{ - SYSTEM_STORAGE_POLICY_ID, serialize_allowed_types, set_default_policy_and_group, + lock_default_group_assignment, serialize_allowed_types, set_default_policy_and_group, }; use crate::storage::{ ExecuteDraftStorageConnectorActionInput, ExecuteSavedStorageConnectorActionInput, @@ -237,22 +237,6 @@ pub async fn delete(state: &(impl TaskRuntimeState + Sync), id: i64, force: bool "deleting storage policy" ); - if policy.id == SYSTEM_STORAGE_POLICY_ID { - return Err(AsterError::validation_error( - "cannot delete the built-in system storage policy", - )); - } - - if policy.is_default { - let all = policy_repo::find_all(state.writer_db()).await?; - let default_count = all.iter().filter(|p| p.is_default).count(); - if default_count <= 1 { - return Err(AsterError::validation_error( - "cannot delete the only default storage policy", - )); - } - } - let blob_count = crate::db::repository::file_repo::count_blobs_by_policy(state.writer_db(), id).await?; if blob_count > 0 { @@ -301,21 +285,42 @@ pub async fn delete(state: &(impl TaskRuntimeState + Sync), id: i64, force: bool ); } - let blob_count = - crate::db::repository::file_repo::count_blobs_by_policy(state.writer_db(), id).await?; + let txn = transaction::begin(state.writer_db()).await?; + lock_default_group_assignment(&txn).await?; + let policy = policy_repo::find_by_id(&txn, id).await?; + + let blob_count = crate::db::repository::file_repo::count_blobs_by_policy(&txn, id).await?; if blob_count > 0 { return Err(AsterError::validation_error(format!( "cannot delete policy: {blob_count} blob(s) still reference it" ))); } - let cleared = - crate::db::repository::folder_repo::clear_policy_references(state.writer_db(), id).await?; + let group_ref_count = policy_group_repo::count_group_items_by_policy(&txn, id).await?; + if group_ref_count > 0 { + return Err(AsterError::validation_error(format!( + "cannot delete policy: {group_ref_count} policy group item(s) still reference it" + ))); + } + + let upload_session_count = + crate::db::repository::upload_session_repo::count_by_policy(&txn, id).await?; + if upload_session_count > 0 { + return Err(validation_error_with_code( + ApiErrorCode::PolicyUploadSessionsExist, + format!( + "cannot delete policy: {upload_session_count} upload session(s) still reference it" + ), + )); + } + + let cleared = crate::db::repository::folder_repo::clear_policy_references(&txn, id).await?; if cleared > 0 { tracing::info!("cleared policy_id on {cleared} folders before deleting policy #{id}"); } - policy_repo::delete(state.writer_db(), id).await?; + policy_repo::delete(&txn, id).await?; + transaction::commit(txn).await?; // 与 update 一致:先 invalidate driver 再 reload snapshot, // 避免"策略行已删除但 driver 仍在缓存里"的窗口。 diff --git a/src/services/storage_policy/policy/shared.rs b/src/services/storage_policy/policy/shared.rs index 8b0181a1f..601f7c2b7 100644 --- a/src/services/storage_policy/policy/shared.rs +++ b/src/services/storage_policy/policy/shared.rs @@ -3,7 +3,9 @@ use chrono::Utc; use sea_orm::Set; -use crate::db::repository::{policy_group_repo, policy_repo, user_repo}; +use crate::db::repository::{ + policy_group_repo, policy_repo, system_initialization_repo, user_repo, +}; use crate::errors::{AsterError, Result}; use crate::runtime::SharedRuntimeState; use aster_drive_model::entities::{storage_policy_group, storage_policy_group_item}; @@ -16,8 +18,6 @@ use super::models::{ StoragePolicySummaryInfo, }; -pub(super) const SYSTEM_STORAGE_POLICY_ID: i64 = 1; - pub(super) fn serialize_allowed_types( allowed_types: &[String], ) -> Result { @@ -162,8 +162,7 @@ pub(super) async fn replace_group_items( pub(super) async fn lock_default_group_assignment( db: &C, ) -> Result<()> { - policy_repo::lock_by_id(db, SYSTEM_STORAGE_POLICY_ID).await?; - Ok(()) + system_initialization_repo::acquire_storage_topology_lock(db).await } pub(super) async fn ensure_singleton_group_for_policy( diff --git a/tests/storage/policies.rs b/tests/storage/policies.rs index 2d209f878..c65a9b5ac 100644 --- a/tests/storage/policies.rs +++ b/tests/storage/policies.rs @@ -3446,6 +3446,19 @@ async fn test_cannot_disable_assigned_policy_group() { body["msg"], "cannot disable policy group: 1 user assignment(s) still reference it" ); + + let req = test::TestRequest::delete() + .uri(&format!("/api/v1/admin/policy-groups/{group_id}")) + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 400); + let body: Value = test::read_body_json(resp).await; + assert_eq!( + body["msg"], + "cannot delete policy group: 1 user assignment(s) still reference it" + ); } #[actix_web::test] @@ -3558,17 +3571,13 @@ async fn test_cannot_disable_or_delete_policy_group_assigned_to_team() { let body: Value = test::read_body_json(resp).await; let group_id = body["data"]["id"].as_i64().unwrap(); - let req = test::TestRequest::post() - .uri("/api/v1/auth/register") - .peer_addr("127.0.0.1:12345".parse().unwrap()) - .set_json(serde_json::json!({ - "username": "teampolicyadmin", - "email": "teampolicyadmin@example.com", - "password": "password123" - })) - .to_request(); - let resp = test::call_service(&app, req).await; - assert_eq!(resp.status(), 201); + admin_create_user!( + app, + token, + "teampolicyadmin", + "teampolicyadmin@example.com", + "password123" + ); let req = test::TestRequest::post() .uri("/api/v1/admin/teams") @@ -3582,6 +3591,8 @@ async fn test_cannot_disable_or_delete_policy_group_assigned_to_team() { .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), 201); + let body: Value = test::read_body_json(resp).await; + let team_id = body["data"]["id"].as_i64().unwrap(); let req = test::TestRequest::patch() .uri(&format!("/api/v1/admin/policy-groups/{group_id}")) @@ -3609,6 +3620,27 @@ async fn test_cannot_disable_or_delete_policy_group_assigned_to_team() { body["msg"], "cannot delete policy group: 1 team assignment(s) still reference it" ); + + let req = test::TestRequest::delete() + .uri(&format!("/api/v1/admin/teams/{team_id}")) + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let req = test::TestRequest::delete() + .uri(&format!("/api/v1/admin/policy-groups/{group_id}")) + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 400); + let body: Value = test::read_body_json(resp).await; + assert_eq!( + body["msg"], + "cannot delete policy group: 1 team assignment(s) still reference it" + ); } #[actix_web::test] @@ -3880,12 +3912,12 @@ async fn test_cannot_migrate_policy_group_assignments_to_disabled_group() { ); } -// ── 不能删除唯一的默认系统策略 ────────────────────────────── +// ── 首个策略/策略组可删除并可重新完成 setup ────────────────── #[actix_web::test] -async fn test_cannot_delete_only_default_policy() { +async fn test_delete_only_default_policy_group_and_policy_returns_to_storage_setup() { let state = common::setup().await; - let app = create_test_app!(state); + let app = create_test_app!(state.clone()); let (token, _) = register_and_login!(app); // 获取默认策略 ID @@ -3898,23 +3930,166 @@ async fn test_cannot_delete_only_default_policy() { let body: Value = test::read_body_json(resp).await; let policy_id = body["data"]["items"][0]["id"].as_i64().unwrap(); - // 尝试删除唯一默认策略 → 应被拒绝 + let req = test::TestRequest::get() + .uri("/api/v1/admin/policy-groups") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let resp = test::call_service(&app, req).await; + let body: Value = test::read_body_json(resp).await; + let group_id = body["data"]["items"] + .as_array() + .unwrap() + .iter() + .find(|group| group["is_default"].as_bool() == Some(true)) + .and_then(|group| group["id"].as_i64()) + .expect("default policy group should exist"); + + let req = test::TestRequest::delete() + .uri(&format!("/api/v1/admin/policy-groups/{group_id}")) + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 400); + let body: Value = test::read_body_json(resp).await; + assert_eq!( + body["msg"], + "cannot delete policy group: 1 user assignment(s) still reference it" + ); + + let req = test::TestRequest::post() + .uri("/api/v1/admin/policies") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .set_json(serde_json::json!({ + "name": "Replacement Policy", + "connection": local_connection_json(format!( + "/tmp/test-replacement-policy-{}", + uuid::Uuid::new_v4() + )), + "max_file_size": 0, + "is_default": false + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 201); + let body: Value = test::read_body_json(resp).await; + let replacement_policy_id = body["data"]["id"].as_i64().unwrap(); + + let req = test::TestRequest::post() + .uri("/api/v1/admin/policy-groups") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .set_json(serde_json::json!({ + "name": "Replacement Group", + "description": "Keeps setup assignments during policy replacement", + "is_enabled": true, + "is_default": false, + "items": [{ + "policy_id": replacement_policy_id, + "priority": 1, + "min_file_size": 0, + "max_file_size": 0 + }] + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 201); + let body: Value = test::read_body_json(resp).await; + let replacement_group_id = body["data"]["id"].as_i64().unwrap(); + + let req = test::TestRequest::post() + .uri(&format!( + "/api/v1/admin/policy-groups/{group_id}/migrate-assignments" + )) + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .set_json(serde_json::json!({ + "target_group_id": replacement_group_id + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + + let req = test::TestRequest::delete() + .uri(&format!("/api/v1/admin/policy-groups/{group_id}")) + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + assert_eq!( + aster_drive::db::repository::user_repo::find_by_username(state.writer_db(), "testuser",) + .await + .unwrap() + .expect("setup user should exist") + .policy_group_id, + Some(replacement_group_id) + ); + + // 删除组项解除后,首条策略也可以删除。 let req = test::TestRequest::delete() .uri(&format!("/api/v1/admin/policies/{policy_id}")) .insert_header(("Cookie", common::access_cookie_header(&token))) .insert_header(common::csrf_header_for(&token)) .to_request(); let resp: actix_web::dev::ServiceResponse = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + assert!( + aster_drive::db::repository::policy_repo::find_by_id(state.writer_db(), policy_id) + .await + .is_err() + ); assert_eq!( - resp.status(), - 400, - "should reject deleting only default policy, got {}", - resp.status() + aster_drive::services::system_setup::state(state.writer_db()) + .await + .unwrap(), + aster_drive::services::system_setup::SystemSetupState::NeedsStorage ); + let req = test::TestRequest::post() + .uri("/api/v1/auth/check") + .peer_addr("127.0.0.1:12346".parse().unwrap()) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["data"]["setup_state"], "needs_storage"); + + let req = test::TestRequest::post() + .uri("/api/v1/admin/policies") + .insert_header(("Cookie", common::access_cookie_header(&token))) + .insert_header(common::csrf_header_for(&token)) + .set_json(serde_json::json!({ + "name": "Replacement Default", + "connection": local_connection_json(format!( + "/tmp/test-recreated-default-{}", + uuid::Uuid::new_v4() + )), + "max_file_size": 0, + "is_default": true + })) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 201); + assert_eq!( + aster_drive::services::system_setup::state(state.writer_db()) + .await + .unwrap(), + aster_drive::services::system_setup::SystemSetupState::Ready + ); + let req = test::TestRequest::post() + .uri("/api/v1/auth/check") + .peer_addr("127.0.0.1:12347".parse().unwrap()) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), 200); + let body: Value = test::read_body_json(resp).await; + assert_eq!(body["data"]["setup_state"], "ready"); } #[actix_web::test] -async fn test_cannot_delete_builtin_system_policy_even_after_switching_default() { +async fn test_policy_delete_keeps_policy_group_references_protected() { let state = common::setup().await; let app = create_test_app!(state); let (token, _) = register_and_login!(app); @@ -3955,11 +4130,12 @@ async fn test_cannot_delete_builtin_system_policy_even_after_switching_default() .insert_header(common::csrf_header_for(&token)) .to_request(); let resp: actix_web::dev::ServiceResponse = test::call_service(&app, req).await; - assert_eq!( - resp.status(), - 400, - "should reject deleting built-in policy #{built_in_policy_id}, got {}", - resp.status() + assert_eq!(resp.status(), 400); + let body: Value = test::read_body_json(resp).await; + assert!( + body["msg"] + .as_str() + .is_some_and(|message| message.contains("policy group item")) ); let req = test::TestRequest::get() @@ -3975,7 +4151,7 @@ async fn test_cannot_delete_builtin_system_policy_even_after_switching_default() policies .iter() .any(|policy| policy["id"].as_i64() == Some(built_in_policy_id)), - "built-in policy #{built_in_policy_id} should still exist after failed delete" + "policy #{built_in_policy_id} should still exist while a group item references it" ); } From cf5b22bf9c2ffb9494ac6436d52fbe62ddb46de7 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 02:25:54 +0800 Subject: [PATCH 2/3] fix(storage): preserve fail-closed setup locking --- src/db/repository/system_initialization_repo.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/db/repository/system_initialization_repo.rs b/src/db/repository/system_initialization_repo.rs index 3441016bf..69f75d479 100644 --- a/src/db/repository/system_initialization_repo.rs +++ b/src/db/repository/system_initialization_repo.rs @@ -6,10 +6,15 @@ use crate::errors::{AsterError, Result}; use aster_forge_db::system_config::{self, Entity as SystemConfig}; use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, sea_query::Expr}; -async fn acquire_system_config_lock(db: &C, purpose: &str) -> Result<()> { - config_repo::ensure_system_value_if_missing(db, AUTH_ALLOW_USER_REGISTRATION_KEY, "true") - .await?; - +async fn acquire_system_config_lock( + db: &C, + purpose: &str, + ensure_missing: bool, +) -> Result<()> { + if ensure_missing { + config_repo::ensure_system_value_if_missing(db, AUTH_ALLOW_USER_REGISTRATION_KEY, "true") + .await?; + } // The no-op update acquires a row lock on PostgreSQL/MySQL and a write lock on SQLite until // the surrounding transaction completes. SystemConfig::update_many() @@ -44,10 +49,10 @@ async fn acquire_system_config_lock(db: &C, purpose: &str) - /// surrounding transaction completes. After this returns, the caller can safely re-check the user /// table and create the initial administrator in the same transaction. pub async fn acquire_setup_lock(db: &C) -> Result<()> { - acquire_system_config_lock(db, "setup").await + acquire_system_config_lock(db, "setup", false).await } /// Serializes storage topology mutations using the same non-deletable system configuration row. pub async fn acquire_storage_topology_lock(db: &C) -> Result<()> { - acquire_system_config_lock(db, "storage topology").await + acquire_system_config_lock(db, "storage topology", true).await } From 4e0abf65d2a3052149fb861178a75e9c200692c3 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 02:45:09 +0800 Subject: [PATCH 3/3] docs(storage): clarify policy group deletion guards --- developer-docs/en/api/admin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/developer-docs/en/api/admin.md b/developer-docs/en/api/admin.md index cdc3720c5..a353ca450 100644 --- a/developer-docs/en/api/admin.md +++ b/developer-docs/en/api/admin.md @@ -500,7 +500,7 @@ The admin folder-policy request is `{ "policy_id": 12 }`; `{ "policy_id": null } | `DELETE` | `/admin/policy-groups/{id}` | Delete policy group | | `POST` | `/admin/policy-groups/{id}/migrate-assignments` | Migrate user and team policy group bindings by updating `policy_group_id` | -Policy groups define storage policy selection for users and teams. A group configuration still requires at least one policy item. Deleting the last default group clears its user/team bindings and returns the system to `needs_storage`; other referenced groups remain protected. Migration responses report `affected_users`, `affected_teams`, and `migrated_assignments`. +Policy groups define storage policy selection for users and teams. A group configuration still requires at least one policy item. A policy group with user or team bindings must be migrated before deletion, including the last default group; once deletion succeeds, deleting the last default group returns the system to `needs_storage` without clearing bindings. Other referenced groups remain protected. Migration responses report `affected_users`, `affected_teams`, and `migrated_assignments`. ## Users