diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c43ec68f..0a80fc0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.2" + go-version: "1.26.6" cache: true - name: Build all packages @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.2" + go-version: "1.26.6" cache: true - name: Module static tests @@ -71,7 +71,58 @@ jobs: if: always() run: | echo "## Go test" >> "$GITHUB_STEP_SUMMARY" - echo "Commands: \`make test-modules-static\`, \`go test ./test/...\`, coverage gate 90%" >> "$GITHUB_STEP_SUMMARY" + echo "Commands: \`make test-modules-static\`, \`go test ./test/...\`, coverage gate ${GO_COVERAGE_MIN:-42}%" >> "$GITHUB_STEP_SUMMARY" + + go-lint: + name: Go lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # Needed for golangci-lint only-new-issues on pull_request. + fetch-depth: 0 + + - uses: actions/setup-go@v7 + with: + go-version: "1.26.6" + cache: true + + - name: go vet + run: go vet ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + # Need a v2.x build that supports analyzing Go 1.26 modules. + version: latest + args: --timeout=10m + only-new-issues: ${{ github.event_name == 'pull_request' }} + + - name: Job summary + if: always() + run: | + echo "## Go lint" >> "$GITHUB_STEP_SUMMARY" + echo "Commands: \`go vet ./...\`, \`golangci-lint run\`" >> "$GITHUB_STEP_SUMMARY" + + go-vuln: + name: Go vulnerabilities + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version: "1.26.6" + cache: true + + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + - name: Job summary + if: always() + run: | + echo "## Go vulnerabilities" >> "$GITHUB_STEP_SUMMARY" + echo "Command: \`govulncheck ./...\`" >> "$GITHUB_STEP_SUMMARY" swc: name: SWC check and test @@ -84,7 +135,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: "22" + node-version: "24" cache: npm cache-dependency-path: core/swc/package-lock.json @@ -111,7 +162,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.2" + go-version: "1.26.6" cache: true - name: Regenerate zimports.go @@ -135,7 +186,7 @@ jobs: # runs-on: ubuntu-latest # services: # postgres: - # image: postgres:16-alpine + # image: postgres:18-alpine # env: # POSTGRES_USER: postgres # POSTGRES_PASSWORD: postgres @@ -152,7 +203,7 @@ jobs: # - uses: actions/setup-go@v7 # with: - # go-version: "1.26.2" + # go-version: "1.26.6" # cache: true # - name: Bootstrap kernel schema diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..f5bd04c5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,30 @@ +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + settings: + errcheck: + # Deferred Close errors are rarely actionable; keep errcheck for real ignored returns. + exclude-functions: + - (io.Closer).Close + - (*database/sql.Rows).Close + - (*database/sql.DB).Close + - (*os.File).Close + - (*compress/gzip.Reader).Close + - (*mime/multipart.FileHeader).Open + - (net/smtp.Client).Close + - (*net/smtp.Client).Close + - (*github.com/gorilla/websocket.Conn).Close + +run: + timeout: 10m + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ea2c3edf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Multi-stage production image for the Sumeru engine (single-node pilot). +FROM golang:1.26.6-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/sumeru ./cmd/sumeru + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=build /out/sumeru /app/sumeru +COPY --from=build /src/addons /app/addons +COPY --from=build /src/core/engine/assets /app/core/engine/assets +COPY --from=build /src/core/engine/templates /app/core/engine/templates +COPY --from=build /src/sumeru.conf.example /app/sumeru.conf +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/app/sumeru", "-c", "/app/sumeru.conf"] diff --git a/Makefile b/Makefile index 84bdbef7..68e2c954 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: help setup dev build css run generate bp check-sql check-logs db-check \ i18n-export i18n-import module shell test-db test-integration test-coverage \ test-modules test-modules-static test-modules-unit test-modules-addon test-modules-integration \ - swc swc-build assets swc-check swc-test check + swc swc-build assets swc-check swc-test check lint # Extra flags for `make run`, e.g. `make run EXTRA_RUN_FLAGS='-p 9090 -d sumeru_staging'` EXTRA_RUN_FLAGS ?= @@ -19,6 +19,12 @@ check-sql: check-logs: @bash scripts/check_no_stdlog.sh +# Match CI go-lint: go vet + golangci-lint v2 (see .golangci.yml). +# Use go run so a stale v1 binary on PATH does not break the target. +lint: + go vet ./... + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run --timeout=10m + generate: go generate ./cmd/sumeru @@ -62,7 +68,7 @@ dev: run build: generate assets go build -o sumeru ./cmd/sumeru -check: swc-check test-modules-static +check: swc-check lint test-modules-static go test ./test/... -count=1 test-modules-static: @@ -127,7 +133,8 @@ help: @echo "Go / addons:" @echo " make generate - refresh cmd/sumeru/zimports.go" @echo " make bp NAME=x - scaffold kernel addon (then make generate)" - @echo " make check - swc-check + test-modules-static + go test ./test/..." + @echo " make lint - go vet + golangci-lint (matches CI go-lint)" + @echo " make check - swc-check + lint + test-modules-static + go test ./test/..." @echo " make test-modules - static + unit + addon module suite tiers" @echo " make test-coverage - full repo coverage with 90% gate" @echo " make module - module CLI (ARGS='list' | 'install sales' | ...)" @@ -135,4 +142,4 @@ help: @echo "" @echo "Other: db-check | i18n-export | i18n-import | test-integration | check-sql | check-logs" @echo "Vars: EXTRA_RUN_FLAGS='-p 9090 -d mydb'" - @echo "Prerequisites: Go 1.26.2+, Node.js (npm), PostgreSQL — see README.md" + @echo "Prerequisites: Go 1.26.6+, Node.js (npm), PostgreSQL — see README.md" diff --git a/README.md b/README.md index 880454ba..e866708f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Modular open-source ERP — Go backend, PostgreSQL, and a modern web workspace.** [![CI](https://github.com/ProjectMeru/sumeru/actions/workflows/ci.yml/badge.svg)](https://github.com/ProjectMeru/sumeru/actions/workflows/ci.yml) -[![Go](https://img.shields.io/badge/Go-1.26.2+-00ADD8?logo=go&logoColor=white)](https://go.dev/dl/) +[![Go](https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white)](https://go.dev/dl/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Pre-Alpha](https://img.shields.io/badge/Status-Pre--Alpha-critical)](https://github.com/ProjectMeru/sumeru) [![Docs](https://img.shields.io/badge/Docs-projectmeru.github.io-informational)](https://projectmeru.github.io/sumeru/docs/) @@ -38,7 +38,7 @@ This repository is the **core engine** (`module sumeru`). Most teams keep it pul ## Quick start -**Prerequisites:** [Go 1.26.2+](https://go.dev/dl/), [Node.js](https://nodejs.org/) (npm — builds the SWC UI), [PostgreSQL](https://www.postgresql.org/) +**Prerequisites:** [Go 1.26.6+](https://go.dev/dl/), [Node.js](https://nodejs.org/) (npm — builds the SWC UI), [PostgreSQL](https://www.postgresql.org/) Clone the three sibling repositories, configure the workspace, and run: @@ -152,7 +152,7 @@ sumeru_custom_addons | Layer | Technology | | ----- | ---------- | -| Server | Go 1.26.2+, structured logging (`log/slog`) | +| Server | Go 1.26.6+, structured logging (`log/slog`) | | Database | PostgreSQL | | Modules | Go addons + XML views/menus + manifest sync | | Workspace UI | SWC (TypeScript) — sources in `core/swc/` | diff --git a/addons/automation/webhook.go b/addons/automation/webhook.go index 179264da..3e7555f0 100644 --- a/addons/automation/webhook.go +++ b/addons/automation/webhook.go @@ -37,6 +37,15 @@ func dispatchWebhook(ctx context.Context, rawURL string, ev event.Event) error { if err != nil { return err } + u, err := url.Parse(rawURL) + if err != nil { + return err + } + dialIPs, err := resolveWebhookDialIPs(u.Hostname()) + if err != nil { + metrics.Inc("sumeru_webhook_blocked_total") + return err + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(body)) if err != nil { return err @@ -44,11 +53,17 @@ func dispatchWebhook(ctx context.Context, rawURL string, ev event.Event) error { req.Header.Set("Content-Type", "application/json") client := &http.Client{ Timeout: 15 * time.Second, + Transport: &http.Transport{ + DialContext: pinnedWebhookDialer(dialIPs), + }, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return fmt.Errorf("webhook redirect limit exceeded") } - return validateWebhookURL(req.URL.String()) + if err := validateWebhookURL(req.URL.String()); err != nil { + return err + } + return nil }, } resp, err := client.Do(req) @@ -68,6 +83,59 @@ func dispatchWebhook(ctx context.Context, rawURL string, ev event.Event) error { return nil } +func pinnedWebhookDialer(allowed []net.IP) func(ctx context.Context, network, addr string) (net.Conn, error) { + allowedSet := make(map[string]struct{}, len(allowed)) + for _, ip := range allowed { + allowedSet[ip.String()] = struct{}{} + } + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("webhook dial host must be IP") + } + if _, ok := allowedSet[ip.String()]; !ok { + return nil, fmt.Errorf("webhook dial IP not in validated set") + } + if blockedWebhookIP(ip) { + return nil, fmt.Errorf("webhook dial IP not allowed") + } + var d net.Dialer + return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } +} + +func resolveWebhookDialIPs(host string) ([]net.IP, error) { + host = strings.TrimSpace(host) + if host == "" { + return nil, fmt.Errorf("webhook host required") + } + if ip := net.ParseIP(host); ip != nil { + if blockedWebhookIP(ip) { + return nil, fmt.Errorf("webhook IP not allowed") + } + return []net.IP{ip}, nil + } + ips, err := net.LookupIP(host) + if err != nil { + return nil, fmt.Errorf("webhook host lookup: %w", err) + } + var out []net.IP + for _, ip := range ips { + if blockedWebhookIP(ip) { + return nil, fmt.Errorf("webhook host resolves to blocked address") + } + out = append(out, ip) + } + if len(out) == 0 { + return nil, fmt.Errorf("webhook host resolved to no addresses") + } + return out, nil +} + func validateWebhookURL(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { @@ -89,32 +157,27 @@ func validateWebhookURL(raw string) error { if lowerHost == "localhost" || strings.HasSuffix(lowerHost, ".localhost") || lowerHost == "metadata.google.internal" { return fmt.Errorf("webhook host not allowed") } - if ip := net.ParseIP(host); ip != nil { - if blockedWebhookIP(ip) { - return fmt.Errorf("webhook IP not allowed") - } - return nil - } - ips, err := net.LookupIP(host) - if err != nil { - return fmt.Errorf("webhook host lookup: %w", err) - } - if len(ips) == 0 { - return fmt.Errorf("webhook host resolved to no addresses") - } - for _, ip := range ips { - if blockedWebhookIP(ip) { - return fmt.Errorf("webhook host resolves to blocked address") - } - } - return nil + _, err = resolveWebhookDialIPs(host) + return err } func blockedWebhookIP(ip net.IP) bool { - return ip.IsLoopback() || + if ip == nil { + return true + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || - ip.IsMulticast() + ip.IsMulticast() { + return true + } + // CGNAT / shared address space (RFC 6598) — not covered by IsPrivate(). + if ip4 := ip.To4(); ip4 != nil { + if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return true + } + } + return false } diff --git a/addons/mail/mail.go b/addons/mail/mail.go index f10ed7c3..8151f233 100644 --- a/addons/mail/mail.go +++ b/addons/mail/mail.go @@ -59,15 +59,6 @@ func firstCompanyMailSettings(ctx context.Context) (companyMailSettings, bool) { return out, id.Valid } -// firstCompanyID returns the primary company row id, or 0 if none. -func firstCompanyID(ctx context.Context) int64 { - settings, ok := firstCompanyMailSettings(ctx) - if !ok { - return 0 - } - return settings.id -} - // CompanyChatterEnabled reads mail_chatter_enabled from the first core.company row (default true). func CompanyChatterEnabled(ctx context.Context) bool { settings, _ := firstCompanyMailSettings(ctx) diff --git a/core/engine/render/html_helpers.go b/core/engine/render/html_helpers.go index 47e55302..1d10824a 100644 --- a/core/engine/render/html_helpers.go +++ b/core/engine/render/html_helpers.go @@ -1,7 +1,6 @@ package render import ( - "html/template" "strings" "unicode" @@ -62,9 +61,3 @@ func FieldDisplayLabel(field parser.Field) string { } return strings.Join(words, " ") } - -// writeSaveCancelButtons renders form Save/Cancel controls for workspace record toolbar. -func writeSaveCancelButtons(sb *strings.Builder, cancelURL string) { - sb.WriteString(``) - sb.WriteString(`Cancel`) -} diff --git a/core/engine/render/render_helpers.go b/core/engine/render/render_helpers.go index 3624c1cb..edeb7cac 100644 --- a/core/engine/render/render_helpers.go +++ b/core/engine/render/render_helpers.go @@ -1,7 +1,6 @@ package render import ( - "context" "strings" "sumeru/core/orm" @@ -24,75 +23,3 @@ func recStr(rec map[string]interface{}, name string) string { } return strings.TrimSpace(orm.AsString(rec[name])) } - -func isTruthyDB(v interface{}) bool { - switch t := v.(type) { - case bool: - return t - case int64: - return t != 0 - case int32: - return t != 0 - case int: - return t != 0 - case float64: - return t != 0 - case []byte: - s := strings.ToLower(strings.TrimSpace(string(t))) - return s == "t" || s == "true" || s == "1" - case string: - s := strings.ToLower(strings.TrimSpace(t)) - return s == "t" || s == "true" || s == "1" - default: - return false - } -} - -func formFieldReadonly(vr *ViewRecordData) bool { - if vr == nil || strings.TrimSpace(vr.ResModel) == "" { - return true - } - if vr.RecordID == 0 { - return false - } - return !vr.FormEditing -} - -func workspaceFormChrome(vr *ViewRecordData) bool { - return vr != nil && strings.TrimSpace(vr.ResModel) != "" -} - -func rawField(record map[string]interface{}, name string) (interface{}, bool) { - if record == nil { - return nil, false - } - v, ok := record[name] - return v, ok -} - -func fieldDef(model, fieldName string) *orm.FieldDefinition { - inst, ok := orm.Registry[model] - if !ok { - return nil - } - for i := range inst.Fields() { - f := inst.Fields()[i] - if f.Name == fieldName { - return &f - } - } - return nil -} - -// displayCell returns a human-readable cell value, resolving Many2One to display name. -func displayCell(ctx context.Context, model, fieldName string, row map[string]interface{}) string { - if fd := fieldDef(model, fieldName); fd != nil && fd.Type == orm.Many2One { - if id, ok := orm.CoerceInt64(row[fieldName]); ok && id > 0 { - if n := orm.DisplayNameForID(ctx, fd.Relation, int(id)); n != "" { - return n - } - } - return "" - } - return strings.TrimSpace(recStr(row, fieldName)) -} diff --git a/core/engine/render/render_types.go b/core/engine/render/render_types.go index 17e9a3f0..9ec2c9a0 100644 --- a/core/engine/render/render_types.go +++ b/core/engine/render/render_types.go @@ -39,8 +39,8 @@ func RegisterNotebookHook(model, pageTitle string, hook UIHook) { } type ShellCompanyOption struct { - ID int - Name string + ID int `json:"id"` + Name string `json:"name"` } // PageData is the top-level template payload for base.html. diff --git a/core/engine/render/report_helpers.go b/core/engine/render/report_helpers.go deleted file mode 100644 index 8c525919..00000000 --- a/core/engine/render/report_helpers.go +++ /dev/null @@ -1,36 +0,0 @@ -package render - -import ( - "net/url" - "strings" - - "sumeru/core/engine/parser" -) - -func menuIDFromFormBaseQuery(qs string) string { - qs = strings.TrimSpace(qs) - if qs == "" { - return "" - } - qv, err := url.ParseQuery(qs) - if err != nil { - return "" - } - return strings.TrimSpace(qv.Get(WorkspaceMenuIDParam)) -} - -func viewFieldsForReport(view *parser.View) []parser.Field { - if view == nil { - return nil - } - if len(view.Field) > 0 { - return view.Field - } - var out []parser.Field - if view.Sheet != nil { - for _, f := range view.Sheet.Field { - out = append(out, f) - } - } - return out -} diff --git a/core/engine/render/swc_bootstrap.go b/core/engine/render/swc_bootstrap.go index affd3742..a927ade6 100644 --- a/core/engine/render/swc_bootstrap.go +++ b/core/engine/render/swc_bootstrap.go @@ -124,13 +124,13 @@ func BuildSWCBootstrapJSON(ctx context.Context, page PageData, ws *SWCBootstrapW b.SidebarMenus = append(b.SidebarMenus, sg) } for _, c := range page.ShellCompanyOptions { - b.Companies = append(b.Companies, swcBootstrapCompany{ID: c.ID, Name: c.Name}) + b.Companies = append(b.Companies, swcBootstrapCompany(c)) } b.Company = swcBootstrapCompany{Name: page.ShellCompany} if page.ShellActiveCompanyID > 0 { for _, c := range page.ShellCompanyOptions { if c.ID == page.ShellActiveCompanyID { - b.Company = swcBootstrapCompany{ID: c.ID, Name: c.Name} + b.Company = swcBootstrapCompany(c) break } } diff --git a/core/engine/render/user_security_render.go b/core/engine/render/user_security_render.go deleted file mode 100644 index 5cf4bb95..00000000 --- a/core/engine/render/user_security_render.go +++ /dev/null @@ -1,242 +0,0 @@ -package render - -import ( - "context" - "fmt" - "html/template" - "sort" - "strings" - - "sumeru/core/orm" -) - -func writeResUsersSecuritySection(ctx context.Context, sb *strings.Builder, vr *ViewRecordData, ro bool) { - uid := orm.SecurityUID(ctx) - if uid <= 0 { - return - } - if err := orm.CheckModelAccess(ctx, uid, "core.group", "read"); err != nil { - return - } - isAdmin := orm.UserHasGroupXML(ctx, uid, "base.group_system") - // Only system admins may edit access rights; others see assigned groups read-only. - editable := isAdmin && !ro - - sb.WriteString(`
`) - sb.WriteString(`

