Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 24 additions & 25 deletions addons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,42 @@ Installable modules shipped with the `sumeru` Go module. Each folder name equals

| Module | Type | Depends | Purpose |
|--------|------|---------|---------|
| **base** | Application | — | Kernel: users, companies, partners, geo, i18n, platform metadata |
| **base** | Application | — | Kernel: users, companies, partners, security |
| **audit** | Technical | base, platform | Audit trail, retention, exports (auto_install) |
| **platform** | Technical | base | Attachments, sequences, parameters, reports, import (auto_install) |
| **geo** | Technical | base | Countries, states, cities, currency (auto_install) |
| **i18n** | Technical | base | Internationalization: languages, translations, PO catalogs (auto_install) |
| **im** | Technical | base | Internal P2P chat (`im.message`, auto_install) |
| **contacts** | Application | base | Contacts app over `core.partner` (reference layout) |
| **mail** | Technical | base | Chatter (`mail.message`), activities, `PostMessage` API |
| **automation** | Technical | base, mail | Cron, workflow transitions, server actions on events |
| **automation** | Technical | base, audit, im | Cron, workflow transitions, server actions |
| **calendar** | Application | base, im, contacts | Calendar events |
| **digest** | Technical | base, im | KPI digest cron |
| **sumeru_ai** | Application | base | Optional AI shell hooks (`auto_import: false`) |

## Dependency graph

```mermaid
flowchart BT
base[base]
platform[platform]
geo[geo]
i18n[i18n]
audit[audit]
im[im]
contacts[contacts]
mail[mail]
automation[automation]
sumeru_ai[sumeru_ai]
platform --> base
geo --> base
i18n --> base
audit --> base
audit --> platform
im --> base
contacts --> base
mail --> base
automation --> base
automation --> mail
sumeru_ai --> base
automation --> audit
automation --> im
```

## Reference addon
## Standard

Use **[contacts/](contacts/)** as the gold standard for new modules:

- Split views: `{model}_{type}_views.xml`
- Window actions in `views/actions.xml` with `<action type="window">`
- Security first in manifest, menus last
- Model extend in `models/partner_extend.go`, view inherit in `views/*_inherit_views.xml`

Full rules: [core/module/addon_template/MODULE_STANDARD.txt](../core/module/addon_template/MODULE_STANDARD.txt)

## Scaffold

```bash
make bp NAME=my_module
make generate
go run ./cmd/sumeru -- -c sumeru.conf -i my_module
```
See [core/module/addon_template/MODULE_STANDARD.txt](../core/module/addon_template/MODULE_STANDARD.txt) and **contacts** for layout conventions.
98 changes: 98 additions & 0 deletions addons/audit/audit_cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package audit

import (
"context"
"fmt"
"strings"

"sumeru/core/orm"
)

const (
auditCleanupModel = "sys.audit.cleanup"
auditPurgeBatchSize = 500
)

func init() {
orm.RegisterObjectAction(auditCleanupModel, "action_purge", purgeAuditLog)
}

func purgeAuditLog(ctx context.Context, model string, id int, _ map[string]string) (string, error) {
if model != auditCleanupModel || id <= 0 {
return "", fmt.Errorf("invalid cleanup record")
}
if !orm.UserHasGroupXML(ctx, orm.SecurityUID(ctx), "base.group_system") {
return "", fmt.Errorf("access denied")
}
if err := orm.CheckModelAccess(ctx, orm.SecurityUID(ctx), auditModel, "unlink"); err != nil {
return "", fmt.Errorf("access denied")
}
rec, err := orm.SearchOne(ctx, auditCleanupModel, map[string]interface{}{"id": id})
if err != nil {
return "", fmt.Errorf("record not found")
}
days, err := retentionDaysFromRecord(rec)
if err != nil {
return "", err
}
dryRun := orm.AsBool(rec["dry_run"])
opts := RetentionOptions{
RetentionDays: days,
FilterModel: strings.TrimSpace(orm.AsString(rec["filter_model"])),
DryRun: dryRun,
DeleteAfterExport: !dryRun,
Compress: true,
BatchSize: auditPurgeBatchSize,
MaxRowsPerRun: 50000,
}
if policy, perr := loadRetentionPolicy(ctx); perr == nil && len(policy) > 0 {
policyOpts := retentionOptionsFromPolicy(policy, dryRun)
opts.Compress = policyOpts.Compress
opts.LegalHold = policyOpts.LegalHold
if policyOpts.LegalHold {
opts.DeleteAfterExport = false
}
}
var result RetentionResult
err = orm.WithElevated(ctx, "audit_cleanup", func(elevCtx context.Context) error {
var runErr error
result, runErr = runAuditRetention(elevCtx, opts)
return runErr
})
if err != nil {
return "", err
}
summary := result.Summary
if summary == "" {
summary = fmt.Sprintf("%d audit row(s) older than %d days", result.RowsDeleted+result.RowsExported, days)
}
if dryRun {
summary = "Dry run: " + summary
} else if result.RowsDeleted > 0 || result.RowsExported > 0 {
orm.AppendAudit(ctx, "audit_cleanup", auditModel, 0, nil, nil,
fmt.Sprintf("deleted=%d exported=%d days=%d model_filter=%q", result.RowsDeleted, result.RowsExported, days, strings.TrimSpace(orm.AsString(rec["filter_model"]))))
}
_ = orm.UpdateRecordByID(ctx, auditCleanupModel, id, map[string]interface{}{"result_summary": summary})
return "", nil
}

