The Deliveries list narrowed the journal only when exactly one domain was assigned, so an administrator with none or with two or more read every domain's rows. The domain scope is now an IN constraint the store query carries, a filter that states no scope matches nothing, and the domain/app query parameters are validated against the principal before the query runs. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -178,15 +178,29 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
|
||||
// application login. An empty field matches everything.
|
||||
// SendLogFilter narrows QuerySendLog/CountSendLog. It carries two kinds of
|
||||
// narrowing, and they behave in opposite ways on purpose.
|
||||
//
|
||||
// Domain and AppLogin are the operator's own filters, chosen in the UI: an
|
||||
// empty field matches everything.
|
||||
//
|
||||
// Domains and AllDomains are the authorization scope, which no query parameter
|
||||
// may widen. Domains is the exhaustive set of domain names the caller is
|
||||
// entitled to read, applied as an IN constraint; AllDomains lifts that
|
||||
// restriction and is the only way to read the whole journal. A zero-valued
|
||||
// filter therefore matches *no* rows: a caller that forgets to state a scope
|
||||
// gets an empty log rather than every tenant's mail, which is the failure mode
|
||||
// this struct exists to make impossible.
|
||||
type SendLogFilter struct {
|
||||
Domain string
|
||||
AppLogin string
|
||||
Domain string
|
||||
AppLogin string
|
||||
Domains []string
|
||||
AllDomains bool
|
||||
}
|
||||
|
||||
// QuerySendLog returns send-log rows matching filter, newest first, for the
|
||||
// monitoring screen's server-side pagination (product.md's send-log view).
|
||||
// The filter's authorization scope is mandatory: see SendLogFilter.
|
||||
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
|
||||
where, args := sendLogWhere(filter)
|
||||
args = append(args, limit, offset)
|
||||
@@ -233,6 +247,20 @@ func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
|
||||
func sendLogWhere(f SendLogFilter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
if !f.AllDomains {
|
||||
// No scope is not "no restriction": a domain administrator whose last
|
||||
// assignment was deleted owns nothing in the journal and must see
|
||||
// nothing, and the same clause catches a caller that never set a scope.
|
||||
if len(f.Domains) == 0 {
|
||||
return " WHERE 1 = 0", nil
|
||||
}
|
||||
marks := make([]string, len(f.Domains))
|
||||
for i, name := range f.Domains {
|
||||
marks[i] = "?"
|
||||
args = append(args, name)
|
||||
}
|
||||
clauses = append(clauses, "domain IN ("+strings.Join(marks, ", ")+")")
|
||||
}
|
||||
if f.Domain != "" {
|
||||
clauses = append(clauses, "domain = ?")
|
||||
args = append(args, f.Domain)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -162,6 +164,61 @@ func TestUpdateStatusNoMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The journal is read by principals who are only entitled to part of it, so
|
||||
// the scope is part of the query rather than something the caller remembers to
|
||||
// apply afterwards. A filter that states no scope is a caller that has not
|
||||
// decided who is asking, and the safe answer to that is nothing.
|
||||
func TestSendLogScopeIsMandatory(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
for _, domain := range []string{"first.example.ru", "second.example.ru"} {
|
||||
if err := st.InsertQueued(SendLogEntry{
|
||||
QueueID: "Q-" + domain, Domain: domain, AppLogin: "app-" + domain,
|
||||
From: "noreply@" + domain, To: "public@example.net", Subject: domain,
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
filter SendLogFilter
|
||||
want []string
|
||||
}{
|
||||
"no scope": {SendLogFilter{}, nil},
|
||||
"empty scope": {SendLogFilter{Domains: []string{}}, nil},
|
||||
"all domains": {SendLogFilter{AllDomains: true}, []string{"first.example.ru", "second.example.ru"}},
|
||||
"one domain": {SendLogFilter{Domains: []string{"first.example.ru"}}, []string{"first.example.ru"}},
|
||||
"two domains": {SendLogFilter{Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru", "second.example.ru"}},
|
||||
"unknown domain": {SendLogFilter{Domains: []string{"third.example.ru"}}, nil},
|
||||
"filter within": {SendLogFilter{Domain: "first.example.ru", Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru"}},
|
||||
"filter outside": {SendLogFilter{Domain: "second.example.ru", Domains: []string{"first.example.ru"}}, nil},
|
||||
"app filter outside": {SendLogFilter{AppLogin: "app-second.example.ru", Domains: []string{"first.example.ru"}}, nil},
|
||||
} {
|
||||
rows, err := st.QuerySendLog(tc.filter, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: QuerySendLog: %v", name, err)
|
||||
}
|
||||
var got []string
|
||||
for _, r := range rows {
|
||||
got = append(got, r.Domain)
|
||||
}
|
||||
// Which rows came back is the question here; the page's own order is
|
||||
// newest-first and is tested where it matters.
|
||||
sort.Strings(got)
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("%s: rows for %v, want %v", name, got, tc.want)
|
||||
}
|
||||
// The count drives pagination, so it has to agree with the page or the
|
||||
// UI advertises pages of rows the reader is not allowed to see.
|
||||
n, err := st.CountSendLog(tc.filter)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: CountSendLog: %v", name, err)
|
||||
}
|
||||
if int(n) != len(tc.want) {
|
||||
t.Errorf("%s: count %d, want %d", name, n, len(tc.want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSendLogBefore(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user