Access rights

`) - if editable { - sb.WriteString(`

Only installed applications are listed. Manager includes User rights for that app. System admin includes all module Managers. Portal and Public are mutually exclusive with Internal User.

`) - sb.WriteString(``) - sb.WriteString(`
`) - sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(``) - sb.WriteString(`
`) - } else { - sb.WriteString(`

Assigned access for this user. Only an administrator can change access rights.

`) - } - - selected := map[int]struct{}{} - if vr.RecordID > 0 { - rel := orm.MustQuotedTableName("core.group.user.rel") - rows, err := orm.DB.QueryContext(ctx, `SELECT group_id FROM `+rel+` WHERE user_id = $1`, vr.RecordID) - if err == nil { - for rows.Next() { - var gid int - if err := rows.Scan(&gid); err == nil { - selected[gid] = struct{}{} - } - } - rows.Close() - } - } - - groups, err := orm.ListAllGroupRows(ctx) - if err != nil || len(groups) == 0 { - sb.WriteString(`

No groups defined.

`) - return - } - - typeXML := map[string]int{} - for _, x := range []string{"base.group_user", "base.group_portal", "base.group_public", "base.group_system"} { - if gid, _, err := orm.ResolveXmlId(ctx, x); err == nil && gid > 0 { - typeXML[x] = gid - } - } - - if !editable { - var names []string - for _, g := range groups { - id, _ := orm.CoerceInt64(g["id"]) - if _, ok := selected[int(id)]; ok { - names = append(names, orm.AsString(g["name"])) - } - } - sort.Strings(names) - if len(names) == 0 { - sb.WriteString(`

No access groups assigned.

`) - return - } - sb.WriteString(``) - return - } - - installed, _ := orm.InstalledModuleNames(ctx) - if installed == nil { - installed = map[string]struct{}{} - } - // Kernel groups always visible for admin assignment. - installed["base"] = struct{}{} - groupModule := loadCoreGroupModules(ctx) - - // User type radios - sb.WriteString(`
`) - sb.WriteString(`

User type

`) - sb.WriteString(`
`) - typeChoices := []struct{ xml, label string }{ - {"base.group_user", "Internal User"}, - {"base.group_portal", "Portal"}, - {"base.group_public", "Public"}, - } - chosenType := "" - for _, c := range typeChoices { - if gid, ok := typeXML[c.xml]; ok { - if _, sel := selected[gid]; sel { - chosenType = c.xml - break - } - } - } - if chosenType == "" { - chosenType = "base.group_user" - } - for _, c := range typeChoices { - gid := typeXML[c.xml] - if gid == 0 { - continue - } - chk := "" - if c.xml == chosenType { - chk = ` checked` - } - sb.WriteString(``) - } - sb.WriteString(`
`) - - type gRow struct { - ID int - Name string - CatID int - CatName string - Sequence int - } - var rows []gRow - catNames := map[int]string{} - if cats, err := orm.Search(ctx, "sys.module.category", nil); err == nil { - for _, c := range cats { - cid, _ := orm.CoerceInt64(c["id"]) - catNames[int(cid)] = orm.AsString(c["name"]) - } - } - skipTypes := map[int]struct{}{} - for _, x := range []string{"base.group_user", "base.group_portal", "base.group_public"} { - if gid, ok := typeXML[x]; ok { - skipTypes[gid] = struct{}{} - } - } - for _, g := range groups { - gid, ok := orm.CoerceInt64(g["id"]) - if !ok { - continue - } - if _, skip := skipTypes[int(gid)]; skip { - continue - } - mod := strings.TrimSpace(groupModule[int(gid)]) - if mod == "" { - mod = "base" - } - if _, ok := installed[mod]; !ok { - continue - } - cid, _ := orm.CoerceInt64(g["category_id"]) - seq, _ := orm.CoerceInt64(g["sequence"]) - cn := catNames[int(cid)] - if cn == "" { - cn = "Other" - } - rows = append(rows, gRow{ID: int(gid), Name: orm.AsString(g["name"]), CatID: int(cid), CatName: cn, Sequence: int(seq)}) - } - sort.Slice(rows, func(i, j int) bool { - if rows[i].CatName != rows[j].CatName { - return rows[i].CatName < rows[j].CatName - } - if rows[i].Sequence != rows[j].Sequence { - return rows[i].Sequence < rows[j].Sequence - } - return rows[i].Name < rows[j].Name - }) - - curCat := "" - for _, g := range rows { - if g.CatName != curCat { - if curCat != "" { - sb.WriteString(``) - } - curCat = g.CatName - sb.WriteString(`
`) - sb.WriteString(`

` + template.HTMLEscapeString(curCat) + `