func retentionDaysFromRecord(rec map[string]interface{}) (int, error) {
preset := strings.TrimSpace(orm.AsString(rec["retention_preset"]))
switch preset {
case "7_days":
return 7, nil
case "30_days", "":
return 30, nil
case "custom":
d, ok := orm.CoerceInt64(rec["retention_days"])
if !ok || d <= 0 {
return 0, fmt.Errorf("custom retention requires days greater than zero")
}
if d > 3650 {
return 0, fmt.Errorf("retention days too large")
}
return int(d), nil
default:
return 0, fmt.Errorf("unknown retention preset %q", preset)
}
}
166 changes: 166 additions & 0 deletions addons/audit/audit_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package audit

import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"sort"

"sumeru/core/orm"
)

const (
auditArchiveStateStored = "stored"
auditArchiveStateFailed = "failed"
auditArchiveStatePurged = "purged"
)

// auditExportRow is one sys.audit row in NDJSON export.
type auditExportRow struct {
ID int64 `json:"id"`
Action string `json:"action"`
Model string `json:"model"`
ResID int64 `json:"res_id"`
UserID int64 `json:"user_id,omitempty"`
CreateDate string `json:"create_date"`
BeforeJSON string `json:"before_json,omitempty"`
AfterJSON string `json:"after_json,omitempty"`
Detail string `json:"detail,omitempty"`
}

func auditRowFromMap(row map[string]interface{}) auditExportRow {
id, _ := orm.CoerceInt64(row["id"])
resID, _ := orm.CoerceInt64(row["res_id"])
uid, _ := orm.CoerceInt64(row["user_id"])
return auditExportRow{
ID: id,
Action: orm.AsString(row["action"]),
Model: orm.AsString(row["model"]),
ResID: resID,
UserID: uid,
CreateDate: orm.AsString(row["create_date"]),
BeforeJSON: orm.AsString(row["before_json"]),
AfterJSON: orm.AsString(row["after_json"]),
Detail: orm.AsString(row["detail"]),
}
}

func encodeAuditJSONL(rows []auditExportRow) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
for _, row := range rows {
if err := enc.Encode(row); err != nil {
return nil, fmt.Errorf("jsonl encode: %w", err)
}
}
return buf.Bytes(), nil
}

func gzipBytes(raw []byte) ([]byte, error) {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
if _, err := w.Write(raw); err != nil {
_ = w.Close()
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

func maybeGzipAudit(compress bool, raw []byte) (payload []byte, mime string, err error) {
if !compress {
return raw, "application/x-ndjson", nil
}
out, err := gzipBytes(raw)
if err != nil {
return nil, "", err
}
return out, "application/gzip", nil
}

func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}

func auditPeriodBounds(rows []auditExportRow) (from, to string) {
if len(rows) == 0 {
return "", ""
}
dates := make([]string, len(rows))
for i, r := range rows {
dates[i] = r.CreateDate
}
sort.Strings(dates)
return dates[0], dates[len(dates)-1]
}

func auditMinMaxID(rows []auditExportRow) (minID, maxID int64) {
if len(rows) == 0 {
return 0, 0
}
minID = rows[0].ID
maxID = rows[0].ID
for _, r := range rows[1:] {
if r.ID < minID {
minID = r.ID
}
if r.ID > maxID {
maxID = r.ID
}
}
return minID, maxID
}

// DecodeAuditJSONLForTest parses NDJSON (tests).
func DecodeAuditJSONLForTest(data []byte) ([]auditExportRow, error) {
dec := json.NewDecoder(bytes.NewReader(data))
var rows []auditExportRow
for {
var row auditExportRow
if err := dec.Decode(&row); err != nil {
if err == io.EOF {
break
}
return nil, err
}
rows = append(rows, row)
}
return rows, nil
}

// EncodeAuditJSONLForTest encodes rows (tests).
func EncodeAuditJSONLForTest(rows []auditExportRow) ([]byte, error) {
return encodeAuditJSONL(rows)
}

// MaybeGzipAuditForTest compresses export payload (tests).
func MaybeGzipAuditForTest(compress bool, raw []byte) ([]byte, string, error) {
return maybeGzipAudit(compress, raw)
}

// SHA256HexAuditForTest returns digest hex (tests).
func SHA256HexAuditForTest(data []byte) string {
return sha256Hex(data)
}

// AuditPeriodBoundsForTest returns min/max create_date (tests).
func AuditPeriodBoundsForTest(rows []auditExportRow) (string, string) {
return auditPeriodBounds(rows)
}

// AuditMinMaxIDForTest returns min/max row id (tests).
func AuditMinMaxIDForTest(rows []auditExportRow) (int64, int64) {
return auditMinMaxID(rows)
}

// AuditRowFromMapForTest maps ORM row to export row (tests).
func AuditRowFromMapForTest(row map[string]interface{}) AuditExportRowForTest {
return auditRowFromMap(row)
}
Loading
Loading