Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/core/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type AuditLogEntry struct {
Severity models.EventSeverity
ActorUserID string
ActorUsername string
ActorFullName string
ActorIP string
ResourceType models.ResourceType
ResourceID string
Expand Down
2 changes: 2 additions & 0 deletions internal/handlers/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ func (h *AuditHandler) ExportAuditLogs(c *gin.Context) {
"Event Type",
"Severity",
"Actor Username",
"Actor Full Name",
"Actor IP",
"Resource Type",
"Resource Name",
Expand All @@ -247,6 +248,7 @@ func (h *AuditHandler) ExportAuditLogs(c *gin.Context) {
string(log.EventType),
string(log.Severity),
log.ActorUsername,
log.ActorFullName,
log.ActorIP,
string(log.ResourceType),
log.ResourceName,
Expand Down
1 change: 1 addition & 0 deletions internal/models/audit_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ type AuditLog struct {
// Actor information
ActorUserID string `gorm:"type:varchar(36);index" json:"actor_user_id"`
ActorUsername string `gorm:"type:varchar(100)" json:"actor_username"`
ActorFullName string `gorm:"type:varchar(100)" json:"actor_full_name"`
ActorIP string `gorm:"type:varchar(45);index" json:"actor_ip"` // Support IPv6

// Resource information
Expand Down
73 changes: 52 additions & 21 deletions internal/services/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"

"github.com/go-authgate/authgate/internal/core"
"github.com/go-authgate/authgate/internal/models"
Expand Down Expand Up @@ -192,6 +193,17 @@ func (s *AuditService) flushBatchUnsafe() {
}
}

// clampToColumn returns s unchanged when it already fits within limit runes,
// otherwise it truncates to limit runes (the trailing 3 reserved for the "..."
// that TruncateString appends). This preserves values that are within the
// varchar column width verbatim instead of needlessly truncating them.
func clampToColumn(s string, limit int) string {
if utf8.RuneCountInString(s) <= limit {
return s
}
return util.TruncateString(s, limit-3)
}

// buildAuditLog enriches an AuditLogEntry from context and builds the database record.
func (s *AuditService) buildAuditLog(
ctx context.Context,
Expand All @@ -200,27 +212,41 @@ func (s *AuditService) buildAuditLog(
if entry.ActorIP == "" {
entry.ActorIP = util.GetIPFromContext(ctx)
}
// Fill ActorUsername from context only when the entry's ActorUserID is
// empty or matches the context user — otherwise the username could be
// misattributed to a different principal than ActorUserID identifies.
if entry.ActorUsername == "" {
ctxUserID := models.GetUserIDFromContext(ctx)
if entry.ActorUserID == "" || entry.ActorUserID == ctxUserID {
entry.ActorUsername = models.GetUsernameFromContext(ctx)
// Fill the actor's username/full name from the context user, but only when
// the entry's ActorUserID is empty or matches that user — otherwise the
// values could be misattributed to a different principal than ActorUserID
// identifies. The context user is the authoritative, freshly-loaded row
// (see middleware.loadUserFromSession), so when it supplies the actor we
// skip the DB fallback entirely.
ctxUser := models.GetUserFromContext(ctx)
resolvedFromContext := ctxUser != nil &&
(entry.ActorUserID == "" || entry.ActorUserID == ctxUser.ID)
if resolvedFromContext {
if entry.ActorUsername == "" {
entry.ActorUsername = ctxUser.Username
}
if entry.ActorFullName == "" {
entry.ActorFullName = ctxUser.FullName
}
}
// Fall back to a DB lookup when context did not provide a username and
// the actor is a real user (not a synthetic machine identity from the
// client_credentials grant, which uses a "client:<clientID>" format and
// has no corresponding user row).
if entry.ActorUsername == "" && entry.ActorUserID != "" &&
!IsMachineUserID(entry.ActorUserID) {
// Fall back to a DB lookup only when the actor was not resolved from the
// context user (e.g. token grants that set ActorUserID from request data
// without a logged-in session) and is a real user — not a synthetic machine
// identity from the client_credentials grant, which uses a "client:<clientID>"
// format and has no corresponding user row.
if !resolvedFromContext && (entry.ActorUsername == "" || entry.ActorFullName == "") &&
entry.ActorUserID != "" && !IsMachineUserID(entry.ActorUserID) {
if user, err := s.store.GetUserByID(entry.ActorUserID); err == nil {
entry.ActorUsername = user.Username
if entry.ActorUsername == "" {
entry.ActorUsername = user.Username
}
if entry.ActorFullName == "" {
entry.ActorFullName = user.FullName
}
}
}
if entry.ActorUserID == "" {
entry.ActorUserID = models.GetUserIDFromContext(ctx)
if entry.ActorUserID == "" && ctxUser != nil {
entry.ActorUserID = ctxUser.ID
}
if entry.UserAgent == "" {
entry.UserAgent = util.GetUserAgentFromContext(ctx)
Expand All @@ -233,11 +259,15 @@ func (s *AuditService) buildAuditLog(
}
entry.Details = maskSensitiveDetails(entry.Details)

// Truncate fields to match database column size limits.
// TruncateString appends "..." (3 chars) when truncating, so subtract 3
// from the varchar limit to guarantee the final length fits the column.
entry.UserAgent = util.TruncateString(entry.UserAgent, 497)
entry.RequestPath = util.TruncateString(entry.RequestPath, 497)
// Clamp string fields to their varchar column widths. Values already within
// the limit are preserved verbatim; only over-long values are truncated
// (with an ellipsis). ActorUsername/ActorFullName mirror their unbounded
// User columns, so clamping them avoids INSERT errors on stricter drivers
// (e.g. Postgres) for over-long names.
entry.UserAgent = clampToColumn(entry.UserAgent, 500)
entry.RequestPath = clampToColumn(entry.RequestPath, 500)
entry.ActorUsername = clampToColumn(entry.ActorUsername, 100)
entry.ActorFullName = clampToColumn(entry.ActorFullName, 100)

// RequestMethod is stored in a varchar(10) column. Preserve values up to
// the full column width and hard-truncate anything longer without adding
Expand All @@ -254,6 +284,7 @@ func (s *AuditService) buildAuditLog(
Severity: entry.Severity,
ActorUserID: entry.ActorUserID,
ActorUsername: entry.ActorUsername,
ActorFullName: entry.ActorFullName,
ActorIP: entry.ActorIP,
Comment thread
appleboy marked this conversation as resolved.
ResourceType: entry.ResourceType,
ResourceID: entry.ResourceID,
Expand Down
79 changes: 79 additions & 0 deletions internal/services/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package services
import (
"context"
"fmt"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -100,6 +101,48 @@ func TestBuildAuditLog_EnrichesRequestMetadataFromContext(t *testing.T) {
assert.Equal(t, "POST", result.RequestMethod)
}

func TestBuildAuditLog_TruncatesOverlongActorNames(t *testing.T) {
// ActorUsername/ActorFullName mirror unbounded User columns but persist into
// varchar(100) audit columns, so over-long values must be clamped to avoid
// INSERT errors on stricter drivers (e.g. Postgres).
svc := &AuditService{}

entry := core.AuditLogEntry{
EventType: models.EventAccessTokenIssued,
Severity: models.SeverityInfo,
ActorUsername: strings.Repeat("u", 200),
ActorFullName: strings.Repeat("n", 200),
Action: "test",
Success: true,
}

result := svc.buildAuditLog(context.Background(), entry)

assert.LessOrEqual(t, len([]rune(result.ActorUsername)), 100)
assert.LessOrEqual(t, len([]rune(result.ActorFullName)), 100)
}

func TestBuildAuditLog_PreservesActorNamesWithinColumnWidth(t *testing.T) {
// A name that already fits the varchar(100) column must be stored verbatim,
// not needlessly truncated by the ellipsis-appending clamp.
svc := &AuditService{}

fullName := strings.Repeat("n", 100) // exactly the column width
entry := core.AuditLogEntry{
EventType: models.EventAccessTokenIssued,
Severity: models.SeverityInfo,
ActorUsername: "alice",
ActorFullName: fullName,
Action: "test",
Success: true,
}

result := svc.buildAuditLog(context.Background(), entry)

assert.Equal(t, "alice", result.ActorUsername)
assert.Equal(t, fullName, result.ActorFullName)
}

func TestBuildAuditLog_DoesNotOverrideExplicitValues(t *testing.T) {
svc := &AuditService{}

Expand Down Expand Up @@ -133,6 +176,7 @@ func TestBuildAuditLog_EnrichesUserFromContext(t *testing.T) {
user := &models.User{
ID: "user-123",
Username: "testuser",
FullName: "Test User",
}
ctx := models.SetUserContext(context.Background(), user)

Expand All @@ -147,6 +191,7 @@ func TestBuildAuditLog_EnrichesUserFromContext(t *testing.T) {

assert.Equal(t, "user-123", result.ActorUserID)
assert.Equal(t, "testuser", result.ActorUsername)
assert.Equal(t, "Test User", result.ActorFullName)
}

func TestBuildAuditLog_FillsActorUsernameFromDBFallback(t *testing.T) {
Expand All @@ -156,6 +201,7 @@ func TestBuildAuditLog_FillsActorUsernameFromDBFallback(t *testing.T) {
user := &models.User{
ID: "fallback-user-id",
Username: "fallback-user",
FullName: "Fallback User",
Email: "fallback@example.com",
PasswordHash: "x",
AuthSource: models.AuthSourceLocal,
Expand All @@ -176,6 +222,39 @@ func TestBuildAuditLog_FillsActorUsernameFromDBFallback(t *testing.T) {

assert.Equal(t, user.ID, result.ActorUserID)
assert.Equal(t, "fallback-user", result.ActorUsername)
assert.Equal(t, "Fallback User", result.ActorFullName)
}

func TestBuildAuditLog_FillsActorFullNameWhenUsernameAlreadySet(t *testing.T) {
// A call site that pre-sets ActorUserID + ActorUsername (no context user,
// e.g. a token grant with no logged-in session) must still backfill
// ActorFullName via the DB lookup even though ActorUsername is populated.
s := setupTestStore(t)
user := &models.User{
ID: "view-user-id",
Username: "view-user",
FullName: "View User",
Email: "view@example.com",
PasswordHash: "x",
AuthSource: models.AuthSourceLocal,
}
require.NoError(t, s.CreateUser(user))

svc := &AuditService{store: s}

entry := core.AuditLogEntry{
EventType: models.EventTypeAuditLogView,
Severity: models.SeverityInfo,
ActorUserID: user.ID,
ActorUsername: "view-user", // already set, full name intentionally empty
Action: "Viewed audit logs",
Success: true,
}

result := svc.buildAuditLog(context.Background(), entry)

assert.Equal(t, "view-user", result.ActorUsername)
assert.Equal(t, "View User", result.ActorFullName)
}

func TestBuildAuditLog_SkipsDBLookupForMachineIdentity(t *testing.T) {
Expand Down
7 changes: 5 additions & 2 deletions internal/store/audit_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ func (s *Store) GetAuditLogsPaginated(
if filters.Search != "" {
searchPattern := "%" + filters.Search + "%"
query = query.Where(
"action LIKE ? OR resource_name LIKE ? OR actor_username LIKE ?",
searchPattern, searchPattern, searchPattern,
"action LIKE ? OR resource_name LIKE ? OR actor_username LIKE ? OR actor_full_name LIKE ?",
searchPattern,
searchPattern,
searchPattern,
searchPattern,
)
}

Expand Down
16 changes: 16 additions & 0 deletions internal/store/audit_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func TestGetAuditLogsPaginated(t *testing.T) {
Severity: models.SeverityWarning,
ActorUserID: uuid.New().String(),
ActorUsername: "bob",
ActorFullName: "Wang Xiaoming",
ActorIP: "10.0.0.2",
ResourceType: models.ResourceUser,
Action: "login_fail",
Expand Down Expand Up @@ -189,6 +190,21 @@ func TestGetAuditLogsPaginated(t *testing.T) {
require.NoError(t, err)
assert.Len(t, result, 1)

// Filter by search (matches actor_full_name)
result, _, err = store.GetAuditLogsPaginated(params, AuditLogFilters{
Search: "Xiaoming",
})
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "bob", result[0].ActorUsername)

// Filter by search (no match)
result, _, err = store.GetAuditLogsPaginated(params, AuditLogFilters{
Search: "nonexistent-actor",
})
require.NoError(t, err)
assert.Empty(t, result)

// Filter by time range
result, _, err = store.GetAuditLogsPaginated(params, AuditLogFilters{
StartTime: now.Add(-90 * time.Minute),
Expand Down
4 changes: 2 additions & 2 deletions internal/templates/admin_audit_logs.templ
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ templ AdminAuditLogs(props AuditLogsPageProps) {
type="text"
name="search"
class="search-input"
placeholder="Search action, resource, username..."
placeholder="Search action, resource, username, full name..."
value={ props.Search }
data-debounce-search
/>
Expand Down Expand Up @@ -277,7 +277,7 @@ templ AuditLogRow(log *models.AuditLog) {
</td>
<td data-label="Actor">
if log.ActorUsername != "" {
<span class="audit-actor">{ log.ActorUsername }</span>
<span class="audit-actor">{ UserDisplay(log.ActorUsername, log.ActorFullName) }</span>
} else {
<span class="audit-actor-system">System</span>
}
Expand Down
2 changes: 1 addition & 1 deletion internal/templates/admin_dashboard.templ
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ templ AdminDashboard(props DashboardPageProps) {
</td>
<td data-label="Actor">
if log.ActorUsername != "" {
<span>{ log.ActorUsername }</span>
<span>{ UserDisplay(log.ActorUsername, log.ActorFullName) }</span>
} else {
<span style="color:var(--color-text-tertiary);font-style:italic;">system</span>
}
Expand Down
Loading