`) - sb.WriteString(`
`) - } - _, checked := selected[g.ID] - chk := "" - if checked { - chk = ` checked` - } - sb.WriteString(``) - } - if curCat != "" { - sb.WriteString(`
`) - } - sb.WriteString(``) -} - -// loadCoreGroupModules maps core.group id → declaring module from sys.model.data. -func loadCoreGroupModules(ctx context.Context) map[int]string { - out := map[int]string{} - if orm.DB == nil { - return out - } - tbl := orm.MustQuotedTableName("sys.model.data") - rows, err := orm.DB.QueryContext(ctx, - `SELECT core_id, module FROM `+tbl+` WHERE model = $1 AND core_id IS NOT NULL AND core_id > 0`, - "core.group") - if err != nil { - return out - } - defer rows.Close() - for rows.Next() { - var id int - var mod string - if err := rows.Scan(&id, &mod); err != nil { - continue - } - mod = strings.TrimSpace(mod) - if id > 0 && mod != "" { - out[id] = mod - } - } - return out -} diff --git a/core/engine/viewinherit/xpath.go b/core/engine/viewinherit/xpath.go index 64138fdf..d183e630 100644 --- a/core/engine/viewinherit/xpath.go +++ b/core/engine/viewinherit/xpath.go @@ -125,12 +125,6 @@ func openingTagRe(target xpathTarget) *regexp.Regexp { }) } -func attrValuePattern(attrName, attrVal string) string { - q := regexp.QuoteMeta(attrVal) - a := regexp.QuoteMeta(attrName) - return `\b` + a + `=(?:"` + q + `"|'` + q + `')` -} - func findElementSpan(arch string, target xpathTarget) (start, end int, ok bool) { index := target.matchIndex() openRe := openingTagRe(target) diff --git a/core/importgen/models_render.go b/core/importgen/models_render.go index cbc21edf..94852242 100644 --- a/core/importgen/models_render.go +++ b/core/importgen/models_render.go @@ -123,8 +123,8 @@ func writeORMZRefs(ormDir string) error { b.WriteString("// Code generated by sumeru-import-gen; DO NOT EDIT.\n\npackage orm\n\nimport \"sumeru/core/modelmeta\"\n\n") for _, name := range names { technical := modelmeta.ModelNameFromGo(name) - b.WriteString(fmt.Sprintf("// %s → %s\n", name, technical)) - b.WriteString(fmt.Sprintf("type %s struct {\n\tmodelmeta.ModelMeta `sumeru:\"model=%s\"`\n}\n\n", name, technical)) + fmt.Fprintf(&b, "// %s → %s\n", name, technical) + fmt.Fprintf(&b, "type %s struct {\n\tmodelmeta.ModelMeta `sumeru:\"model=%s\"`\n}\n\n", name, technical) } return os.WriteFile(zrefsPath, []byte(b.String()), 0o644) } diff --git a/core/importgen/resolve.go b/core/importgen/resolve.go index b54c849d..dcfb80ef 100644 --- a/core/importgen/resolve.go +++ b/core/importgen/resolve.go @@ -74,15 +74,3 @@ func dirsForModule(workspace, sumeruRoot, addonsRoot, moduleName string) []strin addDir(filepath.Join(workspace, "addons", moduleName, "wizard")) return dirs } - -func workspaceAddonNames(workspaceRoot string) ([]string, error) { - discovered, err := module.DiscoverAddonRoots([]string{filepath.Join(workspaceRoot, "addons")}) - if err != nil { - return nil, err - } - names := make([]string, 0, len(discovered)) - for name := range discovered { - names = append(names, name) - } - return names, nil -} diff --git a/core/importgen/scan.go b/core/importgen/scan.go index aec37dba..84168143 100644 --- a/core/importgen/scan.go +++ b/core/importgen/scan.go @@ -82,15 +82,7 @@ func modelSpecFromStruct(st *ast.StructType, goName string) (modelmeta.ModelSpec return modelmeta.ModelSpecFromTags(tags, goName) } -func modelTagFromStruct(st *ast.StructType, goName string) string { - spec, err := modelSpecFromStruct(st, goName) - if err != nil { - return "" - } - return spec.Name -} - -func scanPackageModels(pkgs map[string]*ast.Package) []scannedModel { +func scanPackageModels(pkgs map[string]*ast.Package) []scannedModel { //nolint:staticcheck // SA1019: ParseDir/ast.Package adequate for model tag scan; go/packages migration is separate var out []scannedModel for _, pkg := range pkgs { for _, f := range pkg.Files { @@ -126,7 +118,7 @@ func scanPackageModels(pkgs map[string]*ast.Package) []scannedModel { func parseDirModels(dir string) ([]scannedModel, error) { fset := token.NewFileSet() - pkgs, err := parser.ParseDir(fset, dir, isSourceGo, 0) + pkgs, err := parser.ParseDir(fset, dir, isSourceGo, 0) //nolint:staticcheck // SA1019: see scanPackageModels if err != nil { return nil, err } @@ -151,7 +143,7 @@ func scanPackageForModels(dir string) ([]string, error) { func packageNameForDir(dir string) string { fset := token.NewFileSet() - pkgs, err := parser.ParseDir(fset, dir, isSourceGo, parser.PackageClauseOnly) + pkgs, err := parser.ParseDir(fset, dir, isSourceGo, parser.PackageClauseOnly) //nolint:staticcheck // SA1019: see scanPackageModels if err != nil || len(pkgs) == 0 { return "models" } diff --git a/core/importgen/zrefs_render.go b/core/importgen/zrefs_render.go index 6feb4788..620f3945 100644 --- a/core/importgen/zrefs_render.go +++ b/core/importgen/zrefs_render.go @@ -61,7 +61,7 @@ func renderZRefs(refs []exportedRef) string { b.WriteString("\t\"sumeru/core/sdk\"\n") } for _, p := range paths { - b.WriteString(fmt.Sprintf("\t%s %q\n", imports[p], p)) + fmt.Fprintf(&b, "\t%s %q\n", imports[p], p) } b.WriteString(")\n\n") @@ -69,12 +69,12 @@ func renderZRefs(refs []exportedRef) string { for _, ref := range refs { switch ref.Kind { case "alias": - b.WriteString(fmt.Sprintf("// %s → %s\n", ref.Name, ref.TechnicalModel)) - b.WriteString(fmt.Sprintf("type %s = %s.%s\n\n", ref.Name, ref.ImportAlias, ref.SourceGoName)) + fmt.Fprintf(&b, "// %s → %s\n", ref.Name, ref.TechnicalModel) + fmt.Fprintf(&b, "type %s = %s.%s\n\n", ref.Name, ref.ImportAlias, ref.SourceGoName) case "phantom": - b.WriteString(fmt.Sprintf("// %s → %s\n", ref.Name, ref.TechnicalModel)) - b.WriteString(fmt.Sprintf("type %s struct {\n\tsdk.Model `sumeru:\"model=%s\"`\n}\n\n", - ref.Name, ref.TechnicalModel)) + fmt.Fprintf(&b, "// %s → %s\n", ref.Name, ref.TechnicalModel) + fmt.Fprintf(&b, "type %s struct {\n\tsdk.Model `sumeru:\"model=%s\"`\n}\n\n", + ref.Name, ref.TechnicalModel) } } return b.String() diff --git a/core/importgen/zrefs_scan.go b/core/importgen/zrefs_scan.go index 7a6fa8ad..e959f82d 100644 --- a/core/importgen/zrefs_scan.go +++ b/core/importgen/zrefs_scan.go @@ -85,7 +85,7 @@ func scanUsedRelationTypes(modelsDir string) (map[string]struct{}, error) { } fset := token.NewFileSet() - pkgs, err := parser.ParseDir(fset, modelsDir, isSourceGo, 0) + pkgs, err := parser.ParseDir(fset, modelsDir, isSourceGo, 0) //nolint:staticcheck // SA1019: ParseDir adequate for model tag scan; go/packages migration is separate if err != nil { return nil, err } diff --git a/core/modelreg/activate.go b/core/modelreg/activate.go index 040d60e3..da10b1f9 100644 --- a/core/modelreg/activate.go +++ b/core/modelreg/activate.go @@ -1,7 +1,6 @@ package modelreg import ( - "fmt" "sort" "sync" @@ -94,9 +93,3 @@ func moduleOrderForActivation(moduleOrder []string) []string { sort.Strings(extras) return append(order, extras...) } - -func mustActivateForTest() { - if err := ActivateAll(nil); err != nil { - panic(fmt.Sprintf("modelreg.ActivateAll: %v", err)) - } -} diff --git a/core/orm/computed_store.go b/core/orm/computed_store.go index 86d9e815..ac33479b 100644 --- a/core/orm/computed_store.go +++ b/core/orm/computed_store.go @@ -95,16 +95,6 @@ func topoSort(fields []string, deps map[string][]string) []string { return out } -func storedComputeFields(model Model) []string { - var names []string - for _, f := range model.Fields() { - if f.Compute != "" && f.ComputeStore { - names = append(names, f.Name) - } - } - return names -} - // RejectVirtualWrites returns an error if values touch virtual or readonly-compute fields. func RejectVirtualWrites(model Model, values map[string]interface{}) error { if model == nil || len(values) == 0 { diff --git a/core/orm/crud_insert.go b/core/orm/crud_insert.go index cc617cb4..bd3d8356 100644 --- a/core/orm/crud_insert.go +++ b/core/orm/crud_insert.go @@ -34,7 +34,10 @@ func Upsert(ctx context.Context, model Model, values map[string]interface{}, con if err := CheckModelAccess(ctx, uid, model.ModelName(), "create"); err != nil { return 0, err } - prepared, err := PrepareValues(model, values, WriteOpCreate, PrepareOptions{StrictUnknown: !SecurityBypass(ctx)}) + prepared, err := PrepareValues(model, values, WriteOpCreate, PrepareOptions{ + StrictUnknown: !SecurityBypass(ctx), + AllowPasswordHash: passwordHashWriteAllowed(ctx), + }) if err != nil { return 0, err } diff --git a/core/orm/crud_mutate.go b/core/orm/crud_mutate.go index 7d612b2b..01ed8892 100644 --- a/core/orm/crud_mutate.go +++ b/core/orm/crud_mutate.go @@ -19,14 +19,6 @@ func runMutationTx(ctx context.Context, fn func(tx TxWrapper) error) error { return tx.Commit() } -type mutationKind string - -const ( - mutationCreate mutationKind = "create" - mutationUpdate mutationKind = "update" - mutationDelete mutationKind = "delete" -) - type mutationResult struct { ID int RowsAffected int64 @@ -84,7 +76,10 @@ func executeUpdateMutation(ctx context.Context, modelName string, domain [][]int if err != nil { return result, err } - prepared, err := PrepareValues(inst, values, WriteOpWrite, PrepareOptions{StrictUnknown: false}) + prepared, err := PrepareValues(inst, values, WriteOpWrite, PrepareOptions{ + StrictUnknown: false, + AllowPasswordHash: passwordHashWriteAllowed(ctx), + }) if err != nil { return result, err } diff --git a/core/orm/crud_prepare.go b/core/orm/crud_prepare.go index e4bed8ff..f70fca90 100644 --- a/core/orm/crud_prepare.go +++ b/core/orm/crud_prepare.go @@ -13,6 +13,7 @@ func prepareCreateWrite(ctx context.Context, model Model, values map[string]inte if err := RejectVirtualWrites(model, values); err != nil { return nil, 0, err } + opts.AllowPasswordHash = opts.AllowPasswordHash || passwordHashWriteAllowed(ctx) prepared, err = PrepareValues(model, values, WriteOpCreate, opts) if err != nil { return nil, 0, err diff --git a/core/orm/crud_search.go b/core/orm/crud_search.go index f06e8dc7..aa4204a9 100644 --- a/core/orm/crud_search.go +++ b/core/orm/crud_search.go @@ -54,10 +54,13 @@ func execSearchQuery(ctx context.Context, modelName string, domain [][]interface if err != nil { return nil, err } - enrichRecordForRead(ctx, uid, modelName, recordMap) results = append(results, recordMap) } - return results, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + enrichRecordsForRead(ctx, uid, modelName, results) + return results, nil } func prepareSearchRead(ctx context.Context, modelName string, domain [][]interface{}) (uid int, whereClause string, args []interface{}, domainOut [][]interface{}, err error) { diff --git a/core/orm/crud_tx.go b/core/orm/crud_tx.go index 0a1f9fb0..3bd74f09 100644 --- a/core/orm/crud_tx.go +++ b/core/orm/crud_tx.go @@ -26,7 +26,10 @@ func insertPreparedOnTx(ctx context.Context, tx TxWrapper, model Model, prepared // insertRawOnTx inserts side-effect rows without ACL checks (caller must pass bypass ctx). func insertRawOnTx(ctx context.Context, tx TxWrapper, model Model, values map[string]interface{}) (int, error) { - prepared, err := PrepareValues(model, values, WriteOpCreate, PrepareOptions{StrictUnknown: false}) + prepared, err := PrepareValues(model, values, WriteOpCreate, PrepareOptions{ + StrictUnknown: false, + AllowPasswordHash: passwordHashWriteAllowed(ctx), + }) if err != nil { return 0, err } @@ -36,14 +39,6 @@ func insertRawOnTx(ctx context.Context, tx TxWrapper, model Model, values map[st return insertPreparedOnTx(ctx, tx, model, prepared) } -func createOnTx(ctx context.Context, tx TxWrapper, model Model, values map[string]interface{}) (id int, err error) { - prepared, _, err := prepareCreateWrite(ctx, model, values, PrepareOptions{StrictUnknown: true}) - if err != nil { - return 0, err - } - return insertPreparedOnTx(ctx, tx, model, prepared) -} - // execSideEffectOnTx runs fn on tx without aborting the parent transaction on failure. func execSideEffectOnTx(ctx context.Context, tx TxWrapper, modelName, operation string, fn func() error) { if tx == nil { diff --git a/core/orm/field_access.go b/core/orm/field_access.go index 850baa96..fedbe5c6 100644 --- a/core/orm/field_access.go +++ b/core/orm/field_access.go @@ -79,6 +79,9 @@ func fieldAccessDenied(ctx context.Context, uid int, model, op string) (map[stri } } out, err = applyGroupsFieldDenial(ctx, uid, model, op, out) + if err != nil { + return out, err + } return out, rows.Err() } diff --git a/core/orm/field_read.go b/core/orm/field_read.go index 02307667..134bfba3 100644 --- a/core/orm/field_read.go +++ b/core/orm/field_read.go @@ -57,5 +57,17 @@ func RedactSearchResults(ctx context.Context, uid int, model string, rows []map[ func enrichRecordForRead(ctx context.Context, uid int, modelName string, record map[string]interface{}) { RedactRecordForRead(ctx, uid, modelName, record) _ = ApplyComputes(ctx, modelName, record) - _ = ApplyRelatedFields(ctx, modelName, record) + if !skipRelatedEnrichment(ctx) { + _ = ApplyRelatedFields(ctx, modelName, record) + } +} + +func enrichRecordsForRead(ctx context.Context, uid int, modelName string, records []map[string]interface{}) { + for _, record := range records { + RedactRecordForRead(ctx, uid, modelName, record) + _ = ApplyComputes(ctx, modelName, record) + } + if !skipRelatedEnrichment(ctx) { + _ = ApplyRelatedFieldsBatch(ctx, modelName, records) + } } diff --git a/core/orm/related.go b/core/orm/related.go index 1c6ae13b..5c292549 100644 --- a/core/orm/related.go +++ b/core/orm/related.go @@ -6,52 +6,121 @@ import ( "strings" ) +type ctxKeySkipRelated struct{} + +// ContextSkipRelatedEnrichment prevents ApplyRelatedFields during nested Search used for batching. +func ContextSkipRelatedEnrichment(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeySkipRelated{}, true) +} + +func skipRelatedEnrichment(ctx context.Context) bool { + v, _ := ctx.Value(ctxKeySkipRelated{}).(bool) + return v +} + // ApplyRelatedFields fills virtual related fields on rec (in place). func ApplyRelatedFields(ctx context.Context, model string, rec map[string]interface{}) error { if rec == nil { return nil } + return ApplyRelatedFieldsBatch(ctx, model, []map[string]interface{}{rec}) +} + +// ApplyRelatedFieldsBatch resolves related fields for many rows with one Search per relation hop. +func ApplyRelatedFieldsBatch(ctx context.Context, model string, records []map[string]interface{}) error { + if len(records) == 0 { + return nil + } inst, ok := Registry[model] if !ok || inst == nil { return nil } + type relatedSpec struct { + fieldName string + relField string + targetField string + relation string + } + var specs []relatedSpec for _, fieldDef := range inst.Fields() { if fieldDef.Related == "" || fieldDef.RelatedStore { continue } - value, err := resolveRelatedValue(ctx, model, rec, fieldDef.Related) - if err != nil { - return fmt.Errorf("related %s.%s: %w", model, fieldDef.Name, err) + parts := strings.Split(fieldDef.Related, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid related path %q", fieldDef.Related) } - rec[fieldDef.Name] = value - } - return nil -} - -func resolveRelatedValue(ctx context.Context, model string, rec map[string]interface{}, path string) (interface{}, error) { - parts := strings.Split(path, ".") - if len(parts) < 2 { - return nil, fmt.Errorf("invalid related path %q", path) - } - relField := strings.TrimSpace(parts[0]) - targetField := strings.TrimSpace(parts[1]) - if relField == "" || targetField == "" { - return nil, fmt.Errorf("invalid related path %q", path) + relField := strings.TrimSpace(parts[0]) + targetField := strings.TrimSpace(parts[1]) + relationFieldDef := FieldDef(model, relField) + if relationFieldDef == nil || relationFieldDef.Relation == "" { + return fmt.Errorf("relation field %q not found on %s", relField, model) + } + specs = append(specs, relatedSpec{ + fieldName: fieldDef.Name, + relField: relField, + targetField: targetField, + relation: relationFieldDef.Relation, + }) } - relID, ok := CoerceInt64(rec[relField]) - if !ok || relID <= 0 { - return nil, nil + if len(specs) == 0 { + return nil } - relationFieldDef := FieldDef(model, relField) - if relationFieldDef == nil || relationFieldDef.Relation == "" { - return nil, fmt.Errorf("relation field %q not found on %s", relField, model) + + // relation model -> ids to fetch + idsByRelation := map[string]map[int64]struct{}{} + for _, spec := range specs { + set := idsByRelation[spec.relation] + if set == nil { + set = map[int64]struct{}{} + idsByRelation[spec.relation] = set + } + for _, rec := range records { + if id, ok := CoerceInt64(rec[spec.relField]); ok && id > 0 { + set[id] = struct{}{} + } + } } - target, err := SearchOne(ctx, relationFieldDef.Relation, map[string]interface{}{"id": int(relID)}) - if err != nil { - return nil, err + + ctx = ContextSkipRelatedEnrichment(ctx) + cacheByRelation := map[string]map[int64]map[string]interface{}{} + for relation, idSet := range idsByRelation { + if len(idSet) == 0 { + continue + } + values := make([]interface{}, 0, len(idSet)) + for id := range idSet { + values = append(values, int(id)) + } + rows, err := Search(ctx, relation, [][]interface{}{{"id", "in", values}}) + if err != nil { + return err + } + byID := make(map[int64]map[string]interface{}, len(rows)) + for _, row := range rows { + id, ok := CoerceInt64(row["id"]) + if !ok { + continue + } + byID[id] = row + } + cacheByRelation[relation] = byID } - if target == nil { - return nil, nil + + for _, rec := range records { + for _, spec := range specs { + relID, ok := CoerceInt64(rec[spec.relField]) + if !ok || relID <= 0 { + rec[spec.fieldName] = nil + continue + } + target := cacheByRelation[spec.relation][relID] + if target == nil { + rec[spec.fieldName] = nil + continue + } + rec[spec.fieldName] = target[spec.targetField] + } } - return target[targetField], nil + return nil } diff --git a/core/orm/schema_sync.go b/core/orm/schema_sync.go index 4539dd9e..8843e178 100644 --- a/core/orm/schema_sync.go +++ b/core/orm/schema_sync.go @@ -228,7 +228,7 @@ func ensureModelIndexes(ctx context.Context, tbl schemaTable) error { if IsVirtualField(field) { continue } - if !(field.Index || field.Type == Many2One) { + if !field.Index && field.Type != Many2One { continue } colQuoted, err := QuotedColumnForModel(tbl.ModelName, field.Name) diff --git a/core/orm/user_password.go b/core/orm/user_password.go new file mode 100644 index 00000000..4862232c --- /dev/null +++ b/core/orm/user_password.go @@ -0,0 +1,64 @@ +package orm + +import ( + "context" + "fmt" + "strings" + + "sumeru/core/applog" + + "golang.org/x/crypto/bcrypt" +) + +type ctxKeyAllowPasswordHash struct{} + +// ContextAllowPasswordHashWrite marks ctx so PrepareValues may accept core.user.password +// (bcrypt hash only). Used exclusively by SetUserPassword / SetUserPasswordHash. +func ContextAllowPasswordHashWrite(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeyAllowPasswordHash{}, true) +} + +func passwordHashWriteAllowed(ctx context.Context) bool { + v, _ := ctx.Value(ctxKeyAllowPasswordHash{}).(bool) + return v +} + +// SetUserPassword hashes plain under policy and stores it. Requires system admin. +func SetUserPassword(ctx context.Context, actor, userID int, plain string) error { + if userID <= 0 { + return fmt.Errorf("invalid user id") + } + if actor <= 0 { + return fmt.Errorf("unauthenticated") + } + if !UserHasGroupXML(ctx, actor, "base.group_system") { + applog.WarnMsg(ctx, "orm", "user_password", "deny password change: actor not system admin", nil, + map[string]interface{}{"user_id": userID, "actor": actor}) + return fmt.Errorf("password change requires system administrator") + } + plain = strings.TrimSpace(plain) + if plain == "" { + return fmt.Errorf("password required") + } + if err := ValidatePasswordPolicy(plain); err != nil { + return err + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + return SetUserPasswordHash(ctx, userID, string(hash)) +} + +// SetUserPasswordHash stores a pre-computed bcrypt hash (internal / trusted callers only). +func SetUserPasswordHash(ctx context.Context, userID int, hash string) error { + if userID <= 0 { + return fmt.Errorf("invalid user id") + } + hash = strings.TrimSpace(hash) + if hash == "" { + return fmt.Errorf("password hash required") + } + ctx = ContextAllowPasswordHashWrite(ctx) + return UpdateRecordByID(ctx, "core.user", userID, map[string]interface{}{"password": hash}) +} diff --git a/core/orm/user_security_post.go b/core/orm/user_security_post.go index 23558706..0b785630 100644 --- a/core/orm/user_security_post.go +++ b/core/orm/user_security_post.go @@ -7,8 +7,6 @@ import ( "strings" "sumeru/core/applog" - - "golang.org/x/crypto/bcrypt" ) // ApplyUserSecurityPost applies core.user security side effects from a form POST @@ -62,11 +60,6 @@ func ApplyUserSecurityPost(ctx context.Context, actor, userID int, form url.Valu } } if _, ok := form["password_plain"]; ok { - if !UserHasGroupXML(ctx, actor, "base.group_system") { - applog.WarnMsg(ctx, "orm", "user_security", "deny password change: actor not system admin", nil, - map[string]interface{}{"user_id": userID, "actor": actor}) - return - } if pw := strings.TrimSpace(form.Get("password_plain")); pw != "" { confirm := strings.TrimSpace(form.Get("password_plain_confirm")) if pw != confirm { @@ -74,16 +67,7 @@ func ApplyUserSecurityPost(ctx context.Context, actor, userID int, form url.Valu map[string]interface{}{"user_id": userID}) return } - if err := ValidatePasswordPolicy(pw); err != nil { - applog.WarnMsg(ctx, "orm", "user_security", "password policy rejected", err, nil) - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) - if err != nil { - applog.WarnMsg(ctx, "orm", "user_security", "bcrypt failed", err, nil) - return - } - if err := UpdateRecordByID(ctx, "core.user", userID, map[string]interface{}{"password": string(hash)}); err != nil { + if err := SetUserPassword(ctx, actor, userID, pw); err != nil { applog.WarnMsg(ctx, "orm", "user_security", "password update failed", err, map[string]interface{}{"user_id": userID}) } diff --git a/core/orm/values_prepare.go b/core/orm/values_prepare.go index 35db0e44..8fbc887b 100644 --- a/core/orm/values_prepare.go +++ b/core/orm/values_prepare.go @@ -20,6 +20,8 @@ type PrepareOptions struct { // StrictUnknown rejects undeclared field keys. When false, unknown keys are dropped silently // (historical Update behavior). Create uses StrictUnknown=true. StrictUnknown bool + // AllowPasswordHash permits writing core.user.password (bcrypt hash). Only SetUserPassword* sets this via context. + AllowPasswordHash bool } // PrepareValues whitelists model fields, coerces types, and validates required fields on create. @@ -38,6 +40,9 @@ func PrepareValues(model Model, values map[string]interface{}, op WriteOp, opts if k == "id" { continue } + if model.ModelName() == "core.user" && k == "password" && !opts.AllowPasswordHash { + return nil, fmt.Errorf("password cannot be set directly; use the password change API") + } fieldDef, ok := fieldDefs[k] if !ok { if opts.StrictUnknown { diff --git a/core/report/export_xlsx.go b/core/report/export_xlsx.go index b1fe907c..6343e67a 100644 --- a/core/report/export_xlsx.go +++ b/core/report/export_xlsx.go @@ -92,11 +92,11 @@ func sheetXML(header []string, rows [][]string) string { } func writeSheetRow(b *strings.Builder, rowNum int, cells []string) { - b.WriteString(fmt.Sprintf(``, rowNum)) + fmt.Fprintf(b, ``, rowNum) for col, val := range cells { cellRef := cellRef(col, rowNum) esc := escapeOOXMLCell(sanitizeSpreadsheetCell(val)) - b.WriteString(fmt.Sprintf(`%s`, cellRef, esc)) + fmt.Fprintf(b, `%s`, cellRef, esc) } b.WriteString(``) } diff --git a/core/server/api/method_read.go b/core/server/api/method_read.go index 4a0a73ac..704d8f95 100644 --- a/core/server/api/method_read.go +++ b/core/server/api/method_read.go @@ -2,7 +2,6 @@ package api import ( "context" - "database/sql" "encoding/json" "fmt" @@ -36,16 +35,32 @@ func rpcRead(ctx context.Context, model string, args json.RawMessage) (interface return nil, err } } - var out []map[string]interface{} + if len(ids) == 0 { + return []map[string]interface{}{}, nil + } + idValues := make([]interface{}, len(ids)) + for i, id := range ids { + idValues[i] = id + } + rows, err := orm.Search(ctx, model, [][]interface{}{{"id", "in", idValues}}) + if err != nil { + return nil, err + } + byID := make(map[int]map[string]interface{}, len(rows)) + for _, row := range rows { + id, ok := orm.CoerceInt64(row["id"]) + if !ok { + continue + } + byID[int(id)] = row + } var missing []int + out := make([]map[string]interface{}, 0, len(ids)) for _, id := range ids { - rec, err := orm.SearchOne(ctx, model, map[string]interface{}{"id": id}) - if err != nil { - if err == sql.ErrNoRows { - missing = append(missing, id) - continue - } - return nil, err + rec, ok := byID[id] + if !ok { + missing = append(missing, id) + continue } if len(fields) > 0 { out = append(out, projectFields([]map[string]interface{}{rec}, fields)[0]) diff --git a/core/server/config/config.go b/core/server/config/config.go index 9ed0c814..44b5c37d 100644 --- a/core/server/config/config.go +++ b/core/server/config/config.go @@ -45,6 +45,8 @@ type Config struct { DbReadReplicaDSN string // optional libpq DSN for read replica (search/read_group RPC) RateLimitRPM int // rate_limit_rpm per client IP on /api/rpc and login; 0 = disabled TrustedProxies string // trusted_proxies: comma-separated CIDRs/IPs allowed to set X-Forwarded-For; empty = never trust XFF + CSRFSecret string // csrf_secret: shared HMAC key for multi-instance; empty = ephemeral per process + MetricsScrapeToken string // metrics_scrape_token: Bearer token for unauthenticated /metrics scrape; empty = admin session only SMTPHost string SMTPPort int SMTPUser string @@ -177,6 +179,10 @@ func LoadConfig(path string) error { } case keyTrustedProxies: AppConfig.TrustedProxies = val + case keyCSRFSecret: + AppConfig.CSRFSecret = val + case keyMetricsScrapeToken: + AppConfig.MetricsScrapeToken = val case keySMTPHost: AppConfig.SMTPHost = val case keySMTPPort: @@ -196,10 +202,14 @@ func LoadConfig(path string) error { return err } + ApplyEnvOverrides(&AppConfig) + if err := validateRequired(&AppConfig, absPath); err != nil { return err } + ApplyProductionSecurityDefaults(&AppConfig) + // Default assets/templates paths are applied in AbsPaths() so sumeru_home can anchor // them under the standard tree; do not set repo-relative defaults here (would resolve // from the INI directory and break workspace configs next to ../sumeru). @@ -207,6 +217,64 @@ func LoadConfig(path string) error { return nil } +// ApplyEnvOverrides applies SUMERU_* environment variables over INI values (container secrets). +func ApplyEnvOverrides(c *Config) { + if c == nil { + return + } + if v := strings.TrimSpace(os.Getenv("SUMERU_DB_PASSWORD")); v != "" { + c.DbPass = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_DB_USER")); v != "" { + c.DbUser = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_DB_HOST")); v != "" { + c.DbHost = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_DB_NAME")); v != "" { + c.DbName = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_SETUP_TOKEN")); v != "" { + c.SetupToken = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_CSRF_SECRET")); v != "" { + c.CSRFSecret = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_METRICS_SCRAPE_TOKEN")); v != "" { + c.MetricsScrapeToken = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_SMTP_PASSWORD")); v != "" { + c.SMTPPassword = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_SMTP_HOST")); v != "" { + c.SMTPHost = v + } + if v := strings.TrimSpace(os.Getenv("SUMERU_SMTP_FROM")); v != "" { + c.SMTPFrom = v + } +} + +const defaultProdRateLimitRPM = 120 + +// ApplyProductionSecurityDefaults sets safe defaults when not in dev_mode and returns operator warnings. +func ApplyProductionSecurityDefaults(c *Config) []string { + if c == nil { + return nil + } + var warns []string + if !c.DevMode && c.RateLimitRPM == 0 { + c.RateLimitRPM = defaultProdRateLimitRPM + warns = append(warns, "rate_limit_rpm defaulted to 120 because dev_mode is false") + } + if c.DevMode { + warns = append(warns, "dev_mode=true: session cookies are not Secure; do not use in production") + if c.RateLimitRPM == 0 { + warns = append(warns, "rate_limit_rpm=0: login/RPC rate limiting disabled") + } + } + return warns +} + // parseBoolKey parses INI booleans; empty string returns defaultVal. func parseBoolKey(val string, defaultVal bool) bool { s := strings.TrimSpace(strings.ToLower(val)) diff --git a/core/server/config/const.go b/core/server/config/const.go index 6e08c644..eadb8a1d 100644 --- a/core/server/config/const.go +++ b/core/server/config/const.go @@ -43,6 +43,8 @@ const ( keyDbReadReplicaDSN = "db_read_replica_dsn" keyRateLimitRPM = "rate_limit_rpm" keyTrustedProxies = "trusted_proxies" + keyCSRFSecret = "csrf_secret" + keyMetricsScrapeToken = "metrics_scrape_token" keySMTPHost = "smtp_host" keySMTPPort = "smtp_port" keySMTPUser = "smtp_user" @@ -56,8 +58,6 @@ const ( ) const ( - fileGoMod = "go.mod" - segCore = "core" segEngine = "engine" segAssets = "assets" diff --git a/core/server/run.go b/core/server/run.go index d241c698..8d6bb2ed 100644 --- a/core/server/run.go +++ b/core/server/run.go @@ -57,6 +57,9 @@ func Run() { applog.RegisterUIDResolver(orm.UIDFromContext) applog.RegisterCompanyIDResolver(orm.CompanyIDFromContext) ctx := context.Background() + for _, w := range config.ApplyProductionSecurityDefaults(&config.AppConfig) { + applog.WarnMsg(ctx, "server", "config", w, nil, nil) + } if s := strings.TrimSpace(*dbNameLong); s != "" { config.AppConfig.DbName = s @@ -133,6 +136,8 @@ func Run() { registerBrandingAndStatic() registerSetupRoutes() + web.InitRateLimit() + web.InitCSRFSecret() listenHost := setupListenAddr(config.AppConfig) applog.InfoMsg(ctx, "server", "listen", "Server starting in setup mode", @@ -168,6 +173,7 @@ func Run() { registerBrandingAndStatic() registerAppRoutes() web.InitRateLimit() + web.InitCSRFSecret() if err := sdk.RunStartups(ctx); err != nil { applog.Fatal(ctx, "Startup hooks failed", "err", err) } diff --git a/core/server/web/apps_module_actions.go b/core/server/web/apps_module_actions.go index a0333a29..9d098582 100644 --- a/core/server/web/apps_module_actions.go +++ b/core/server/web/apps_module_actions.go @@ -2,7 +2,7 @@ package web import ( "context" - "fmt" + "errors" "net/http" "strconv" "strings" @@ -106,7 +106,7 @@ func runModuleLifecycleAction(ctx context.Context, action, moduleName string) (f } outcomeVerb = "activated" default: - return "", fmt.Errorf(moduleMsgUnknownAction) + return "", errors.New(moduleMsgUnknownAction) } if err = run(ctx, moduleName); err != nil { @@ -118,17 +118,17 @@ func runModuleLifecycleAction(ctx context.Context, action, moduleName string) (f func saveModuleFromForm(r *http.Request, moduleName string) error { recordID, err := strconv.Atoi(strings.TrimSpace(r.FormValue(moduleRowIDField))) if err != nil || recordID <= 0 { - return fmt.Errorf(moduleMsgInvalidModuleRow) + return errors.New(moduleMsgInvalidModuleRow) } row, err := orm.SearchOne(r.Context(), appsModuleModel, map[string]interface{}{"id": recordID}) if err != nil { - return fmt.Errorf(moduleMsgModuleNotFound) + return errors.New(moduleMsgModuleNotFound) } parsed, ok := parseModuleRow(row) if !ok || parsed.Name != moduleName { - return fmt.Errorf(moduleMsgModuleMismatch) + return errors.New(moduleMsgModuleMismatch) } return orm.UpdateRecordByID(r.Context(), appsModuleModel, recordID, map[string]interface{}{ diff --git a/core/server/web/auth.go b/core/server/web/auth.go index c1b80eb3..f2ee4789 100644 --- a/core/server/web/auth.go +++ b/core/server/web/auth.go @@ -83,6 +83,7 @@ func SecurityMiddleware(next http.Handler) http.Handler { start := time.Now() requestID := requestIDFromHeader(r) w.Header().Set(requestIDHeader, requestID) + setSecurityHeaders(w, r) ctx := enrichRequestContext(r, requestID) r = r.WithContext(ctx) @@ -96,6 +97,17 @@ func SecurityMiddleware(next http.Handler) http.Handler { }) } +func setSecurityHeaders(w http.ResponseWriter, _ *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("X-Frame-Options", "SAMEORIGIN") + h.Set("Content-Security-Policy", "frame-ancestors 'self'; base-uri 'self'; object-src 'none'") + if !config.AppConfig.DevMode { + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } +} + type statusRecorder struct { http.ResponseWriter status int @@ -180,7 +192,8 @@ func LogoutGet(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, loginRoute, http.StatusFound) } -// ActionResetPassword accepts a reset request from a system administrator (email delivery not yet wired). +// ActionNotifyLoginLink emails a login URL to the user (not a password-reset token flow). +// Prefer IdP / SSO or SetUserPassword for credential changes. func ActionResetPassword(w http.ResponseWriter, r *http.Request) { if !requireLoginAndPOST(w, r) { return @@ -198,13 +211,13 @@ func ActionResetPassword(w http.ResponseWriter, r *http.Request) { loginURL := loginRoute if mail.Configured() && to != "" { if err := mail.SendPasswordResetEmail(r.Context(), to, loginName, loginURL); err != nil { - WebLogf(r.Context(), resetPasswordRoute, "email failed for user id=%s: %v", userID, err) + WebLogf(r.Context(), resetPasswordRoute, "login-link email failed for user id=%s: %v", userID, err) } else { - WebLogf(r.Context(), resetPasswordRoute, "reset email sent for user id=%s login=%q", userID, loginName) + WebLogf(r.Context(), resetPasswordRoute, "login-link email sent for user id=%s login=%q", userID, loginName) } } else { WebLogf(r.Context(), resetPasswordRoute, - "requested for user id=%s login=%q (configure smtp_host/smtp_from to send email)", userID, loginName) + "login-link notify for user id=%s login=%q (configure smtp_host/smtp_from to send email; this does not reset passwords)", userID, loginName) } redirectWithWebMessage(w, r, r.PostFormValue(nextField), resetPasswordMsg) } diff --git a/core/server/web/auth_session.go b/core/server/web/auth_session.go index 05542111..c9257a89 100644 --- a/core/server/web/auth_session.go +++ b/core/server/web/auth_session.go @@ -13,6 +13,7 @@ import ( const sessionCookieName = "sumeru_session" const sessionDuration = 7 * 24 * time.Hour +const sessionSlidingTTL = 24 * time.Hour var testSessionUserIDOverride int @@ -80,6 +81,12 @@ func SessionUserID(r *http.Request) int { if err != nil { return 0 } + // Sliding idle expiry (DB-backed; works across instances). + _, _ = orm.DB.Exec( + `UPDATE `+sessionTable+` SET expires_at = $1 WHERE sid = $2 AND expires_at > NOW()`, + time.Now().UTC().Add(sessionSlidingTTL), + cookie.Value, + ) return userID } diff --git a/core/server/web/csrf.go b/core/server/web/csrf.go index 4fc26542..32a5e9d2 100644 --- a/core/server/web/csrf.go +++ b/core/server/web/csrf.go @@ -6,7 +6,10 @@ import ( "crypto/sha256" "encoding/hex" "net/http" + "strings" "sync" + + "sumeru/core/server/config" ) const ( @@ -30,15 +33,28 @@ func csrfKey() []byte { csrfSecretMu.Lock() defer csrfSecretMu.Unlock() if len(csrfSecret) == 0 { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - panic("csrf: crypto/rand failed: " + err.Error()) + if configured := strings.TrimSpace(config.AppConfig.CSRFSecret); configured != "" { + csrfSecret = []byte(configured) + } else { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("csrf: crypto/rand failed: " + err.Error()) + } + csrfSecret = b } - csrfSecret = b } return csrfSecret } +// InitCSRFSecret loads csrf_secret from config (call after LoadConfig). Empty keeps ephemeral key. +func InitCSRFSecret() { + csrfSecretMu.Lock() + defer csrfSecretMu.Unlock() + if configured := strings.TrimSpace(config.AppConfig.CSRFSecret); configured != "" { + csrfSecret = []byte(configured) + } +} + func sessionIDFromRequest(r *http.Request) string { cookie, err := r.Cookie(sessionCookieName) if err != nil || cookie.Value == "" { @@ -58,7 +74,7 @@ func CSRFTokenForRequest(r *http.Request) string { return hex.EncodeToString(mac.Sum(nil)[:16]) } -// ValidateCSRF checks the csrf_token form field or X-CSRF-Token header against the session-bound token. +// ValidateCSRF checks the csrf_token form field, query param, or X-CSRF-Token header against the session-bound token. func ValidateCSRF(r *http.Request) bool { expected := CSRFTokenForRequest(r) if expected == "" { @@ -68,5 +84,8 @@ func ValidateCSRF(r *http.Request) bool { if got == "" { got = r.Header.Get(csrfHeaderName) } + if got == "" { + got = strings.TrimSpace(r.URL.Query().Get(csrfFormField)) + } return got != "" && hmac.Equal([]byte(got), []byte(expected)) } diff --git a/core/server/web/export_handlers.go b/core/server/web/export_handlers.go index eb754610..92e6ced8 100644 --- a/core/server/web/export_handlers.go +++ b/core/server/web/export_handlers.go @@ -11,6 +11,9 @@ import ( ) func resolveExportRequest(w http.ResponseWriter, r *http.Request) (report.ExportCSVInput, bool) { + if !validateSessionCSRF(w, r) { + return report.ExportCSVInput{}, false + } modelName := strings.TrimSpace(r.URL.Query().Get(importModelField)) if modelName == "" { http.Error(w, "model required", http.StatusBadRequest) diff --git a/core/server/web/import_csv.go b/core/server/web/import_csv.go index a54fd6af..ecda17ce 100644 --- a/core/server/web/import_csv.go +++ b/core/server/web/import_csv.go @@ -1,15 +1,9 @@ package web import ( - "context" - "encoding/csv" - "fmt" - "io" "net/http" "strconv" "strings" - - "sumeru/core/orm" ) // ImportCSVHandler imports CSV rows — redirects to bulk upload staging flow. @@ -17,96 +11,10 @@ func ImportCSVHandler(w http.ResponseWriter, r *http.Request) { BulkUploadHandler(w, r) } -type importCSVRequest struct { - modelInst orm.Model - file io.ReadCloser - next string -} - -func openImportCSVRequest(w http.ResponseWriter, r *http.Request) (importCSVRequest, bool) { - modelName := strings.TrimSpace(r.FormValue(importModelField)) - if modelName == "" { - http.Error(w, "model required", http.StatusBadRequest) - return importCSVRequest{}, false - } - - modelInst, ok := requireRegisteredModel(w, modelName) - if !ok { - return importCSVRequest{}, false - } - if !requireModelAccess(w, r, modelName, "create") { - return importCSVRequest{}, false - } - - upload, _, err := r.FormFile(importFileField) - if err != nil { - http.Error(w, "file required", http.StatusBadRequest) - return importCSVRequest{}, false - } - - return importCSVRequest{ - modelInst: modelInst, - file: upload, - next: r.FormValue(nextField), - }, true -} - func importCSVFlashMessage(createdCount int) string { return "imported_" + strconv.Itoa(createdCount) } -func importCSVRows(ctx context.Context, modelInst orm.Model, file io.Reader) (int, error) { - reader := csv.NewReader(file) - reader.TrimLeadingSpace = true - - header, err := reader.Read() - if err != nil { - return 0, fmt.Errorf("empty csv") - } - normalizeCSVHeader(header) - - allowedFields := allowedImportFieldNames(modelInst) - createdCount := 0 - - for { - record, err := reader.Read() - if err == io.EOF { - break - } - if err != nil { - return createdCount, fmt.Errorf("csv error after %d rows: %v", createdCount, err) - } - - values := importableRowValues(header, record, allowedFields) - if len(values) == 0 { - continue - } - if _, err := orm.Create(ctx, modelInst, values); err != nil { - return createdCount, fmt.Errorf("row %d: %v", createdCount+1, err) - } - createdCount++ - } - - return createdCount, nil -} - -func normalizeCSVHeader(header []string) { - for i := range header { - header[i] = strings.TrimSpace(header[i]) - } -} - -func allowedImportFieldNames(modelInst orm.Model) map[string]struct{} { - allowed := make(map[string]struct{}) - for _, field := range modelInst.Fields() { - if field.Name == "" || field.Name == workspaceRecordIDParam { - continue - } - allowed[field.Name] = struct{}{} - } - return allowed -} - func importableRowValues(header, record []string, allowedFields map[string]struct{}) map[string]interface{} { values := map[string]interface{}{} for columnIndex, columnName := range header { diff --git a/core/server/web/metrics.go b/core/server/web/metrics.go index ae3469a9..dd04324b 100644 --- a/core/server/web/metrics.go +++ b/core/server/web/metrics.go @@ -1,14 +1,27 @@ package web import ( + "crypto/subtle" "net/http" + "strings" "sumeru/core/metrics" + "sumeru/core/server/config" ) -// MetricsHandler exposes Prometheus text metrics to session users in base.group_system. -// Access is enforced by requireSystemAdmin (groupSystemXML). +// MetricsHandler exposes Prometheus text metrics. +// Auth: Bearer metrics_scrape_token when configured, otherwise system-admin session. func MetricsHandler(w http.ResponseWriter, r *http.Request) { + token := strings.TrimSpace(config.AppConfig.MetricsScrapeToken) + if token != "" { + got := bearerToken(r.Header.Get(authHeader)) + if subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + metrics.Handler(w, r) + return + } if !requireSystemAdmin(w, r, false) { return } diff --git a/core/server/web/page_flash.go b/core/server/web/page_flash.go index 1c0f148c..c6035dbb 100644 --- a/core/server/web/page_flash.go +++ b/core/server/web/page_flash.go @@ -6,11 +6,11 @@ import ( // PageFlash is a one-time user-visible banner after redirect. type PageFlash struct { - Kind string // success, info, warning, error - Title string - Body string - Details string // optional technical details for error flashes - FieldErrors []string + Kind string `json:"kind"` // success, info, warning, error + Title string `json:"title"` + Body string `json:"body"` + Details string `json:"details,omitempty"` // optional technical details for error flashes + FieldErrors []string `json:"field_errors,omitempty"` } // ConsumePageFlashes reads and clears one-time flash data (cookies). diff --git a/core/server/web/record_error_flash.go b/core/server/web/record_error_flash.go index c3a82eaf..7d438392 100644 --- a/core/server/web/record_error_flash.go +++ b/core/server/web/record_error_flash.go @@ -1,7 +1,6 @@ package web import ( - "context" "errors" "fmt" "net/http" @@ -164,14 +163,6 @@ func appendFieldErrorsToURL(rawURL string, fieldErrors []string) string { return parsed.String() } -func redirectRecordSuccess(w http.ResponseWriter, r *http.Request, nextURL, msg string) { - redirectURL, err := urlWithQueryParam(SafeWebNext(nextURL, homeRoute), flashMessageParam, msg) - if err != nil { - redirectURL = SafeWebNext(nextURL, homeRoute) + "?" + flashMessageParam + "=" + url.QueryEscape(msg) - } - http.Redirect(w, r, redirectURL, http.StatusSeeOther) -} - func operationRoute(operation string) string { switch operation { case "record_save", "record_delete": @@ -199,25 +190,3 @@ func ensureFormEditRedirectURL(rawNext string, clearRecordID bool) string { parsed.RawQuery = query.Encode() return parsed.String() } - -func applyCreateOwnershipDefaults(ctx context.Context, modelInst orm.Model, values map[string]interface{}) { - if values == nil || modelInst == nil { - return - } - uid := orm.SecurityUID(ctx) - if uid <= 0 { - return - } - for _, field := range modelInst.Fields() { - if field.Name != "user_id" || field.Type != orm.Many2One { - continue - } - if existing, ok := values["user_id"]; ok && existing != nil { - if id, ok := orm.CoerceInt64(existing); ok && id > 0 { - continue - } - } - values["user_id"] = uid - return - } -} diff --git a/core/server/web/record_error_flash_cookie.go b/core/server/web/record_error_flash_cookie.go index ac1de00f..6a9be31a 100644 --- a/core/server/web/record_error_flash_cookie.go +++ b/core/server/web/record_error_flash_cookie.go @@ -31,13 +31,7 @@ func recordErrorFlashCookieAttrs() http.Cookie { // SetRecordErrorFlash stores a one-time error banner in an HttpOnly cookie. func SetRecordErrorFlash(w http.ResponseWriter, flash PageFlash) { - payload, err := json.Marshal(recordErrorFlashPayload{ - Kind: flash.Kind, - Title: flash.Title, - Body: flash.Body, - Details: flash.Details, - FieldErrors: flash.FieldErrors, - }) + payload, err := json.Marshal(recordErrorFlashPayload(flash)) if err != nil { return } @@ -68,11 +62,5 @@ func ConsumeRecordErrorFlash(r *http.Request, w http.ResponseWriter) (PageFlash, if payload.Kind == "" && payload.Body == "" && payload.Title == "" { return PageFlash{}, false } - return PageFlash{ - Kind: payload.Kind, - Title: payload.Title, - Body: payload.Body, - Details: payload.Details, - FieldErrors: payload.FieldErrors, - }, true + return PageFlash(payload), true } diff --git a/core/server/web/report_routes.go b/core/server/web/report_routes.go index dd7a2115..c6d048b4 100644 --- a/core/server/web/report_routes.go +++ b/core/server/web/report_routes.go @@ -42,6 +42,9 @@ func ExportPivotHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } + if !validateSessionCSRF(w, r) { + return + } modelName := strings.TrimSpace(r.URL.Query().Get(importModelField)) if modelName == "" { http.Error(w, "model required", http.StatusBadRequest) @@ -77,6 +80,9 @@ func ExportGraphHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } + if !validateSessionCSRF(w, r) { + return + } modelName := strings.TrimSpace(r.URL.Query().Get(importModelField)) if modelName == "" { http.Error(w, "model required", http.StatusBadRequest) diff --git a/core/server/web/routes_table.go b/core/server/web/routes_table.go index d3e8a1a6..48bdf72e 100644 --- a/core/server/web/routes_table.go +++ b/core/server/web/routes_table.go @@ -84,6 +84,7 @@ func registerSettingsRoutes() { func registerAPIRoutes() { registerPublic(http.MethodGet, apiHealthRoute, APIHealthHandler) + registerPublic(http.MethodGet, apiReadyRoute, APIReadyHandler) registerPublic(http.MethodPost, apiRPCRoute, RPCJSONHandler) } diff --git a/core/server/web/rpc_json.go b/core/server/web/rpc_json.go index 88861290..fe3cc909 100644 --- a/core/server/web/rpc_json.go +++ b/core/server/web/rpc_json.go @@ -14,7 +14,7 @@ import ( "sumeru/core/server/api" ) -// APIHealthHandler returns {"ok":true} for probes (no auth). +// APIHealthHandler is liveness: process is up (no dependency checks). func APIHealthHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -23,11 +23,28 @@ func APIHealthHandler(w http.ResponseWriter, r *http.Request) { writeJSONOK(w) } +// APIReadyHandler is readiness: PostgreSQL is reachable. +func APIReadyHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if orm.DB == nil { + api.WriteResponse(w, http.StatusServiceUnavailable, api.Fail(api.CodeInternalError, "database not initialized", nil)) + return + } + if err := orm.DB.Ping(); err != nil { + api.WriteResponse(w, http.StatusServiceUnavailable, api.Fail(api.CodeInternalError, "database unavailable", nil)) + return + } + writeJSONOK(w) +} + // RPCJSONHandler is model RPC: POST JSON {"model","method","args","kwargs"} with session or API key auth. func RPCJSONHandler(w http.ResponseWriter, r *http.Request) { start := time.Now() metrics.Inc(rpcMetricRequests) - defer metrics.ObserveDuration(rpcMetricDuration, time.Since(start)) + defer func() { metrics.ObserveDuration(rpcMetricDuration, time.Since(start)) }() if r.Method != http.MethodPost { api.WriteResponse(w, http.StatusMethodNotAllowed, api.Fail(api.CodeMethodNotAllowed, "Method not allowed", nil)) @@ -39,6 +56,12 @@ func RPCJSONHandler(w http.ResponseWriter, r *http.Request) { api.WriteResponse(w, http.StatusUnauthorized, api.Fail(api.CodeUnauthorized, "Unauthorized", nil)) return } + // Cookie sessions require CSRF; API keys are not browser cookie auth. + if SessionUserID(r) > 0 && !ValidateCSRF(r) { + metrics.Inc("sumeru_csrf_rejected_total") + api.WriteResponse(w, http.StatusForbidden, api.Fail(api.CodeAccessDenied, "Invalid CSRF token", nil)) + return + } if !acceptsJSONContentType(r.Header.Get("Content-Type")) { api.WriteResponse(w, http.StatusUnsupportedMediaType, api.Fail(api.CodeUnsupportedMediaType, "Content-Type must be application/json", nil)) diff --git a/core/server/web/setup_handlers.go b/core/server/web/setup_handlers.go index 312767fe..ef533358 100644 --- a/core/server/web/setup_handlers.go +++ b/core/server/web/setup_handlers.go @@ -67,7 +67,7 @@ func SetupInitHandler(w http.ResponseWriter, r *http.Request) { } scheduleSetupRestart() - fmt.Fprintln(w, setupCompleteMessage) + _, _ = fmt.Fprintln(w, setupCompleteMessage) } // SetupPageHandler renders the setup page from templates/setup.html. diff --git a/core/server/web/swc_saved_search.go b/core/server/web/swc_saved_search.go index 9448c50f..6fcf3755 100644 --- a/core/server/web/swc_saved_search.go +++ b/core/server/web/swc_saved_search.go @@ -30,7 +30,7 @@ func SwcSavedSearchesListHandler(w http.ResponseWriter, r *http.Request) { } func SwcSavedSearchSaveHandler(w http.ResponseWriter, r *http.Request) { - if !requireLogin(w, r) { + if !requireLoginJSONPost(w, r) { return } var body struct { @@ -70,6 +70,13 @@ func SwcSavedSearchDeleteHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } + if r.Method != http.MethodDelete { + http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed) + return + } + if !validateSessionCSRF(w, r) { + return + } id, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("id"))) if err != nil || id <= 0 { http.Error(w, "invalid id", http.StatusBadRequest) diff --git a/core/server/web/web_constants.go b/core/server/web/web_constants.go index 9b72f168..64476a1b 100644 --- a/core/server/web/web_constants.go +++ b/core/server/web/web_constants.go @@ -17,6 +17,7 @@ const ( settingsRoute = "/web/settings" appLogsRoute = "/web/settings/app-logs" apiHealthRoute = "/api/health" + apiReadyRoute = "/api/ready" apiRPCRoute = "/api/rpc" metricsRoute = "/metrics" setupRoute = "/setup" @@ -182,12 +183,6 @@ const ( moduleMsgModuleMismatch = "module_mismatch" ) -// Kanban move field names. -const ( - stageIDField = "stage_id" - dateLastStageUpdateField = "date_last_stage_update" -) - // Company switch form field. const companyIDFormField = "company_id" diff --git a/core/server/web/workspace_load.go b/core/server/web/workspace_load.go index 3770a703..d472727d 100644 --- a/core/server/web/workspace_load.go +++ b/core/server/web/workspace_load.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "sumeru/core/engine/parser" "sumeru/core/engine/render" ) @@ -32,14 +31,6 @@ type workspaceLoadInput struct { Req workspaceRequest } -type searchWorkspaceRowsInput struct { - workspaceLoadInput - View *parser.View - SearchQuery string - RowLimit int -} - - func buildViewRecordData(ctx context.Context, w http.ResponseWriter, r *http.Request, req workspaceRequest, resolved *resolvedWorkspaceView, actionData map[string]interface{}) (*render.ViewRecordData, error) { viewRecord := &render.ViewRecordData{ ActionID: req.actionID, diff --git a/core/server/web/workspace_request.go b/core/server/web/workspace_request.go index 02a8d8de..aeca77c8 100644 --- a/core/server/web/workspace_request.go +++ b/core/server/web/workspace_request.go @@ -204,7 +204,7 @@ func workspaceViewNotFoundError(targetModel string, modes []string, lastErr erro } func actionViewModesForTabs(actionData map[string]interface{}) []string { - if actionData == nil || len(actionData) == 0 { + if len(actionData) == 0 { return nil } modes := splitViewModes(strings.TrimSpace(orm.AsString(actionData["view_mode"]))) diff --git a/core/server/web/workspace_rows.go b/core/server/web/workspace_rows.go index 4b95321e..74fdc91d 100644 --- a/core/server/web/workspace_rows.go +++ b/core/server/web/workspace_rows.go @@ -214,24 +214,6 @@ func loadWorkspacePivotData(ctx context.Context, in workspaceLoadInput) error { return nil } -func searchWorkspaceRowsWithSearch(ctx context.Context, in searchWorkspaceRowsInput) ([]map[string]interface{}, error) { - domain := workspaceListDomain(ctx, listDomainInput{ - ActionData: in.ActionData, - View: in.View, - SearchView: nil, - SearchQuery: in.SearchQuery, - }) - return orm.SearchLimit(ctx, in.Resolved.targetModel, domain, in.RowLimit) -} - -func searchWorkspaceRows(ctx context.Context, in workspaceLoadInput, rowLimit int) ([]map[string]interface{}, error) { - return searchWorkspaceRowsWithSearch(ctx, searchWorkspaceRowsInput{ - workspaceLoadInput: in, - SearchQuery: "", - RowLimit: rowLimit, - }) -} - func loadSearchViewForAction(ctx context.Context, model string, actionData map[string]interface{}) *parser.View { if searchViewID := actionSearchViewIDFromContext(actionData); searchViewID != "" { if view := loadSearchViewByName(ctx, model, searchViewID); view != nil { diff --git a/core/swc/src/views/shared/view-toolbar.ts b/core/swc/src/views/shared/view-toolbar.ts index ca7c60df..604e9527 100644 --- a/core/swc/src/views/shared/view-toolbar.ts +++ b/core/swc/src/views/shared/view-toolbar.ts @@ -50,6 +50,7 @@ export function exportQuery( if (payload.listFilter) params.set("filter", payload.listFilter); if (payload.listDomain) params.set("domain", payload.listDomain); if (payload.listSearch) params.set("q", payload.listSearch); + if (payload.csrfToken) params.set("csrf_token", payload.csrfToken); return params; } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..c21611dc --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,36 @@ +# Single-node production-shaped stack for local/ops validation. +# Does not replace HA; see docs/ha-ops.md. + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: sumeru + POSTGRES_PASSWORD: ${SUMERU_DB_PASSWORD:-sumeru} + POSTGRES_DB: sumeru + volumes: + - sumeru_pg:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U sumeru -d sumeru"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: . + ports: + - "8080:8080" + environment: + SUMERU_DB_HOST: db + SUMERU_DB_USER: sumeru + SUMERU_DB_PASSWORD: ${SUMERU_DB_PASSWORD:-sumeru} + SUMERU_DB_NAME: sumeru + SUMERU_CSRF_SECRET: ${SUMERU_CSRF_SECRET:-change-me-in-prod} + SUMERU_METRICS_SCRAPE_TOKEN: ${SUMERU_METRICS_SCRAPE_TOKEN:-} + depends_on: + db: + condition: service_healthy + # Mount a real conf or edit the image default; db_host must be "db". + +volumes: + sumeru_pg: diff --git a/docs/ddl-policy.md b/docs/ddl-policy.md new file mode 100644 index 00000000..39ea33bc --- /dev/null +++ b/docs/ddl-policy.md @@ -0,0 +1,30 @@ +# Schema sync and DDL policy + +Sumeru materializes the ORM registry into PostgreSQL with **additive** schema sync (`core/orm/schema_sync.go`): + +- Creates missing tables and columns +- Ensures indexes, unique indexes, and foreign keys (best-effort) +- Does **not** drop or rename columns/tables automatically + +## Environments + +| Environment | Policy | +|-------------|--------| +| Fresh setup / CI | `SyncRegistrySchema` / module install sync is the source of truth | +| Shared staging / production | Treat sync as **forward-only**. Destructive changes require a reviewed SQL migration run book and backup | + +## Production rules + +1. **Backup** before any manual DDL or module upgrade that alters schema. +2. Prefer **additive** model changes (new columns nullable or with Go-side defaults). +3. For renames/drops: ship a documented SQL script, apply in a maintenance window, then update Go models to match. Do not rely on sync to reverse changes. +4. Module install/update (`-i` / `-u`) still runs scoped sync; review module diffs before production update. +5. Keep `db_sslmode` and pool settings production-appropriate (`sumeru.conf.example`). + +## Rollback + +Schema sync has no automatic rollback. Rollback = restore from backup (or reverse SQL you authored). Application binary rollback alone may fail if the DB already has newer columns (usually safe) or if you manually dropped required columns (unsafe). + +## Future + +A recorded migration ledger (versioned SQL + apply tracking) may replace ad-hoc scripts; until then this document is the operational contract. diff --git a/docs/ha-ops.md b/docs/ha-ops.md new file mode 100644 index 00000000..dc71a2b8 --- /dev/null +++ b/docs/ha-ops.md @@ -0,0 +1,21 @@ +# High availability operations (Phase 6) + +Sumeru sessions are **DB-backed** (`sys.session`), so sticky sessions are not required for auth. + +## Requirements before multi-instance + +1. **`csrf_secret` / `SUMERU_CSRF_SECRET`** — shared across all app processes (see `sumeru.conf.example`). +2. **Shared PostgreSQL** — one primary; optional `db_read_replica_dsn` for read RPCs. +3. **`metrics_scrape_token`** — scrape `/metrics` without an admin browser session. +4. **Probes** — liveness `GET /api/health`; readiness `GET /api/ready` (DB ping). +5. **Queue** — in-process pub/sub does not cross instances. Use `queue.SetPublishMirrorHook` (or an external broker) so outbox/bus events fan out; WebSocket bus remains per-instance unless a shared pub/sub is wired. + +## Rolling deploy + +1. Backup DB. +2. Apply additive schema / module updates (`docs/ddl-policy.md`). +3. Roll instances behind the load balancer; drain with SIGTERM (20s shutdown). + +## SSO / IdP + +Native SAML/OIDC is not shipped yet. See [sso.md](sso.md). Until then use strong passwords, system-admin gated password changes (`orm.SetUserPassword`), and the login-link notify action (not a tokenized reset). diff --git a/docs/ops-runbook.md b/docs/ops-runbook.md new file mode 100644 index 00000000..e4953051 --- /dev/null +++ b/docs/ops-runbook.md @@ -0,0 +1,24 @@ +# Operations runbook (single-node pilot) + +## Deploy + +1. Set secrets via env: `SUMERU_DB_PASSWORD`, `SUMERU_CSRF_SECRET`, optional `SUMERU_METRICS_SCRAPE_TOKEN`. +2. `dev_mode=false`, `rate_limit_rpm` ≥ 120 (auto-defaulted when not in dev). +3. Start: `./sumeru -c sumeru.conf` or `docker compose -f docker-compose.prod.yml up --build`. +4. Probes: `/api/health` (live), `/api/ready` (DB). + +## Backup / restore + +- Use `pg_dump` / `pg_restore` (or your managed Postgres backup). +- Restore DB before rolling back application binaries if schema advanced (see `ddl-policy.md`). + +## Incident + +- Revoke sessions: delete rows from `sys.session` or destroy cookie via logout. +- Rotate `csrf_secret` only with a full restart of all instances (invalidates CSRF tokens). +- Check `/metrics` (Bearer scrape token) and JSON logs (`request_id`). + +## Rollback + +1. Point LB to previous image/binary. +2. If DDL was applied, restore DB or run reverse SQL from the change ticket. diff --git a/docs/sso.md b/docs/sso.md new file mode 100644 index 00000000..99683bda --- /dev/null +++ b/docs/sso.md @@ -0,0 +1,16 @@ +# SSO / identity providers + +Sumeru does **not** yet include built-in SAML or OIDC login. + +## Current options + +- Local `core.user` passwords (bcrypt) via UI `password_plain` / `orm.SetUserPassword` +- API keys (`sk_…`) for automation +- Admin **login-link notify** email (`ActionResetPassword`) — sends a login URL only; it does **not** issue a password-reset token + +## Recommended enterprise path + +1. Terminate SSO at a reverse proxy or identity-aware proxy (e.g. OAuth2 proxy) and map the authenticated identity into Sumeru in a future bridge module, **or** +2. Implement OIDC authorization-code login as a `base` extension when product prioritizes it. + +Track status against the enterprise readiness DoD before removing the pre-alpha banner. diff --git a/go.mod b/go.mod index f4b174d5..7e9723e1 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,16 @@ module sumeru -go 1.26.2 +go 1.26.6 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/gorilla/websocket v1.5.3 - github.com/gpdf-dev/gpdf v1.0.11 + github.com/gpdf-dev/gpdf v1.0.12 github.com/lib/pq v1.12.3 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) -require github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect - -replace github.com/gpdf-dev/gpdf => github.com/ProjectMeru/gpdf v1.0.11 +replace github.com/gpdf-dev/gpdf => github.com/ProjectMeru/gpdf v1.0.12 replace github.com/gorilla/websocket => github.com/ProjectMeru/websocket v1.5.3 diff --git a/go.sum b/go.sum index c50cd694..c54b7d29 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/ProjectMeru/gpdf v1.0.11 h1:+LXvvaR3TnIA92KZy+L2IgehJISnhi0t2ufwJVr56ys= -github.com/ProjectMeru/gpdf v1.0.11/go.mod h1:QTnE2q2L+q9E1neWCZen/V/x9iLOuJ1jWHBtylimfAw= +github.com/ProjectMeru/gpdf v1.0.12 h1:gjuT+ofWcFc1RYQSC40e8pChXKyIjpDNoufXS3lZwNU= +github.com/ProjectMeru/gpdf v1.0.12/go.mod h1:QTnE2q2L+q9E1neWCZen/V/x9iLOuJ1jWHBtylimfAw= github.com/ProjectMeru/websocket v1.5.3 h1:X3/lutkNQab0fuf7FVRQ80mkazPleR372uVPUWd6HWw= github.com/ProjectMeru/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= diff --git a/sumeru.conf.example b/sumeru.conf.example index 41df536f..c9546765 100644 --- a/sumeru.conf.example +++ b/sumeru.conf.example @@ -14,7 +14,9 @@ db_sslmode = disable # HTTP (required) http_port = 8080 # http_interface = 127.0.0.1 -dev_mode = true +# Production: leave false (Secure cookies, rate limit defaults). Local UI: set true. +dev_mode = false +# dev_mode = true # Setup wizard — first run only. # When setup_localhost_only = false, setup_token must be non-empty. @@ -52,16 +54,29 @@ log_rolling = false # db_conn_max_lifetime_minutes = 30 # db_read_replica_dsn = host=replica dbname=sumeru user=postgres password=postgres sslmode=disable -# Rate limiting (optional; requests per minute per client IP on /api/rpc and /web/login) -# rate_limit_rpm = 120 +# Rate limiting (requests per minute per client IP on /api/rpc and /web/login). +# When dev_mode=false and this is unset/0, the server defaults to 120. +rate_limit_rpm = 120 # Comma-separated CIDRs/IPs of reverse proxies allowed to set X-Forwarded-For. # Empty (default) = never trust XFF; client IP is always RemoteAddr. # trusted_proxies = 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1 -# SMTP (optional — password reset and notifications) +# Shared CSRF HMAC secret (required for multi-instance). Env: SUMERU_CSRF_SECRET +# csrf_secret = + +# Bearer token for Prometheus scrape of /metrics (optional). Env: SUMERU_METRICS_SCRAPE_TOKEN +# Without this, /metrics requires a system-admin session. +# metrics_scrape_token = + +# SMTP (optional — login-link notify and notifications) # smtp_host = localhost # smtp_port = 587 # smtp_user = # smtp_password = # smtp_from = noreply@example.com + +# Secrets may also be supplied via environment (override INI): +# SUMERU_DB_PASSWORD, SUMERU_DB_USER, SUMERU_DB_HOST, SUMERU_DB_NAME +# SUMERU_SETUP_TOKEN, SUMERU_CSRF_SECRET, SUMERU_METRICS_SCRAPE_TOKEN +# SUMERU_SMTP_PASSWORD, SUMERU_SMTP_HOST, SUMERU_SMTP_FROM diff --git a/test/addons/automation/webhook_test.go b/test/addons/automation/webhook_test.go index 7703b79a..a97f9294 100644 --- a/test/addons/automation/webhook_test.go +++ b/test/addons/automation/webhook_test.go @@ -19,6 +19,7 @@ func TestValidateWebhookURL(t *testing.T) { {"http://169.254.169.254/latest/meta-data", true}, {"https://192.168.1.1/hook", true}, {"https://10.0.0.5/hook", true}, + {"http://100.64.0.1/hook", true}, {"https://8.8.8.8/hook", false}, } for _, tc := range cases { diff --git a/test/core/applog/applog_extended_test.go b/test/core/applog/applog_extended_test.go index 810be2c5..3060ae50 100644 --- a/test/core/applog/applog_extended_test.go +++ b/test/core/applog/applog_extended_test.go @@ -34,8 +34,8 @@ func TestRequestIDAndLoggerHelpers(t *testing.T) { if got := applog.RequestIDFromContext(ctx); got != id { t.Fatalf("got %q", got) } - if got := applog.RequestIDFromContext(nil); got != "" { - t.Fatal("nil ctx") + if got := applog.RequestIDFromContext(context.TODO()); got != "" { + t.Fatal("empty ctx") } } diff --git a/test/core/config/env_overrides_test.go b/test/core/config/env_overrides_test.go new file mode 100644 index 00000000..6d041dde --- /dev/null +++ b/test/core/config/env_overrides_test.go @@ -0,0 +1,20 @@ +package config_test + +import ( + "testing" + + "sumeru/core/server/config" +) + +func TestApplyEnvOverrides(t *testing.T) { + t.Setenv("SUMERU_DB_PASSWORD", "from-env") + t.Setenv("SUMERU_CSRF_SECRET", "csrf-from-env") + c := &config.Config{DbPass: "ini", CSRFSecret: ""} + config.ApplyEnvOverrides(c) + if c.DbPass != "from-env" { + t.Fatalf("DbPass=%q", c.DbPass) + } + if c.CSRFSecret != "csrf-from-env" { + t.Fatalf("CSRFSecret=%q", c.CSRFSecret) + } +} diff --git a/test/core/config/prod_defaults_test.go b/test/core/config/prod_defaults_test.go new file mode 100644 index 00000000..c197b31e --- /dev/null +++ b/test/core/config/prod_defaults_test.go @@ -0,0 +1,36 @@ +package config_test + +import ( + "testing" + + "sumeru/core/server/config" +) + +func TestApplyProductionSecurityDefaults_setsRateLimitWhenNotDev(t *testing.T) { + c := &config.Config{DevMode: false, RateLimitRPM: 0} + warns := config.ApplyProductionSecurityDefaults(c) + if c.RateLimitRPM != 120 { + t.Fatalf("RateLimitRPM=%d want 120", c.RateLimitRPM) + } + if len(warns) == 0 { + t.Fatal("expected warning about defaulted rate limit") + } +} + +func TestApplyProductionSecurityDefaults_keepsDevUnlimited(t *testing.T) { + c := &config.Config{DevMode: true, RateLimitRPM: 0} + warns := config.ApplyProductionSecurityDefaults(c) + if c.RateLimitRPM != 0 { + t.Fatalf("dev RateLimitRPM=%d want 0", c.RateLimitRPM) + } + found := false + for _, w := range warns { + if w != "" { + found = true + break + } + } + if !found { + t.Fatal("expected dev_mode warnings") + } +} diff --git a/test/core/mail/mail_test.go b/test/core/mail/mail_test.go index 28821632..d012d716 100644 --- a/test/core/mail/mail_test.go +++ b/test/core/mail/mail_test.go @@ -45,7 +45,7 @@ func TestListComments_nilDB(t *testing.T) { if err != nil { t.Fatal(err) } - if rows != nil && len(rows) != 0 { + if len(rows) != 0 { t.Fatalf("expected empty, got %v", rows) } } diff --git a/test/core/modelreg/register_test.go b/test/core/modelreg/register_test.go index 21d25c11..1886f89b 100644 --- a/test/core/modelreg/register_test.go +++ b/test/core/modelreg/register_test.go @@ -93,8 +93,8 @@ func TestIsEmbeddedModelMeta(t *testing.T) { if !modelmeta.IsEmbeddedModelMeta(st.Field(0)) { t.Fatal("expected embedded ModelMeta") } - if modelmeta.IsEmbeddedModelMeta(st.Field(0)) && st.NumField() == 1 { - // only one field + if st.NumField() != 1 { + t.Fatalf("expected single embedded field, got %d", st.NumField()) } type withName struct { Name string diff --git a/test/core/orm/authz_matrix_test.go b/test/core/orm/authz_matrix_test.go new file mode 100644 index 00000000..b2200922 --- /dev/null +++ b/test/core/orm/authz_matrix_test.go @@ -0,0 +1,47 @@ +package orm_test + +import ( + "context" + "testing" + + "sumeru/core/orm" +) + +func TestAuthzMatrix_anonymousDenied(t *testing.T) { + ctx := context.Background() + err := orm.CheckModelAccess(ctx, 0, "core.user", "read") + if err == nil { + t.Fatal("anonymous must be denied") + } + if !orm.IsAccessDenied(err) { + t.Fatalf("want AccessDenied, got %v", err) + } +} + +func TestAuthzMatrix_bypassAllows(t *testing.T) { + ctx := orm.ContextWithBypass(context.Background(), true) + if err := orm.CheckModelAccess(ctx, 0, "core.user", "write"); err != nil { + t.Fatalf("bypass should allow: %v", err) + } +} + +func TestAuthzMatrix_superuserAllows(t *testing.T) { + ctx := context.Background() + if err := orm.CheckModelAccess(ctx, 1, "core.user", "write"); err != nil { + t.Fatalf("uid=1 should allow: %v", err) + } +} + +func TestAuthzMatrix_unknownModel(t *testing.T) { + err := orm.CheckModelAccess(context.Background(), 2, "no.such.model", "read") + if err == nil { + t.Fatal("expected unknown model error") + } +} + +func TestAuthzMatrix_passwordPrepareDenied(t *testing.T) { + _, err := orm.PrepareValues(stubUserModel{}, map[string]interface{}{"password": "x"}, orm.WriteOpWrite, orm.PrepareOptions{}) + if err == nil { + t.Fatal("password write must be rejected") + } +} diff --git a/test/core/orm/orm_extended_test.go b/test/core/orm/orm_extended_test.go index c420ba92..d692e67d 100644 --- a/test/core/orm/orm_extended_test.go +++ b/test/core/orm/orm_extended_test.go @@ -110,8 +110,8 @@ func TestAccessErrorHelpersExtended(t *testing.T) { if denied2.Error() != "access denied" { t.Fatal(denied2.Error()) } - if !orm.IsAccessDenied(errors.New("wrap: " + denied.Error())) { - // errors.As needs typed error + if orm.IsAccessDenied(errors.New("wrap: " + denied.Error())) { + t.Fatal("string-wrapped error should not match via errors.As") } if !orm.IsAccessDenied(denied) { t.Fatal("access denied") @@ -153,10 +153,9 @@ func TestRegistryModel(t *testing.T) { func TestInitDevFeaturesAccessInDevMode(t *testing.T) { orm.InitDevFeatures("") - // cleared map path - if orm.DevFeatureEnabled("access") && !orm.DevFeatureEnabled("sql") { - // depends on config.AppConfig.DevMode - } + // After clear, features are off unless AppConfig.DevMode injects defaults. + _ = orm.DevFeatureEnabled("access") + _ = orm.DevFeatureEnabled("sql") } func TestParseDomainJSONInvalid(t *testing.T) { diff --git a/test/core/orm/pure_helpers_test.go b/test/core/orm/pure_helpers_test.go index d499ef93..407c293b 100644 --- a/test/core/orm/pure_helpers_test.go +++ b/test/core/orm/pure_helpers_test.go @@ -35,8 +35,8 @@ func TestAccessErrors(t *testing.T) { func TestSecurityContext(t *testing.T) { ctx := context.Background() - if orm.UIDFromContext(nil) != 0 || orm.BypassFromContext(nil) { - t.Fatal("nil context") + if orm.UIDFromContext(context.TODO()) != 0 || orm.BypassFromContext(context.TODO()) { + t.Fatal("empty context") } ctx = orm.ContextWithUID(ctx, 7) if orm.UIDFromContext(ctx) != 7 || orm.SecurityUID(ctx) != 7 { diff --git a/test/core/orm/user_password_test.go b/test/core/orm/user_password_test.go new file mode 100644 index 00000000..c1382fbd --- /dev/null +++ b/test/core/orm/user_password_test.go @@ -0,0 +1,52 @@ +package orm_test + +import ( + "context" + "strings" + "testing" + + "sumeru/core/orm" +) + +type stubUserModel struct{} + +func (stubUserModel) ModelName() string { return "core.user" } +func (stubUserModel) Fields() []orm.FieldDefinition { + return []orm.FieldDefinition{ + {Name: "id", Type: orm.Integer}, + {Name: "login", Type: orm.Char}, + {Name: "password", Type: orm.Char}, + } +} + +func TestPrepareValuesRejectsDirectPassword(t *testing.T) { + t.Parallel() + _, err := orm.PrepareValues(stubUserModel{}, map[string]interface{}{ + "login": "a@b.c", + "password": "plaintext", + }, orm.WriteOpWrite, orm.PrepareOptions{}) + if err == nil || !strings.Contains(err.Error(), "password cannot be set directly") { + t.Fatalf("want direct password rejection, got %v", err) + } +} + +func TestPrepareValuesAllowsPasswordHashOption(t *testing.T) { + t.Parallel() + out, err := orm.PrepareValues(stubUserModel{}, map[string]interface{}{ + "password": "$2a$10$abcdefghijklmnopqrstuv", + }, orm.WriteOpWrite, orm.PrepareOptions{AllowPasswordHash: true}) + if err != nil { + t.Fatal(err) + } + if out["password"] == nil { + t.Fatal("expected password in prepared values") + } +} + +func TestSetUserPasswordRequiresAdmin(t *testing.T) { + ctx := orm.ContextWithUID(context.Background(), 2) + err := orm.SetUserPassword(ctx, 2, 2, "ValidPass1") + if err == nil { + t.Fatal("expected denial without system admin (and/or DB)") + } +} diff --git a/test/core/sdk/reflect_test.go b/test/core/sdk/reflect_test.go index 6e0c97da..b8a6a62e 100644 --- a/test/core/sdk/reflect_test.go +++ b/test/core/sdk/reflect_test.go @@ -8,12 +8,6 @@ import ( "sumeru/core/orm" ) -type testPriority string - -const ( - testPriorityLow testPriority = "low" -) - type testLine struct { modelmeta.ModelMeta `sumeru:"model=test.line"` Name modelmeta.String @@ -35,11 +29,6 @@ type testOrder struct { type cookbookPriority string -const ( - cookbookPriorityLow cookbookPriority = "low" - cookbookPriorityHigh cookbookPriority = "high" -) - type cookbookLine struct { modelmeta.ModelMeta `sumeru:"model=test.cookbook.line"` Name modelmeta.String diff --git a/test/core/server/web/export_handlers_test.go b/test/core/server/web/export_handlers_test.go index 882a8da7..1fbe7619 100644 --- a/test/core/server/web/export_handlers_test.go +++ b/test/core/server/web/export_handlers_test.go @@ -24,8 +24,8 @@ func TestResolveExportRequestMissingModel(t *testing.T) { if ok { t.Fatal("expected resolve export request to fail without model") } - if rr.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d (CSRF required before model validation)", rr.Code, http.StatusForbidden) } } diff --git a/test/core/server/web/health_ready_test.go b/test/core/server/web/health_ready_test.go new file mode 100644 index 00000000..92e87d4a --- /dev/null +++ b/test/core/server/web/health_ready_test.go @@ -0,0 +1,32 @@ +package web_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "sumeru/core/server/web" +) + +func TestAPIHealthHandler(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + web.APIHealthHandler(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } +} + +func TestAPIReadyHandlerWithoutDB(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/ready", nil) + web.APIReadyHandler(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status %d want 503 when DB nil", rec.Code) + } +} diff --git a/test/core/server/web/helpers_coverage_test.go b/test/core/server/web/helpers_coverage_test.go index 08647a5d..d73bb5fa 100644 --- a/test/core/server/web/helpers_coverage_test.go +++ b/test/core/server/web/helpers_coverage_test.go @@ -57,9 +57,8 @@ func TestWebHelperExportsCoverage(t *testing.T) { t.Fatalf("setup params: %+v", params) } page := web.BuildSetupPageData() - if page.DbName == "" && !page.SetupTokenRequired { - // defaults are fine - } + _ = page.DbName + _ = page.SetupTokenRequired if !web.AcceptsJSONContentType("application/json") { t.Fatal("json content type") } diff --git a/test/core/server/web/metrics_scrape_test.go b/test/core/server/web/metrics_scrape_test.go new file mode 100644 index 00000000..0b964c89 --- /dev/null +++ b/test/core/server/web/metrics_scrape_test.go @@ -0,0 +1,31 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "sumeru/core/server/config" + "sumeru/core/server/web" +) + +func TestMetricsHandler_scrapeToken(t *testing.T) { + prev := config.AppConfig.MetricsScrapeToken + config.AppConfig.MetricsScrapeToken = "scrape-secret" + t.Cleanup(func() { config.AppConfig.MetricsScrapeToken = prev }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + web.MetricsHandler(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("without token status=%d", rec.Code) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/metrics", nil) + req.Header.Set("Authorization", "Bearer scrape-secret") + web.MetricsHandler(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("with token status=%d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/test/core/server/web/security_headers_test.go b/test/core/server/web/security_headers_test.go new file mode 100644 index 00000000..67837e6f --- /dev/null +++ b/test/core/server/web/security_headers_test.go @@ -0,0 +1,33 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "sumeru/core/server/config" + "sumeru/core/server/web" +) + +func TestSecurityHeadersSet(t *testing.T) { + prev := config.AppConfig.DevMode + config.AppConfig.DevMode = false + t.Cleanup(func() { config.AppConfig.DevMode = prev }) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := web.SecurityMiddleware(inner) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + h.ServeHTTP(rec, req) + if rec.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatal("missing nosniff") + } + if rec.Header().Get("X-Frame-Options") != "SAMEORIGIN" { + t.Fatal("missing frame options") + } + if rec.Header().Get("Strict-Transport-Security") == "" { + t.Fatal("missing HSTS when not in dev_mode") + } +} diff --git a/test/module/unit/sync_mode_test.go b/test/module/unit/sync_mode_test.go index 88a916f8..9488c17f 100644 --- a/test/module/unit/sync_mode_test.go +++ b/test/module/unit/sync_mode_test.go @@ -18,7 +18,7 @@ func TestDataFileOptsSkipExistingOnUpdate(t *testing.T) { t.Fatal("install mode should not skip") } if opts.SkipExistingOnUpdateForTest(ctx, "base", "company_main") { - // without DB/xml id resolution this returns false — no skip when id unknown + t.Fatal("without DB/xml id resolution should not skip when id unknown") } opts2 := module.NewDataFileOptsForTest(false) if opts2.SkipExistingOnUpdateForTest(ctx, "base", "company_main") {