From ff6a78b8bcde3dff75b8c317fbf284f977d9eee9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:05:22 +0200 Subject: [PATCH] Add masked IP address field to audit logs (#277) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ribtoks <505555+ribtoks@users.noreply.github.com> --- pkg/common/auditlogevent.go | 2 ++ pkg/common/ipaddr.go | 18 ++++++++++++++++++ pkg/db/audit.go | 13 +++++++++++++ pkg/db/generated/auditlog.sql.go | 11 ++++++++--- pkg/db/generated/copyfrom.go | 3 ++- pkg/db/generated/models.go | 2 ++ ...1_add_audit_logs_ip_address_column.down.sql | 1 + ...121_add_audit_logs_ip_address_column.up.sql | 1 + pkg/db/queries/postgres/auditlog.sql | 4 ++-- widget/package-lock.json | 6 +++++- 10 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 pkg/common/ipaddr.go create mode 100644 pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.down.sql create mode 100644 pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.up.sql diff --git a/pkg/common/auditlogevent.go b/pkg/common/auditlogevent.go index 062dbca4..4db4d23a 100644 --- a/pkg/common/auditlogevent.go +++ b/pkg/common/auditlogevent.go @@ -1,6 +1,7 @@ package common import ( + "net/netip" "strconv" "time" ) @@ -77,6 +78,7 @@ type AuditLogEvent struct { EntityID int64 TableName string SessionID string + IPAddress netip.Addr OldValue interface{} NewValue interface{} Timestamp time.Time diff --git a/pkg/common/ipaddr.go b/pkg/common/ipaddr.go new file mode 100644 index 00000000..0e231e70 --- /dev/null +++ b/pkg/common/ipaddr.go @@ -0,0 +1,18 @@ +package common + +import "net/netip" + +const ( + ipv4MaskBits = 24 + ipv6MaskBits = 48 +) + +// MaskIPAddress masks an IP address for privacy by zeroing the last octet for IPv4 +// or last 80 bits for IPv6. Returns the masked address. +func MaskIPAddress(ip netip.Addr) netip.Addr { + if ip.Is4() { + return netip.PrefixFrom(ip, ipv4MaskBits).Masked().Addr() + } + + return netip.PrefixFrom(ip, ipv6MaskBits).Masked().Addr() +} diff --git a/pkg/db/audit.go b/pkg/db/audit.go index 1c4f9442..ca550488 100644 --- a/pkg/db/audit.go +++ b/pkg/db/audit.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "net/netip" "time" "github.com/PrivateCaptcha/PrivateCaptcha/pkg/common" @@ -73,6 +74,11 @@ func (al *AuditLog) PersistAuditLog(ctx context.Context, batch []*common.AuditLo source = dbgen.AuditLogSourceApi } + var ipAddress *netip.Addr + if e.IPAddress.IsValid() { + ipAddress = &e.IPAddress + } + event := &dbgen.CreateAuditLogsParams{ UserID: Int(e.UserID), Action: action, @@ -83,6 +89,7 @@ func (al *AuditLog) PersistAuditLog(ctx context.Context, batch []*common.AuditLo OldValue: nil, NewValue: nil, CreatedAt: Timestampz(e.Timestamp), + IpAddress: ipAddress, } if e.OldValue != nil { @@ -150,6 +157,12 @@ func (al *AuditLog) RecordEvent(ctx context.Context, event *common.AuditLogEvent event.SessionID = sid } + if ip, ok := ctx.Value(common.RateLimitKeyContextKey).(netip.Addr); ok && ip.IsValid() { + event.IPAddress = common.MaskIPAddress(ip) + } else { + slog.ErrorContext(ctx, "IP address not found in request context for audit log event", "table", event.TableName, "entityID", event.EntityID, "action", event.Action.String()) + } + event.Timestamp = time.Now().UTC() event.Source = source diff --git a/pkg/db/generated/auditlog.sql.go b/pkg/db/generated/auditlog.sql.go index 5cb89a27..99008bac 100644 --- a/pkg/db/generated/auditlog.sql.go +++ b/pkg/db/generated/auditlog.sql.go @@ -7,6 +7,7 @@ package generated import ( "context" + "net/netip" "github.com/jackc/pgx/v5/pgtype" ) @@ -21,6 +22,7 @@ type CreateAuditLogsParams struct { OldValue []byte `db:"old_value" json:"old_value"` NewValue []byte `db:"new_value" json:"new_value"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + IpAddress *netip.Addr `db:"ip_address" json:"ip_address"` } const deleteOldAuditLogs = `-- name: DeleteOldAuditLogs :exec @@ -33,7 +35,7 @@ func (q *Queries) DeleteOldAuditLogs(ctx context.Context, createdAt pgtype.Times } const getOrgAuditLogs = `-- name: GetOrgAuditLogs :many -SELECT a.id, a.user_id, a.action, a.entity_id, a.entity_table, a.session_id, a.old_value, a.new_value, a.created_at, a.source, u.name, u.email +SELECT a.id, a.user_id, a.action, a.entity_id, a.entity_table, a.session_id, a.old_value, a.new_value, a.created_at, a.source, a.ip_address, u.name, u.email FROM backend.audit_logs a LEFT JOIN backend.users u ON u.id = a.user_id WHERE ( @@ -87,6 +89,7 @@ func (q *Queries) GetOrgAuditLogs(ctx context.Context, arg *GetOrgAuditLogsParam &i.AuditLog.NewValue, &i.AuditLog.CreatedAt, &i.AuditLog.Source, + &i.AuditLog.IpAddress, &i.Name, &i.Email, ); err != nil { @@ -101,7 +104,7 @@ func (q *Queries) GetOrgAuditLogs(ctx context.Context, arg *GetOrgAuditLogsParam } const getPropertyAuditLogs = `-- name: GetPropertyAuditLogs :many -SELECT a.id, a.user_id, a.action, a.entity_id, a.entity_table, a.session_id, a.old_value, a.new_value, a.created_at, a.source, u.name, u.email +SELECT a.id, a.user_id, a.action, a.entity_id, a.entity_table, a.session_id, a.old_value, a.new_value, a.created_at, a.source, a.ip_address, u.name, u.email FROM backend.audit_logs a LEFT JOIN backend.users u ON u.id = a.user_id WHERE a.entity_table = 'properties' AND a.entity_id = $1 AND a.created_at >= $2 @@ -148,6 +151,7 @@ func (q *Queries) GetPropertyAuditLogs(ctx context.Context, arg *GetPropertyAudi &i.AuditLog.NewValue, &i.AuditLog.CreatedAt, &i.AuditLog.Source, + &i.AuditLog.IpAddress, &i.Name, &i.Email, ); err != nil { @@ -162,7 +166,7 @@ func (q *Queries) GetPropertyAuditLogs(ctx context.Context, arg *GetPropertyAudi } const getUserAuditLogs = `-- name: GetUserAuditLogs :many -SELECT a.id, a.user_id, a.action, a.entity_id, a.entity_table, a.session_id, a.old_value, a.new_value, a.created_at, a.source, u.name, u.email +SELECT a.id, a.user_id, a.action, a.entity_id, a.entity_table, a.session_id, a.old_value, a.new_value, a.created_at, a.source, a.ip_address, u.name, u.email FROM backend.audit_logs a LEFT JOIN backend.users u ON u.id = a.user_id WHERE (a.user_id = $1 OR @@ -222,6 +226,7 @@ func (q *Queries) GetUserAuditLogs(ctx context.Context, arg *GetUserAuditLogsPar &i.AuditLog.NewValue, &i.AuditLog.CreatedAt, &i.AuditLog.Source, + &i.AuditLog.IpAddress, &i.Name, &i.Email, ); err != nil { diff --git a/pkg/db/generated/copyfrom.go b/pkg/db/generated/copyfrom.go index 152363db..d7b7ab74 100644 --- a/pkg/db/generated/copyfrom.go +++ b/pkg/db/generated/copyfrom.go @@ -38,6 +38,7 @@ func (r iteratorForCreateAuditLogs) Values() ([]interface{}, error) { r.rows[0].OldValue, r.rows[0].NewValue, r.rows[0].CreatedAt, + r.rows[0].IpAddress, }, nil } @@ -46,5 +47,5 @@ func (r iteratorForCreateAuditLogs) Err() error { } func (q *Queries) CreateAuditLogs(ctx context.Context, arg []*CreateAuditLogsParams) (int64, error) { - return q.db.CopyFrom(ctx, []string{"backend", "audit_logs"}, []string{"user_id", "action", "source", "entity_id", "entity_table", "session_id", "old_value", "new_value", "created_at"}, &iteratorForCreateAuditLogs{rows: arg}) + return q.db.CopyFrom(ctx, []string{"backend", "audit_logs"}, []string{"user_id", "action", "source", "entity_id", "entity_table", "session_id", "old_value", "new_value", "created_at", "ip_address"}, &iteratorForCreateAuditLogs{rows: arg}) } diff --git a/pkg/db/generated/models.go b/pkg/db/generated/models.go index 008aef64..d2772b07 100644 --- a/pkg/db/generated/models.go +++ b/pkg/db/generated/models.go @@ -7,6 +7,7 @@ package generated import ( "database/sql/driver" "fmt" + "net/netip" "github.com/jackc/pgx/v5/pgtype" "time" @@ -318,6 +319,7 @@ type AuditLog struct { NewValue []byte `db:"new_value" json:"new_value"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` Source AuditLogSource `db:"source" json:"source"` + IpAddress *netip.Addr `db:"ip_address" json:"ip_address"` } type Cache struct { diff --git a/pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.down.sql b/pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.down.sql new file mode 100644 index 00000000..54d106bc --- /dev/null +++ b/pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.down.sql @@ -0,0 +1 @@ +ALTER TABLE backend.audit_logs DROP COLUMN IF EXISTS ip_address; diff --git a/pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.up.sql b/pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.up.sql new file mode 100644 index 00000000..5e8b9e17 --- /dev/null +++ b/pkg/db/migrations/postgres/000121_add_audit_logs_ip_address_column.up.sql @@ -0,0 +1 @@ +ALTER TABLE backend.audit_logs ADD COLUMN ip_address INET; diff --git a/pkg/db/queries/postgres/auditlog.sql b/pkg/db/queries/postgres/auditlog.sql index ab4d5e4f..e82651ad 100644 --- a/pkg/db/queries/postgres/auditlog.sql +++ b/pkg/db/queries/postgres/auditlog.sql @@ -1,6 +1,6 @@ -- name: CreateAuditLogs :copyfrom -INSERT INTO backend.audit_logs (user_id, action, source, entity_id, entity_table, session_id, old_value, new_value, created_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); +INSERT INTO backend.audit_logs (user_id, action, source, entity_id, entity_table, session_id, old_value, new_value, created_at, ip_address) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); -- name: DeleteOldAuditLogs :exec DELETE FROM backend.audit_logs WHERE created_at < $1; diff --git a/widget/package-lock.json b/widget/package-lock.json index 36a4e673..680e97d4 100644 --- a/widget/package-lock.json +++ b/widget/package-lock.json @@ -671,6 +671,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -939,6 +940,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2065,7 +2067,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -2271,6 +2274,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1",