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:
+36
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user