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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,33 @@ System administrators see a **bug icon** in the web top bar (`features.debugMenu

With developer mode on, the top-bar **bug menu** shows sectioned actions (Record, User interface, Security, Tools). Each form field label gets an **info icon**; hover it for a technical popover (field, model, domain, modifiers). **Metadata** and **Data** open modals; **Access rights** opens the secondary debug drawer (collapsed by default). The **field inspector** toggle enables click-to-select on the field widget without blocking normal input when off. **SWC Vision** and **Open metrics** remain admin-gated.

## Realtime bus channels (SWC WebSocket)

Clients connect to `GET /web/swc/bus` (session cookie). Wire frames are JSON:

- Client → server: `subscribe` / `unsubscribe` (with `channels`, optional `last_event_id`), `ping`.
- Server → client: `event` (`id`, `channel`, `payload`), `pong`, `error`.

**Channel ACL** (server enforces before subscribe):

| Prefix | Rule |
|--------|------|
| `user/{uid}/…` | Session uid must match `{uid}` |
| `group/{xmlid}` | User must belong to group |
| `record/{model}/{id}` | Read access on record |
| `company/{id}` | User allowed company |
| `model/{model}` | Model read ACL |

Events are persisted in `sys.bus.event`; PostgreSQL `NOTIFY sumeru_bus` fan-out supports multiple app processes. Payloads carry record ids only — never field values from elevated writes.

## Mail thread and notifications

Models with `mail_thread` on the embedded model tag auto-subscribe creator and common assignee fields on create. Chatter uses `mail.message` subtypes (`mail.message.subtype`). Followers (`mail.follower`) drive `mail.notification` rows and optional HTML email via the mail queue. @mentions in chatter bodies notify mentioned users by login.

## Auth providers and MFA

Configure IdPs under **Settings → Security → Authentication providers** (`sys.auth.provider`; PKCE on start/callback). **Linked identities** lists `core.user.identity` rows. The login page shows enabled providers as SSO buttons. Local password login stays available unless system parameter `auth.local_enabled` is `false` and at least one provider is enabled. TOTP: users enroll under **Settings → Account security**; login uses `totp_enabled` / `totp_secret` with an HMAC-signed pending-MFA cookie before session creation; trusted devices use a signed cookie bound to the user id. SAML is not implemented — use OIDC-capable IdPs. When `jwks_url` is set, the callback verifies the `id_token` signature against JWKS (RS256/ES256) before linking the user.

## View modifier expressions (SWC)

Dynamic `invisible` / `readonly` / `required` expressions in form and list arch are evaluated client-side with a **frozen allowlist** of identifiers: record field names, `user_id`, `company_id`, and `context` (object). Expressions must be boolean JavaScript fragments (for example `state == 'done'`), not statements. Tokens such as `function`, `=>`, `[`, `` ` ``, or `;` are rejected. Static arch flags still apply when an expression is missing or invalid. List column expressions that reference record fields are evaluated without a row context (static arch flags apply). Action `context` on the workspace payload is not wired yet — `context` is an empty object until then.
Expand Down
4 changes: 4 additions & 0 deletions addons/base/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
"views/sys_report_action_list_views.xml",
"views/sys_report_action_form_views.xml",
"views/security_actions.xml",
"views/auth_actions.xml",
"views/sys_auth_provider_list_views.xml",
"views/sys_auth_provider_form_views.xml",
"views/core_user_identity_list_views.xml",
"views/core_company_form_views.xml",
"views/core_company_kanban_views.xml",
"views/core_company_list_views.xml",
Expand Down
13 changes: 13 additions & 0 deletions addons/base/models/core_user_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package models

import (
"sumeru/core/sdk"
)

type CoreUserIdentity struct {
sdk.Model `sumeru:"model=core.user.identity"`

ProviderID sdk.Many2One[SysAuthProvider] `sumeru:"required,index,string=Provider"`
Subject sdk.String `sumeru:"required,index,string=Subject"`
UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"`
}
14 changes: 14 additions & 0 deletions addons/base/models/core_user_trusteddevice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package models

import (
"sumeru/core/sdk"
)

type CoreUserTrustedDevice struct {
sdk.Model `sumeru:"model=core.user.trusteddevice"`

UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"`
TokenHash sdk.String `sumeru:"required,index,string=Token Hash,column=token_hash"`
UserAgent sdk.String `sumeru:"string=User Agent,column=user_agent"`
ExpiresAt sdk.DateTime `sumeru:"required,index,string=Expires At,column=expires_at"`
}
22 changes: 22 additions & 0 deletions addons/base/models/sys_auth_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package models

import (
"sumeru/core/sdk"
)

type SysAuthProvider struct {
sdk.Model `sumeru:"model=sys.auth.provider"`

Name sdk.String `sumeru:"required,string=Name"`
ProviderType sdk.String `sumeru:"required,string=Type,default=oidc,selection=oidc:OpenID Connect,oauth2:OAuth2"`
ClientID sdk.String `sumeru:"required,string=Client ID"`
ClientSecret sdk.String `sumeru:"string=Client Secret"`
IssuerURL sdk.String `sumeru:"string=Issuer URL"`
AuthorizeURL sdk.String `sumeru:"string=Authorize URL"`
TokenURL sdk.String `sumeru:"string=Token URL"`
JwksURL sdk.String `sumeru:"string=JWKS URL"`
Scopes sdk.String `sumeru:"string=Scopes,default=openid email profile"`
Enabled sdk.Boolean `sumeru:"string=Enabled,default=false"`
ButtonLabel sdk.String `sumeru:"string=Button Label"`
LinkPolicy sdk.String `sumeru:"string=Unknown Users,default=deny,selection=deny:Deny,link:Link by email,jit_internal:JIT internal,jit_portal:JIT portal"`
}
3 changes: 3 additions & 0 deletions addons/base/models/zmodels.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions addons/base/views/auth_actions.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<sumeru>
<data>
<action id="action_sys.auth.provider" type="window" model="sys.auth.provider" name="Authentication Providers" view_mode="list,form"/>
<action id="action_core.user.identity" type="window" model="core.user.identity" name="Linked Identities" view_mode="list"/>
</data>
</sumeru>
10 changes: 10 additions & 0 deletions addons/base/views/core_user_identity_list_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<sumeru>
<data>
<view id="view_core.user.identity_list" model="core.user.identity" type="list">
<field name="provider_id" string="Provider"/>
<field name="subject" string="Subject"/>
<field name="user_id" string="User"/>
</view>
</data>
</sumeru>
2 changes: 1 addition & 1 deletion addons/base/views/core_users_form_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
<group>
<label for="login" string="Login is used to sign in. Keep it unique."/>
<field name="login" string="Login"/>
<field name="totp_enabled" string="Two-Factor Authentication"/>
<field name="totp_enabled" string="Two-Factor Authentication" readonly="true"/>
<field name="password_min_len" string="Min Password Length"/>
</group>
</page>
Expand Down
2 changes: 2 additions & 0 deletions addons/base/views/menus.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
<menuitem id="menu_sys_field_access" name="Field Access" parent="menu_security_section" sequence="30" action="base.action_sys.field.access" groups="base.group_system" web_icon="settings"/>
<menuitem id="menu_sys_field_access_matrix" name="Field access matrix" parent="menu_security_section" sequence="40" action="base.action_sys.field.access_matrix" groups="base.group_system" web_icon="settings"/>
<menuitem id="menu_sys_access_matrix" name="Model access matrix" parent="menu_security_section" sequence="45" action="base.action_sys.access_matrix" groups="base.group_system" web_icon="settings"/>
<menuitem id="menu_sys_auth_provider" name="Authentication providers" parent="menu_security_section" sequence="50" action="base.action_sys.auth.provider" groups="base.group_system" web_icon="settings"/>
<menuitem id="menu_core_user_identity" name="Linked identities" parent="menu_security_section" sequence="55" action="base.action_core.user.identity" groups="base.group_system" web_icon="settings"/>

<!-- Platform -->
<menuitem id="menu_platform_section" name="Platform" parent="menu_settings_root" sequence="16" web_icon="settings" groups="base.group_system"/>
Expand Down
41 changes: 41 additions & 0 deletions addons/base/views/sys_auth_provider_form_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<sumeru>
<data>
<view id="view_sys.auth.provider_form" model="sys.auth.provider" type="form">
<sheet>
<div class="sum_title">
<h1>
<field name="name" placeholder="e.g. Corporate Azure AD"/>
</h1>
</div>
<group>
<group string="General">
<field name="enabled" string="Enabled"/>
<field name="button_label" string="Sign-in button label" placeholder="Sign in with Azure"/>
<field name="provider_type" string="Protocol"/>
<field name="link_policy" string="Unknown users"/>
</group>
<group string="OAuth client">
<field name="client_id" string="Client ID"/>
<field name="client_secret" string="Client secret"/>
<field name="scopes" string="Scopes"/>
</group>
</group>
<group string="Endpoints">
<group>
<field name="issuer_url" string="Issuer URL"/>
<field name="authorize_url" string="Authorize URL"/>
</group>
<group>
<field name="token_url" string="Token URL"/>
<field name="jwks_url" string="JWKS URL"/>
</group>
</group>
<group string="Login callback">
<label string="Redirect URI registered at your IdP must be: https://YOUR_HOST/web/auth/oauth/callback (use http only in local development)."/>
<label string="To hide password login, set System Parameter auth.local_enabled to false (requires at least one enabled provider)."/>
</group>
</sheet>
</view>
</data>
</sumeru>
12 changes: 12 additions & 0 deletions addons/base/views/sys_auth_provider_list_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<sumeru>
<data>
<view id="view_sys.auth.provider_list" model="sys.auth.provider" type="list">
<field name="name" string="Name"/>
<field name="provider_type" string="Type"/>
<field name="enabled" string="Enabled"/>
<field name="button_label" string="Button Label"/>
<field name="link_policy" string="Unknown Users"/>
</view>
</data>
</sumeru>
16 changes: 16 additions & 0 deletions addons/mail/data/mail_subtype_data.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<sumeru>
<data noupdate="true">
<record id="mail_subtype_comment" model="mail.message.subtype">
<field name="name">comment</field>
<field name="description">User discussion</field>
<field name="default">True</field>
</record>
<record id="mail_subtype_notification" model="mail.message.subtype">
<field name="name">notification</field>
<field name="description">System notification</field>
<field name="internal">True</field>
<field name="default">True</field>
</record>
</data>
</sumeru>
171 changes: 171 additions & 0 deletions addons/mail/followers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package mail

import (
"context"
"fmt"
"html"
"regexp"
"strings"

pkgmail "sumeru/core/mail"
"sumeru/core/orm"
"time"
)

var mentionPattern = regexp.MustCompile(`@([a-zA-Z0-9._-]+)`)

// SubscribeUserFollower ensures user_id follows the record (idempotent).
func SubscribeUserFollower(ctx context.Context, model string, resID int64, userID int) error {
if userID <= 0 || resID <= 0 || strings.TrimSpace(model) == "" {
return nil
}
if _, ok := orm.Registry["mail.follower"]; !ok {
return nil
}
existing, err := orm.SearchOne(ctx, "mail.follower", map[string]interface{}{
"res_model": model,
"res_id": int(resID),
"user_id": userID,
})
if err == nil && existing != nil {
return nil
}
inst := orm.Registry["mail.follower"]
_, err = orm.Create(ctx, inst, map[string]interface{}{
"res_model": model,
"res_id": int(resID),
"user_id": userID,
"active": true,
})
return err
}

// NotifyMessageFollowers creates inbox notifications and optional email for a posted message.
func NotifyMessageFollowers(ctx context.Context, messageID int, authorUserID int) error {
if messageID <= 0 || orm.DB == nil {
return nil
}
msg, err := orm.SearchOne(ctx, "mail.message", map[string]interface{}{"id": messageID})
if err != nil || msg == nil {
return err
}
model := rowString(msg, "model")
resID := int(rowInt64(msg, "core_id"))
subtype := rowString(msg, "subtype")
body := rowString(msg, "body")
if model == "" || resID <= 0 {
return nil
}
recipients := map[int]struct{}{}
followers, _ := orm.Search(ctx, "mail.follower", [][]interface{}{
{"res_model", "=", model},
{"res_id", "=", resID},
{"active", "=", true},
})
for _, f := range followers {
uid := int(rowInt64(f, "user_id"))
if uid <= 0 || uid == authorUserID {
continue
}
recipients[uid] = struct{}{}
}
for _, login := range mentionPattern.FindAllStringSubmatch(body, -1) {
if len(login) < 2 {
continue
}
uid := userIDByLogin(ctx, login[1])
if uid > 0 && uid != authorUserID {
recipients[uid] = struct{}{}
_ = SubscribeUserFollower(ctx, model, int64(resID), uid)
}
}
authorName := rowString(msg, "author")
subject := fmt.Sprintf("[%s] %s", model, truncate(body, 80))
for uid := range recipients {
if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil {
continue
}
notifID, err := createNotification(ctx, uid, messageID, model, resID)
if err != nil {
continue
}
orm.PublishBusChannel(ctx, orm.UserNotificationsBusChannel(uid), map[string]interface{}{
"notification_id": notifID,
"message_id": messageID,
"model": model,
"id": resID,
})
if shouldEmailUser(ctx, uid) {
email := userEmail(ctx, uid)
if email != "" {
htmlBody := renderNotificationHTML(authorName, body, model, resID)
pkgmail.EnqueueHTML(ctx, email, subject, htmlBody, stripHTML(body))
}
}
}
_ = subtype
return nil
}

func createNotification(ctx context.Context, uid, messageID int, model string, resID int) (int, error) {
inst := orm.Registry["mail.notification"]
id, err := orm.Create(ctx, inst, map[string]interface{}{
"user_id": uid,
"message_id": messageID,
"is_read": false,
"record_model": model,
"record_id": resID,
"create_date": time.Now().UTC().Format(time.RFC3339),
})
return id, err
}

func userIDByLogin(ctx context.Context, login string) int {
login = strings.TrimSpace(login)
if login == "" || orm.DB == nil {
return 0
}
rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"login": login})
if err != nil || rec == nil {
return 0
}
return int(rowInt64(rec, "id"))
}

func userEmail(ctx context.Context, uid int) string {
rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid})
if err != nil || rec == nil {
return ""
}
return rowString(rec, "email")
}

func shouldEmailUser(ctx context.Context, uid int) bool {
rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid})
if err != nil || rec == nil {
return false
}
if v, ok := rec["notify_email"].(bool); ok {
return v
}
return true
}

func renderNotificationHTML(author, body, model string, resID int) string {
esc := html.EscapeString(body)
authorEsc := html.EscapeString(author)
return "<html><body><p><strong>" + authorEsc + "</strong> on " + html.EscapeString(model) +
" #" + fmt.Sprint(resID) + ":</p><p>" + strings.ReplaceAll(esc, "\n", "<br/>") + "</p></body></html>"
}

func stripHTML(s string) string {
return strings.TrimSpace(s)
}

func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n] + "…"
}
Loading
Loading