Files
selfpost/docs/schema-migrations.md
T
mixandClaude Opus 5.5 b2fbe91de9 panel-redesign: security review of 2.0 - sessions belong to a user by id
Stage 3, step 2. Reviewed by Fable (read-only), fixed by Sonnet with a
failing test before each fix, checked by Opus. No Critical or High
finding: route authorization, tenant isolation by id, the /server/
subtree guard, output encoding and the origin check were found sound.

Fixed
  - M1: a session was keyed by user name and had no link to the user. A
    deleted or renamed user's cookie stayed alive (renewed on every hit,
    before the user was looked up) and became a session of whoever was
    later created under that name, with that account's role. The
    baseline now has sessions.user_id REFERENCES users(id) ON DELETE
    CASCADE; the principal is resolved by id and a session is renewed
    only after its user was found. RenameSession is gone.
  - M2: any user's password change deleted every other user's sessions.
    It ends that user's other sessions only.
  - L1: a password set on the user form ends that user's sessions (an
    administrator editing their own account keeps the current one).
  - L2: store, saslpasswd2 and Postfix reload errors were shown verbatim
    on the application form, the inbound upstream / recipients forms and
    the limit recalculation - pages a domain administrator reaches.
    Validation errors (app.ValidationError, inbound.ValidationError,
    store.ErrNotAutoRateLimit) keep their text; anything else is logged
    and shown as "Could not save ... Check the logs and try again."
  - L3: store.ListDMARCReports treated an empty domain list as "all". It
    takes a scope now (AllDomains or a list; an empty list is no rows),
    and the DMARC hub is open to the domain role as the plan says: the
    reports of the assigned domains, without the instance-wide ingest
    box. The menu entry was already shown to them and led to a 404.
  - I1: /static/ answered with a listing of the embedded assets. A path
    that is not a known asset is a 404.

Consequences
  - The global DMARC hub lists reports of domains that were deleted
    since, too.
  - dev/migrate1x (not in the tree) no longer carries sessions over:
    everyone signs in again after the transfer.
  - docs/schema-migrations.md: the sessions row.

Recorded, not changed - for the owner:
  - L4: the report-address select names the followed user and their
    address to every administrator of the domain, by default the global
    administrator's login name. The plan asks for it.
  - L5: a delegated inbound upstream may be any host and port, loopback
    and private ranges included - within the trusted-operator model; to
    be listed among the accepted risks in security.md.
  - L6: Account's profile is saved without the password; becomes a
    requirement once password-reset mails to users.email.
  - Still echoing an error's text: backup and domain import (global
    only), the domain form's validation messages.

Not checked by the review: anything needing the container (e2e, real
Postfix / DNS), CSP behaviour in a live browser.

  go test -count=1 ./internal/web/... ./internal/store/... ./internal/app/...
    ./internal/inbound/... ./internal/dmarc/... ./cmd/panel
      ok  (all eleven packages; dev/migrate1x also ok)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:04:02 +03:00

7.1 KiB

SQLite schema and migrations

What this file is. A living reference for the embedded SQLite schema: the 2.0 baseline, the migrations added after it, and how a database is opened. When you add or review a migration, update this file in the same change.

