app: logins folded to lower case, unique without regard to case (audit H4, 0008)

Postfix's reject_sender_login_mismatch compares the SASL login with the
sender map without regard to case - seen on the e2e stand this time (a
one-off probe, not committed: map rewritten to "APP1", client authenticated
as "app1", RCPT accepted). The panel stored logins as typed under a BINARY
UNIQUE, so "Ops" and "ops" were two applications to the panel and one sender
to Postfix: a tenant could send with another tenant's envelope sender.

- app.foldLogin: Service.Create lower-cases the login (owner: fold, do not
  refuse); CreateWithSettings rolls back the stored (folded) login.
- ImportApplication keeps the login as written: folding it would rename the
  SASL account and break the imported application's working credentials;
  the index below refuses a case clash there too.
- Migration 0008 (DDL): CREATE UNIQUE INDEX idx_applications_login_nocase ON
  applications (login COLLATE NOCASE). The column's own BINARY UNIQUE stays
  (SQLite cannot change a collation without a table rebuild, which a
  migration transaction cannot do with foreign keys on). Checked on the live
  installation first: 4 logins, no case clash; "Quieter3C" keeps its case.
  0008 is not run anywhere yet; send_log.domain_id joins it later in stage 2.

Tests: "Ops" created as ops, SASL account and map binding in lower case,
OPS in another domain and an imported oPs refused; the store refuses ops
beside Ops; schema at user_version 8 with the index.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 15/61.
Evidence on the test server for this tree (with the probe): go vet clean,
go test ./... 23 packages ok, make e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 14:55:24 +03:00
co-authored by Claude Opus 5.5
parent a59e3d4d61
commit f51f5db245
9 changed files with 88 additions and 8 deletions
+14
View File
@@ -38,6 +38,20 @@ func TestAddApplicationWildcard(t *testing.T) {
}
}
// Logins are unique without regard to case, whatever the caller passed
// (idx_applications_login_nocase, migration 0008).
func TestAddApplicationRefusesALoginDifferingOnlyInCase(t *testing.T) {
st := openTestStore(t)
a := addTestDomain(t, st, "a.example")
b := addTestDomain(t, st, "b.example")
if _, err := st.AddApplication(a.ID, "Ops", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(b.ID, "ops", AddressModeWildcard, nil); !errors.Is(err, ErrLoginExists) {
t.Fatalf("ops beside Ops = %v, want ErrLoginExists", err)
}
}
func TestAddApplicationListStoresAddresses(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
@@ -0,0 +1,11 @@
-- The tenant boundary (audit 2026-10-12, stage 2).
-- An application login is unique without regard to case. Postfix compares
-- the SASL login with the sender map's without regard to case, so "Ops" and
-- "ops" were two applications to the panel and one sender to Postfix: a
-- tenant could take another's envelope sender (audit H4). New logins are
-- folded to lower case by the panel; a login stored earlier keeps its case,
-- since its SASL account and its client use it. The column's own UNIQUE
-- (case-sensitive) stays: SQLite cannot change a column's collation without
-- rebuilding the table, and this index is the stricter rule.
CREATE UNIQUE INDEX idx_applications_login_nocase ON applications (login COLLATE NOCASE);
+5 -5
View File
@@ -104,7 +104,7 @@ func TestBaselineSchema(t *testing.T) {
}
rows.Close()
wantIdx := []string{
"idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received",
"idx_applications_login_nocase", "idx_dmarc_report_records_report", "idx_dmarc_reports_domain", "idx_dmarc_reports_received",
"idx_inbound_spam_log_created_at", "idx_inbound_spam_log_domain_created_at",
"idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_lines_queue_id", "idx_send_log_queue_id", "idx_send_log_rejected",
"idx_sessions_expires_at",
@@ -115,8 +115,8 @@ func TestBaselineSchema(t *testing.T) {
}
var version, appID int64
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 7 {
t.Errorf("user_version = %d, %v; want 7", version, err)
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 8 {
t.Errorf("user_version = %d, %v; want 8", version, err)
}
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
@@ -192,8 +192,8 @@ func TestOpenMigratesA20Database(t *testing.T) {
}
defer st.Close()
var version int
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 7 {
t.Errorf("user_version = %d, %v; want 7", version, err)
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 8 {
t.Errorf("user_version = %d, %v; want 8", version, err)
}
if ok, err := st.UserExists(); err != nil || !ok {
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)