diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e94400c6..0765f223a 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.1] - 2026-08-20
### Changed
diff --git a/developer-docs/en/api/admin.md b/developer-docs/en/api/admin.md
index 387a54ee4..73e7391d9 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. 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
diff --git a/developer-docs/zh-CN/api/admin.md b/developer-docs/zh-CN/api/admin.md
index bd3c28b90..50b5f11ed 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..69f75d479 100644
--- a/src/db/repository/system_initialization_repo.rs
+++ b/src/db/repository/system_initialization_repo.rs
@@ -1,16 +1,22 @@
//! 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,
+ 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()
.col_expr(
system_config::Column::Value,
@@ -31,8 +37,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", 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", true).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"
);
}