Files
pad/internal/models/activity.go
T
xarmian 3f69b76b06 feat(security): enforce session UA binding under strict mode (TASK-2056) (#912)
Session IP/User-Agent binding was log-only by default, so a stolen
session token granted durable any-origin access. IP-change enforcement
already existed behind PAD_IP_CHANGE_ENFORCE=strict; this extends the
same single toggle to also enforce the User-Agent-hash binding.

When strict enforce is ON, a request whose client IP OR User-Agent hash
no longer matches the session's stored binding now revokes the session
(DeleteSessionIfExists) and rejects the request (401 for API,
revoked-passthrough for public/browser paths), killing the stolen token.
When enforce is OFF (default), behavior is unchanged: UA mismatch is
logged (slog only, no new audit row) and the request proceeds, so
existing self-host users see no behavior change and routine client churn
(browser/WebView updates, DevTools emulation, mobile-app rebuilds) is
tolerated.

The UA hash is stable within a real session, so UA-mismatch enforce
carries fewer false positives than IP enforce (mobile roaming, VPN
toggles, carrier NAT) — documented in the handler comment. Adds the
ActionSessionUAChanged audit action, emitted only in strict mode.

No DB migration: reuses the existing IPChangeEnforce config flag and the
existing session store primitives.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 23:32:22 -04:00

142 lines
6.1 KiB
Go

package models
import "time"
// Item-level actions (existing)
var ValidActions = []string{
"created", "updated", "archived", "restored", "moved", "read", "searched",
}
// Audit action constants for auth/admin events
const (
ActionLogin = "login"
ActionLoginFailed = "login_failed"
ActionLogout = "logout"
ActionBootstrap = "bootstrap"
ActionRegister = "register"
ActionPasswordChanged = "password_changed"
ActionPasswordReset = "password_reset"
ActionTokenCreated = "token_created"
ActionTokenRevoked = "token_revoked"
ActionTokenRotated = "token_rotated"
ActionTOTPEnabled = "totp_enabled"
ActionTOTPDisabled = "totp_disabled"
ActionMemberInvited = "member_invited"
ActionMemberRemoved = "member_removed"
ActionRoleChanged = "role_changed"
ActionSettingsChanged = "settings_changed"
ActionOAuthLogin = "oauth_login"
ActionOAuthLoginFailed = "oauth_login_failed"
ActionPlanChanged = "plan_changed"
// ActionPlanOverridesChanged is logged when an admin updates a
// user's plan_overrides JSON via the admin user-detail page.
// Surfaces per-user storage / workspace / API-token quota
// overrides in the audit feed so operators can correlate a
// mysteriously-allowed upload with the override that enabled it.
ActionPlanOverridesChanged = "plan_overrides_changed"
ActionPasswordResetByAdmin = "password_reset_by_admin"
ActionUserDisabled = "user_disabled"
ActionUserEnabled = "user_enabled"
ActionAccountDeleted = "account_deleted"
// ActionEmailVerified is logged when a user confirms their email address
// via a verification link (POST /auth/verify-email). PLAN-1933 / TASK-1936.
ActionEmailVerified = "email_verified"
// ActionEmailVerifiedByAdmin is logged when an admin force-verifies a
// user's email from the admin console (DR-7). PLAN-1933 / TASK-1936.
ActionEmailVerifiedByAdmin = "email_verified_by_admin"
// ActionSessionIPChanged is logged when a session presents a different
// client IP than the one recorded at creation. We don't strict-check IP
// by default (that breaks legitimate geo shifts — VPN toggle, mobile
// roaming) but surface the change to the audit log for detection. In
// deployments configured with PAD_IP_CHANGE_ENFORCE=strict the middleware
// additionally rejects the request.
ActionSessionIPChanged = "session_ip_changed"
// ActionSessionUAChanged is logged when a session presents a different
// User-Agent hash than the one recorded at creation. Like the IP signal
// this is surfaced to the audit log for detection; in deployments
// configured with PAD_IP_CHANGE_ENFORCE=strict the middleware additionally
// revokes the session and rejects the request. The UA hash is stable for
// the life of a real session (a browser doesn't rewrite its own UA mid-
// session), so a mismatch is a stronger theft signal than an IP change —
// which is precisely why UA enforcement carries fewer false positives than
// IP enforcement. This audit row is only emitted in strict mode; log-only
// mode keeps the historical slog-only behavior to avoid changing the audit
// feed for existing self-host users.
ActionSessionUAChanged = "session_ua_changed"
// ActionStripeEventUnmarked is logged when /admin/stripe-event-unmark
// rolls back a row from stripe_processed_events (TASK-736). The
// endpoint intentionally reopens Stripe retry windows, so a persisted
// audit trail is required — a compromised cloud_secret could otherwise
// spam unmarks invisible to the admin /audit-log UI.
ActionStripeEventUnmarked = "stripe_event_unmarked"
// ActionPaymentFailedEmailSent is logged when the sidecar triggers the
// /admin/payment-failed endpoint and pad dispatches a failed-payment
// notification to the user. Audit trail exists so operators can prove
// a customer was notified before a dunning-related plan change.
ActionPaymentFailedEmailSent = "payment_failed_email_sent"
)
type Activity struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
DocumentID string `json:"document_id,omitempty"`
Action string `json:"action"`
Actor string `json:"actor"`
Source string `json:"source"`
Metadata string `json:"metadata,omitempty"` // JSON
UserID string `json:"user_id,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Enrichment fields — populated by handlers, not stored in DB
ItemTitle string `json:"item_title,omitempty"`
ItemSlug string `json:"item_slug,omitempty"`
ItemRef string `json:"item_ref,omitempty"` // e.g. "BUG-1748" — computed from the referenced item
CollectionSlug string `json:"collection_slug,omitempty"`
ActorName string `json:"actor_name,omitempty"`
}
type ActivityListParams struct {
Action string
Actor string
Source string
// Since, when non-zero, restricts results to activity created on or
// after this instant (a.created_at >= Since). Applied in the SQL query
// so LIMIT counts post-filter rows. Used by `pad project activity
// --since` and the pad_project.activity MCP action.
Since time.Time
Limit int
Offset int
}
// AuditLogParams are query parameters for the audit log endpoint.
type AuditLogParams struct {
Action string
Actor string
WorkspaceID string
Days int
Limit int
Offset int
}
// TimelineEntry represents a single entry in the unified item timeline.
// It wraps one of: a comment, an activity, or a version.
type TimelineEntry struct {
ID string `json:"id"`
Kind string `json:"kind"` // "comment", "activity", "version"
CreatedAt time.Time `json:"created_at"`
Actor string `json:"actor"`
ActorName string `json:"actor_name,omitempty"`
Source string `json:"source"`
Comment *Comment `json:"comment,omitempty"`
Activity *Activity `json:"activity,omitempty"`
Version *Version `json:"version,omitempty"`
}
// TimelineResponse is the paginated response from the timeline endpoint.
type TimelineResponse struct {
Entries []TimelineEntry `json:"entries"`
HasMore bool `json:"has_more"`
}