feat: add configurable record retention days per organization

This commit is contained in:
Yang Luo
2026-08-04 14:06:01 +08:00
parent fffa9202c1
commit 8799cfee42
6 changed files with 154 additions and 2 deletions
+1
View File
@@ -77,6 +77,7 @@ func main() {
object.InitUserManager()
object.InitFromFile()
object.InitCleanupTokens()
object.InitCleanupRecords()
object.InitCleanupDeviceAuthMap()
object.InitExpirePermissions()
+1
View File
@@ -66,6 +66,7 @@ type Organization struct {
PasswordObfuscatorKey string `xorm:"varchar(100)" json:"passwordObfuscatorKey"`
PasswordExpireDays int `json:"passwordExpireDays"`
TokenRetentionDays int `json:"tokenRetentionDays"`
RecordRetentionDays int `json:"recordRetentionDays"`
CountryCodes []string `xorm:"mediumtext" json:"countryCodes"`
DefaultAvatar string `xorm:"varchar(200)" json:"defaultAvatar"`
UsePermanentAvatar bool `xorm:"bool" json:"usePermanentAvatar"`
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"time"
"github.com/robfig/cron/v3"
)
// recordCleanupBatchSize limits how many audit rows are deleted by a single statement,
// so that cleaning up a "record" table that has grown for years does not lock it for
// a long time.
const recordCleanupBatchSize = 1000
// getOrgRecordRetentionDays returns a map from organization name to its configured
// record retention period in days. Organizations that keep their records forever
// (the default, i.e. a non-positive value) are not included, so audit rows are never
// deleted unless the retention has been explicitly configured.
func getOrgRecordRetentionDays() (map[string]int, error) {
organizations, err := GetOrganizationsByFields("admin", "name", "record_retention_days")
if err != nil {
return nil, fmt.Errorf("failed to load organizations for record cleanup: %w", err)
}
res := map[string]int{}
for _, organization := range organizations {
if organization.RecordRetentionDays > 0 {
res[organization.Name] = organization.RecordRetentionDays
}
}
return res, nil
}
// cleanupOrgRecords deletes the records of one organization that were created before
// cutoffTime, batch by batch, and returns how many rows were deleted.
func cleanupOrgRecords(owner string, cutoffTime string) (int64, error) {
deletedCount := int64(0)
for {
records := []*Record{}
err := ormer.Engine.Cols("id").Where("owner = ?", owner).And("created_time < ?", cutoffTime).Limit(recordCleanupBatchSize).Find(&records)
if err != nil {
return deletedCount, fmt.Errorf("failed to query expired records of organization %s: %w", owner, err)
}
if len(records) == 0 {
break
}
ids := []int{}
for _, record := range records {
ids = append(ids, record.Id)
}
affected, err := ormer.Engine.In("id", ids).Delete(&Record{})
if err != nil {
return deletedCount, fmt.Errorf("failed to delete expired records of organization %s: %w", owner, err)
}
deletedCount += affected
if len(records) < recordCleanupBatchSize {
break
}
}
return deletedCount, nil
}
func CleanupRecords() error {
retentionDaysMap, err := getOrgRecordRetentionDays()
if err != nil {
return err
}
currentTime := time.Now()
for owner, retentionDays := range retentionDaysMap {
// "record"'s "owner" column is the organization that the record belongs to,
// see AddRecord().
cutoffTime := currentTime.AddDate(0, 0, -retentionDays).Format(time.RFC3339)
deletedCount, err := cleanupOrgRecords(owner, cutoffTime)
if err != nil {
return err
}
if deletedCount != 0 {
fmt.Printf("Deleted [%d] expired records | Org: %s | Created before: %s\n", deletedCount, owner, cutoffTime)
}
}
return nil
}
func InitCleanupRecords() {
schedule := "0 0 * * *"
go func() {
if err := CleanupRecords(); err != nil {
fmt.Printf("Error cleaning up records at startup: %v\n", err)
}
}()
cronJob := cron.New()
_, err := cronJob.AddFunc(schedule, func() {
if err := CleanupRecords(); err != nil {
fmt.Printf("Error cleaning up records: %v\n", err)
}
})
if err != nil {
fmt.Printf("Error scheduling record cleanup: %v\n", err)
return
}
cronJob.Start()
}
+17
View File
@@ -414,6 +414,23 @@ class OrganizationEditPage extends React.Component {
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("organization:Record retention days"), i18next.t("organization:Record retention days - Tooltip"))} :
</Col>
<Col span={4} >
<AutoComplete
style={{width: "100%"}}
value={this.state.organization.recordRetentionDays ? this.state.organization.recordRetentionDays.toString() : ""}
options={[7, 30, 90, 180, 365].map(days => ({value: days.toString(), label: `${days} ${i18next.t("organization:days")}`}))}
filterOption={(inputValue, option) => option.value.startsWith(inputValue)}
onChange={value => {
const digits = (value || "").replace(/\D/g, "");
this.updateOrganizationField("recordRetentionDays", digits === "" ? 0 : Number(digits));
}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Supported country codes"), i18next.t("general:Supported country codes - Tooltip"))} :
+4 -1
View File
@@ -979,6 +979,8 @@
"Password expire days": "Password expire days",
"Password expire days - Tooltip": "Number of days before password expires",
"Prompt": "Prompt",
"Record retention days": "Record retention days",
"Record retention days - Tooltip": "Number of days to keep the organization's records (audit logs) before they are automatically deleted by a daily cleanup job. Leave it empty or set it to 0 to keep the records forever",
"Required": "Required",
"Soft deletion": "Soft deletion",
"Soft deletion - Tooltip": "When enabled, deleting users will not completely remove them from the database. Instead, they will be marked as deleted",
@@ -998,7 +1000,8 @@
"Website URL": "Website URL",
"Website URL - Tooltip": "The homepage URL of the organization. This field is not used in Casdoor",
"Widget items": "Widget items",
"Widget items - Tooltip": "Items displayed in the widget"
"Widget items - Tooltip": "Items displayed in the widget",
"days": "days"
},
"payment": {
"Confirm your invoice information": "Confirm your invoice information",
+4 -1
View File
@@ -979,6 +979,8 @@
"Password expire days": "密码过期天数",
"Password expire days - Tooltip": "密码过期前的天数",
"Prompt": "提示",
"Record retention days": "日志保留天数",
"Record retention days - Tooltip": "组织的日志(审计记录)保留天数,超期的日志会被每天执行的清理任务自动删除。留空或填0表示永久保留",
"Required": "必须",
"Soft deletion": "软删除",
"Soft deletion - Tooltip": "启用后,删除一个用户时不会在数据库彻底清除,只会标记为已删除状态",
@@ -998,7 +1000,8 @@
"Website URL": "主页地址",
"Website URL - Tooltip": "组织的主页地址URL,该字段在Casdoor平台中未被使用",
"Widget items": "功能按钮",
"Widget items - Tooltip": "小部件中显示的项目"
"Widget items - Tooltip": "小部件中显示的项目",
"days": "天"
},
"payment": {
"Confirm your invoice information": "确认您的发票信息",