feat: add configurable record retention days per organization
This commit is contained in:
@@ -77,6 +77,7 @@ func main() {
|
||||
object.InitUserManager()
|
||||
object.InitFromFile()
|
||||
object.InitCleanupTokens()
|
||||
object.InitCleanupRecords()
|
||||
object.InitCleanupDeviceAuthMap()
|
||||
object.InitExpirePermissions()
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"))} :
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "确认您的发票信息",
|
||||
|
||||
Reference in New Issue
Block a user