Source of truth in code: internal/store/migrations/*.sql, applied by internal/store/store.go (migrate()).

Related: architecture.md § Persistence; plans/panel-redesign.md § No compatibility; development.md § Documentation.


Current snapshot

Field Value
Chain head user_version = 1
Files 0001_init.sql (the 2.0 baseline)
File mark PRAGMA application_id = 0x53503230 ("SP20")
Database file /data/selfpost.db (bind mount)
Compatibility 2.x MINOR releases must boot a 2.0.0 data directory. Nothing is carried over from 1.x.

2.0 starts from an empty data directory

Decided by the owner on 2026-09-21: the redesign is a breaking release, the 1.x migration chain (0001 … 0010, with its admin table created in the first file and dropped in the fifth) is deleted from the binary, and there is no upgrade path. The only live 1.x installation is the owner's and is set up again on 2.0. The 1.x chain stays in git history (last present at 1.9.5).

What that means in practice:

  • A fresh /data gets the baseline and nothing else.
  • A 1.x database is refused, not migrated. store.Open returns ErrForeignSchema for a file that has a schema version but not the 2.x application_id. Without that check a 1.x file at user_version = 10 would read as "already migrated" and the panel would start on tables it does not know. The file is not modified. The same applies to restoring a 1.x full backup into a 2.0 container.
  • A database whose version is higher than the build knows is refused too.
  • Domains can still be carried by hand: a 1.x domain export (JSON, with the DKIM key and applications) imports into 2.0 — the transfer format is not the database.

How migrations run

  1. Migrations are embedded at build time (//go:embed migrations/*.sql).
  2. Filenames are sorted lexicographically; file order = version number.
  3. PRAGMA user_version records progress: after file i (0-based), version is i + 1.
  4. Each pending migration runs in its own transaction, then bumps user_version.
  5. There is no down-migration; fixes ship as a new 00NN_*.sql file.

Implication: once 2.0.0 is released, 0001_init.sql is never edited again and no file is deleted, renamed or reordered — every 2.x database has already run it. Until that release the baseline is still being written: later steps of the redesign and the features queued behind it (the next one is 0002_inbound_spam_log.sql of inbound-antispam-panel, 2.1.0) add files after it.


Migration chain

Ver File Shipped Kind Summary
1 0001_init.sql 2.0.0 DDL The whole 2.0 schema, below

Kind: DDL — schema only; data — INSERT/UPDATE that must stay correct for operators upgrading from older 2.x images.


Schema at head (v1)

Table Role
settings Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user
users Panel logins. role is global or domain; email is the user's own address; dmarc_default_mode / dmarc_default_address are their default DMARC report address; all_domains / all_inbound_domains widen a domain user to every domain of that direction
sessions Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry)
domains Sending (outbound) domains. dmarc_rua / dmarc_rua_user_id say where aggregate reports go
user_domains Outbound domains assigned to a domain user
applications SASL applications per domain, with the client-IP restriction (auth_ip_restrict, auth_allowed_ips)
application_addresses Explicit From addresses (list mode)
rate_limits Level-2 limits per domain / application, manual or auto
send_log Delivery journal
logtail_state Log-tailer read position
inbound_domains Inbound relay domains
inbound_transports Upstream host / port / TLS per inbound domain
inbound_recipients Allow-list when recipient_mode = list
user_inbound_domains Inbound domains assigned to a domain user — granted separately from the outbound ones
dmarc_reports Parsed aggregate report summaries
dmarc_report_records Per-source rows inside a report

TestBaselineSchema (internal/store/schema_test.go) pins every table, column and index of this list.

What changed against the last 1.x schema

1.x 2.0 Why
admin created in 0001, dropped in 0005 never exists the reason for the squash
users.role = global / domain_admin global / domain the role is a reach, not a rank — everyone in the panel is an administrator
users.dmarc_report_email users.email a user's e-mail is theirs; it is what the panel writes to, and DMARC only may use it
instance setting dmarc_report_email (mirror of the global user's field) users.dmarc_default_mode, users.dmarc_default_address the default report address is a user's setting, not the server's
domains.dmarc_rua NULL = inherit the instance default, '' = none domains.dmarc_rua_user_id set = follows that user's default; otherwise dmarc_rua is the address, '' = none a domain can have several users, so "the default" has to be someone's
— user_inbound_domains inbound is delegated per user, separately from outbound
— users.all_domains, users.all_inbound_domains "All" includes domains added later
rate_limits.allowed_ips (unused since 1.9.0) gone client IPs belong to the application (auth_allowed_ips)

Where a domain's DMARC reports go

Resolved in one place, Store.DomainDMARCRua:

  1. domains.dmarc_rua_user_id is set → the domain follows that user's default (DMARCDefault.Resolve): hosted → SelfPost's own mailbox for the domain (no reports while ingest is off), account → users.email, custom → users.dmarc_default_address, none → no reports.
  2. Otherwise domains.dmarc_rua is the address itself; '' means a policy-only record.

A new domain follows whoever created it. Deleting the followed user sets dmarc_rua_user_id to NULL (ON DELETE SET NULL), which leaves the domain at "no reports" — never at another user's default.


Adding a migration

  1. Add internal/store/migrations/00NN_short_name.sql (next number).
  2. Update the table in Migration chain, the snapshot at the top, and the affected rows of Schema at head.
  3. Update TestBaselineSchema if tables, columns or indexes change, and add a test that opens a database at the previous version and migrates it.
  4. A data migration states in a comment which 2.x versions it is written for.