fix(PROJ-55): Tenant-Isolation für Rolle "auditor" + Audit-Log korrigieren

Kritischer Sicherheitsbug: Auditoren mit zugewiesenem Tenant sahen Mails
und Audit-Log-Einträge anderer Tenants (DSGVO-relevant). auditor wird
jetzt analog zu domain_auditor pro Tenant gescoped, sofern tenant_id
gesetzt ist (Abwärtskompatibilität: ohne tenant_id bleibt der bisherige
globale Zugriff erhalten). Betrifft Mail-Suche, Mail-Detailzugriff,
Export, eDiscovery, Threads, OCR sowie das Audit-Log (DB + tamper-evidentes
Flat-File), inkl. Befüllung von tenant_id an allen Audit-Log-Schreibstellen.
This commit is contained in:
sysops
2026-06-21 22:26:06 +02:00
parent 4a8b8964e5
commit 92e57431c3
28 changed files with 526 additions and 27 deletions
+36 -2
View File
@@ -37,6 +37,10 @@ type Entry struct {
MailID string `json:"mail_id"`
Success bool `json:"success"`
Detail string `json:"detail"`
// TenantID, when set, records which tenant this event belongs to (PROJ-55).
// nil means a tenant-less / system-wide event (e.g. superadmin actions,
// scheduler/system events) that only superadmin sees in the audit log.
TenantID *int64 `json:"tenant_id,omitempty"`
}
// QueryFilter specifies filtering options for audit log queries.
@@ -46,6 +50,11 @@ type QueryFilter struct {
MailID string
From *time.Time
To *time.Time
// TenantID, when set, restricts results to audit entries belonging to that
// tenant (PROJ-55). Entries with a NULL tenant_id (e.g. written before
// multi-tenancy or by tenant-less system actions) are NOT returned for a
// tenant-scoped query — only superadmin (TenantID == nil) sees those.
TenantID *int64
PageSize int
Page int
}
@@ -73,6 +82,10 @@ type fileEntry struct {
MailID string `json:"mail_id,omitempty"`
Success bool `json:"success"`
Detail string `json:"detail,omitempty"`
// TenantID mirrors the DB column (PROJ-55, BEFUND-3): a tenant-less /
// system-wide event omits the field (nil → omitempty) so DB and flat-file
// audit stay consistent for GoBD/forensic traceability.
TenantID *int64 `json:"tenant_id,omitempty"`
}
// New connects to PostgreSQL using the given DSN and initialises the schema.
@@ -115,6 +128,17 @@ func initSchema(ctx context.Context, pool *pgxpool.Pool) error {
return err
}
// PROJ-55: tenant_id records which tenant an audit event belongs to so the
// audit log can be filtered per tenant (NULL = tenant-less / system-wide event,
// only visible to superadmin). Idempotent and safe on existing databases;
// also created by tenantstore. No FK here to avoid an init-order dependency on
// the tenants table — the value is always written from a validated session.
if _, err := pool.Exec(ctx, `
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT;
`); err != nil {
return fmt.Errorf("add tenant_id column: %w", err)
}
// PROJ-48: make audit_log append-only at the database level. A BEFORE
// UPDATE OR DELETE trigger raises an exception for every row mutation,
// regardless of the DB role used by the application. This is the strongest
@@ -180,8 +204,8 @@ func (l *Logger) Log(entry Entry) {
}
ctx := context.Background()
_, err := l.pool.Exec(ctx,
`INSERT INTO audit_log (timestamp, event_type, username, ip_address, query, mail_id, success, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
`INSERT INTO audit_log (timestamp, event_type, username, ip_address, query, mail_id, success, detail, tenant_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
ts.UTC(),
entry.EventType,
entry.Username,
@@ -190,6 +214,7 @@ func (l *Logger) Log(entry Entry) {
entry.MailID,
entry.Success,
entry.Detail,
entry.TenantID,
)
if err != nil {
l.logger.Error("audit: insert failed", "err", err)
@@ -219,6 +244,7 @@ func (l *Logger) writeFile(entry Entry, ts time.Time) {
MailID: entry.MailID,
Success: entry.Success,
Detail: entry.Detail,
TenantID: entry.TenantID,
})
if err != nil {
l.logger.Error("audit: marshal log line failed", "err", err)
@@ -320,6 +346,14 @@ func buildWhere(f QueryFilter) (string, []interface{}) {
args = append(args, f.To.UTC())
n++
}
if f.TenantID != nil {
// NULL-safe by design: "tenant_id = $n" excludes rows with a NULL
// tenant_id, so tenant-less audit entries stay invisible to tenant-scoped
// roles (PROJ-55).
clauses = append(clauses, fmt.Sprintf("tenant_id = $%d", n))
args = append(args, *f.TenantID)
n++
}
if len(clauses) == 0 {
return "", args