Files
coder/coderd/audit/request.go
T
Ethan 08e17a07fc chore!: route connection logs to new table (#18340)
### Breaking Change (changelog note):
> User connections to workspaces, and the opening of workspace apps or ports will no longer create entries in the audit log. Those events will now be included in the 'Connection Log'.
Please see the 'Connection Log' page in the dashboard, and the Connection Log [documentation](https://coder.com/docs/admin/monitoring/connection-logs) for details. Those with permission to view the Audit Log will also be able to view the Connection Log. The new Connection Log has the same licensing restrictions as the Audit Log, and requires a Premium Coder deployment.

### Context

This is the first PR of a few for moving connection events out of the audit log, and into a new database table and web UI page called the 'Connection Log'.

This PR:
- Creates the new table
- Adds and tests queries for inserting and reading, including reading with an RBAC filter.
- Implements the corresponding RBAC changes, such that anyone who can view the audit log can read from the table
- Implements, under the enterprise package, a `ConnectionLogger` abstraction to replace the `Auditor` abstraction for these logs. (No-op'd in AGPL, like the `Auditor`)
- Routes SSH connection and Workspace App events into the new `ConnectionLogger`
- Updates all existing tests to check the values of the `ConnectionLogger` instead of the `Auditor`.

Future PRs:
- Add filtering to the query
- Add an enterprise endpoint to query the new table
- Write a query to delete old events from the audit log, call it from dbpurge.
- Implement a table in the Web UI for viewing connection logs.


> [!NOTE]
> The PRs in this stack obviously won't be (completely) atomic. Whilst they'll each pass CI, the stack is designed to be merged all at once. I'm splitting them up for the sake of those reviewing, and so changes can be reviewed as early as possible.  Despite this, it's really hard to make this PR any smaller than it already is. I'll be keeping it in draft until it's actually ready to merge.
2025-07-15 14:36:06 +10:00

582 lines
17 KiB
Go

package audit
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel/baggage"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/tracing"
)
type RequestParams struct {
Audit Auditor
Log slog.Logger
// OrganizationID is only provided when possible. If an audit resource extends
// beyond the org scope, leave this as the nil uuid.
OrganizationID uuid.UUID
Request *http.Request
Action database.AuditAction
AdditionalFields interface{}
}
type Request[T Auditable] struct {
params *RequestParams
Old T
New T
// UserID is an optional field can be passed in when the userID cannot be
// determined from the API Key such as in the case of login, when the audit
// log is created prior the API Key's existence.
UserID uuid.UUID
// Action is an optional field can be passed in if the AuditAction must be
// overridden such as in the case of new user authentication when the Audit
// Action is 'register', not 'login'.
Action database.AuditAction
}
// UpdateOrganizationID can be used if the organization ID is not known
// at the initiation of an audit log request.
func (r *Request[T]) UpdateOrganizationID(id uuid.UUID) {
r.params.OrganizationID = id
}
type BackgroundAuditParams[T Auditable] struct {
Audit Auditor
Log slog.Logger
UserID uuid.UUID
RequestID uuid.UUID
Time time.Time
Status int
Action database.AuditAction
OrganizationID uuid.UUID
IP string
UserAgent string
// todo: this should automatically marshal an interface{} instead of accepting a raw message.
AdditionalFields json.RawMessage
New T
Old T
}
func ResourceTarget[T Auditable](tgt T) string {
switch typed := any(tgt).(type) {
case database.Template:
return typed.Name
case database.TemplateVersion:
return typed.Name
case database.User:
return typed.Username
case database.WorkspaceTable:
return typed.Name
case database.WorkspaceBuild:
// this isn't used
return ""
case database.GitSSHKey:
return typed.PublicKey
case database.AuditableGroup:
return typed.Group.Name
case database.APIKey:
if typed.TokenName != "nil" {
return typed.TokenName
}
// API Keys without names are used for auth
// and don't have a target
return ""
case database.License:
return strconv.Itoa(int(typed.ID))
case database.WorkspaceProxy:
return typed.Name
case database.AuditOAuthConvertState:
return string(typed.ToLoginType)
case database.HealthSettings:
return "" // no target?
case database.NotificationsSettings:
return "" // no target?
case database.PrebuildsSettings:
return "" // no target?
case database.OAuth2ProviderApp:
return typed.Name
case database.OAuth2ProviderAppSecret:
return typed.DisplaySecret
case database.CustomRole:
return typed.Name
case database.AuditableOrganizationMember:
return typed.Username
case database.Organization:
return typed.Name
case database.NotificationTemplate:
return typed.Name
case idpsync.OrganizationSyncSettings:
return "Organization Sync"
case idpsync.GroupSyncSettings:
return "Organization Group Sync"
case idpsync.RoleSyncSettings:
return "Organization Role Sync"
case database.WorkspaceAgent:
return typed.Name
case database.WorkspaceApp:
return typed.Slug
default:
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
}
}
// noID can be used for resources that do not have an uuid.
// An example is singleton configuration resources.
// 51A51C = "Static"
var noID = uuid.MustParse("51A51C00-0000-0000-0000-000000000000")
func ResourceID[T Auditable](tgt T) uuid.UUID {
switch typed := any(tgt).(type) {
case database.Template:
return typed.ID
case database.TemplateVersion:
return typed.ID
case database.User:
return typed.ID
case database.WorkspaceTable:
return typed.ID
case database.WorkspaceBuild:
return typed.ID
case database.GitSSHKey:
return typed.UserID
case database.AuditableGroup:
return typed.Group.ID
case database.APIKey:
return typed.UserID
case database.License:
return typed.UUID
case database.WorkspaceProxy:
return typed.ID
case database.AuditOAuthConvertState:
// The merge state is for the given user
return typed.UserID
case database.HealthSettings:
// Artificial ID for auditing purposes
return typed.ID
case database.NotificationsSettings:
// Artificial ID for auditing purposes
return typed.ID
case database.PrebuildsSettings:
// Artificial ID for auditing purposes
return typed.ID
case database.OAuth2ProviderApp:
return typed.ID
case database.OAuth2ProviderAppSecret:
return typed.ID
case database.CustomRole:
return typed.ID
case database.AuditableOrganizationMember:
return typed.UserID
case database.Organization:
return typed.ID
case database.NotificationTemplate:
return typed.ID
case idpsync.OrganizationSyncSettings:
return noID // Deployment all uses the same org sync settings
case idpsync.GroupSyncSettings:
return noID // Org field on audit log has org id
case idpsync.RoleSyncSettings:
return noID // Org field on audit log has org id
case database.WorkspaceAgent:
return typed.ID
case database.WorkspaceApp:
return typed.ID
default:
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
}
}
func ResourceType[T Auditable](tgt T) database.ResourceType {
switch typed := any(tgt).(type) {
case database.Template:
return database.ResourceTypeTemplate
case database.TemplateVersion:
return database.ResourceTypeTemplateVersion
case database.User:
return database.ResourceTypeUser
case database.WorkspaceTable:
return database.ResourceTypeWorkspace
case database.WorkspaceBuild:
return database.ResourceTypeWorkspaceBuild
case database.GitSSHKey:
return database.ResourceTypeGitSshKey
case database.AuditableGroup:
return database.ResourceTypeGroup
case database.APIKey:
return database.ResourceTypeApiKey
case database.License:
return database.ResourceTypeLicense
case database.WorkspaceProxy:
return database.ResourceTypeWorkspaceProxy
case database.AuditOAuthConvertState:
return database.ResourceTypeConvertLogin
case database.HealthSettings:
return database.ResourceTypeHealthSettings
case database.NotificationsSettings:
return database.ResourceTypeNotificationsSettings
case database.PrebuildsSettings:
return database.ResourceTypePrebuildsSettings
case database.OAuth2ProviderApp:
return database.ResourceTypeOauth2ProviderApp
case database.OAuth2ProviderAppSecret:
return database.ResourceTypeOauth2ProviderAppSecret
case database.CustomRole:
return database.ResourceTypeCustomRole
case database.AuditableOrganizationMember:
return database.ResourceTypeOrganizationMember
case database.Organization:
return database.ResourceTypeOrganization
case database.NotificationTemplate:
return database.ResourceTypeNotificationTemplate
case idpsync.OrganizationSyncSettings:
return database.ResourceTypeIdpSyncSettingsOrganization
case idpsync.RoleSyncSettings:
return database.ResourceTypeIdpSyncSettingsRole
case idpsync.GroupSyncSettings:
return database.ResourceTypeIdpSyncSettingsGroup
case database.WorkspaceAgent:
return database.ResourceTypeWorkspaceAgent
case database.WorkspaceApp:
return database.ResourceTypeWorkspaceApp
default:
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
}
}
// ResourceRequiresOrgID will ensure given resources are always audited with an
// organization ID.
func ResourceRequiresOrgID[T Auditable]() bool {
var tgt T
switch any(tgt).(type) {
case database.Template, database.TemplateVersion:
return true
case database.WorkspaceTable, database.WorkspaceBuild:
return true
case database.AuditableGroup:
return true
case database.User:
return false
case database.GitSSHKey:
return false
case database.APIKey:
return false
case database.License:
return false
case database.WorkspaceProxy:
return false
case database.AuditOAuthConvertState:
// The merge state is for the given user
return false
case database.HealthSettings:
// Artificial ID for auditing purposes
return false
case database.NotificationsSettings:
// Artificial ID for auditing purposes
return false
case database.PrebuildsSettings:
// Artificial ID for auditing purposes
return false
case database.OAuth2ProviderApp:
return false
case database.OAuth2ProviderAppSecret:
return false
case database.CustomRole:
return true
case database.AuditableOrganizationMember:
return true
case database.Organization:
return true
case database.NotificationTemplate:
return false
case idpsync.OrganizationSyncSettings:
return false
case idpsync.GroupSyncSettings:
return true
case idpsync.RoleSyncSettings:
return true
case database.WorkspaceAgent:
return true
case database.WorkspaceApp:
return true
default:
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
}
}
// requireOrgID will either panic (in unit tests) or log an error (in production)
// if the given resource requires an organization ID and the provided ID is nil.
func requireOrgID[T Auditable](ctx context.Context, id uuid.UUID, log slog.Logger) uuid.UUID {
if ResourceRequiresOrgID[T]() && id == uuid.Nil {
var tgt T
resourceName := fmt.Sprintf("%T", tgt)
if flag.Lookup("test.v") != nil {
// In unit tests we panic to fail the tests
panic(fmt.Sprintf("missing required organization ID for resource %q", resourceName))
}
log.Error(ctx, "missing required organization ID for resource in audit log",
slog.F("resource", resourceName),
)
}
return id
}
// InitRequestWithCancel returns a commit function with a boolean arg.
// If the arg is false, future calls to commit() will not create an audit log
// entry.
func InitRequestWithCancel[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func(commit bool)) {
req, commitF := InitRequest[T](w, p)
canceled := false
return req, func(commit bool) {
// Once 'commit=false' is called, block
// any future commit attempts.
if !commit {
canceled = true
return
}
// If it was ever canceled, block any commits
if !canceled {
commitF()
}
}
}
// InitRequest initializes an audit log for a request. It returns a function
// that should be deferred, causing the audit log to be committed when the
// handler returns.
func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func()) {
sw, ok := w.(*tracing.StatusWriter)
if !ok {
panic("dev error: http.ResponseWriter is not *tracing.StatusWriter")
}
req := &Request[T]{
params: p,
}
return req, func() {
ctx := context.Background()
logCtx := p.Request.Context()
// If no resources were provided, there's nothing we can audit.
if ResourceID(req.Old) == uuid.Nil && ResourceID(req.New) == uuid.Nil {
// If the request action is a login or logout, we always want to audit it even if
// there is no diff. This is so we can capture events where an API Key is never created
// because a known user fails to login.
if req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
return
}
}
diffRaw := []byte("{}")
// Only generate diffs if the request succeeded
// and only if we aren't auditing authentication actions
if sw.Status < 400 &&
req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
diff := Diff(p.Audit, req.Old, req.New)
var err error
diffRaw, err = json.Marshal(diff)
if err != nil {
p.Log.Warn(logCtx, "marshal diff", slog.Error(err))
diffRaw = []byte("{}")
}
}
additionalFieldsRaw := json.RawMessage("{}")
if p.AdditionalFields != nil {
data, err := json.Marshal(p.AdditionalFields)
if err != nil {
p.Log.Warn(logCtx, "marshal additional fields", slog.Error(err))
} else {
additionalFieldsRaw = json.RawMessage(data)
}
}
var userID uuid.UUID
key, ok := httpmw.APIKeyOptional(p.Request)
switch {
case ok:
userID = key.UserID
case req.UserID != uuid.Nil:
userID = req.UserID
default:
// if we do not have a user associated with the audit action
// we do not want to audit
// (this pertains to logins; we don't want to capture non-user login attempts)
return
}
action := p.Action
if req.Action != "" {
action = req.Action
}
ip := database.ParseIP(p.Request.RemoteAddr)
auditLog := database.AuditLog{
ID: uuid.New(),
Time: dbtime.Now(),
UserID: userID,
Ip: ip,
UserAgent: sql.NullString{String: p.Request.UserAgent(), Valid: true},
ResourceType: either(req.Old, req.New, ResourceType[T], req.params.Action),
ResourceID: either(req.Old, req.New, ResourceID[T], req.params.Action),
ResourceTarget: either(req.Old, req.New, ResourceTarget[T], req.params.Action),
Action: action,
Diff: diffRaw,
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
StatusCode: int32(sw.Status),
RequestID: httpmw.RequestID(p.Request),
AdditionalFields: additionalFieldsRaw,
OrganizationID: requireOrgID[T](logCtx, p.OrganizationID, p.Log),
}
err := p.Audit.Export(ctx, auditLog)
if err != nil {
p.Log.Error(logCtx, "export audit log",
slog.F("audit_log", auditLog),
slog.Error(err),
)
return
}
}
}
// BackgroundAudit creates an audit log for a background event.
// The audit log is committed upon invocation.
func BackgroundAudit[T Auditable](ctx context.Context, p *BackgroundAuditParams[T]) {
ip := database.ParseIP(p.IP)
diff := Diff(p.Audit, p.Old, p.New)
var err error
diffRaw, err := json.Marshal(diff)
if err != nil {
p.Log.Warn(ctx, "marshal diff", slog.Error(err))
diffRaw = []byte("{}")
}
if p.Time.IsZero() {
p.Time = dbtime.Now()
} else {
// NOTE(mafredri): dbtime.Time does not currently enforce UTC.
p.Time = dbtime.Time(p.Time.In(time.UTC))
}
if p.AdditionalFields == nil {
p.AdditionalFields = json.RawMessage("{}")
}
auditLog := database.AuditLog{
ID: uuid.New(),
Time: p.Time,
UserID: p.UserID,
OrganizationID: requireOrgID[T](ctx, p.OrganizationID, p.Log),
Ip: ip,
UserAgent: sql.NullString{Valid: p.UserAgent != "", String: p.UserAgent},
ResourceType: either(p.Old, p.New, ResourceType[T], p.Action),
ResourceID: either(p.Old, p.New, ResourceID[T], p.Action),
ResourceTarget: either(p.Old, p.New, ResourceTarget[T], p.Action),
Action: p.Action,
Diff: diffRaw,
// #nosec G115 - Safe conversion as HTTP status code is expected to be within int32 range (typically 100-599)
StatusCode: int32(p.Status),
RequestID: p.RequestID,
AdditionalFields: p.AdditionalFields,
}
err = p.Audit.Export(ctx, auditLog)
if err != nil {
p.Log.Error(ctx, "export audit log",
slog.F("audit_log", auditLog),
slog.Error(err),
)
}
}
type WorkspaceBuildBaggage struct {
IP string
}
func (b WorkspaceBuildBaggage) Props() ([]baggage.Property, error) {
ipProp, err := baggage.NewKeyValueProperty("ip", b.IP)
if err != nil {
return nil, xerrors.Errorf("create ip kv property: %w", err)
}
return []baggage.Property{ipProp}, nil
}
func WorkspaceBuildBaggageFromRequest(r *http.Request) WorkspaceBuildBaggage {
return WorkspaceBuildBaggage{IP: r.RemoteAddr}
}
type Baggage interface {
Props() ([]baggage.Property, error)
}
func BaggageToContext(ctx context.Context, d Baggage) (context.Context, error) {
props, err := d.Props()
if err != nil {
return ctx, xerrors.Errorf("create baggage properties: %w", err)
}
m, err := baggage.NewMember("audit", "baggage", props...)
if err != nil {
return ctx, xerrors.Errorf("create new baggage member: %w", err)
}
b, err := baggage.New(m)
if err != nil {
return ctx, xerrors.Errorf("create new baggage carrier: %w", err)
}
return baggage.ContextWithBaggage(ctx, b), nil
}
func BaggageFromContext(ctx context.Context) WorkspaceBuildBaggage {
d := WorkspaceBuildBaggage{}
b := baggage.FromContext(ctx)
props := b.Member("audit").Properties()
for _, prop := range props {
switch prop.Key() {
case "ip":
d.IP, _ = prop.Value()
default:
}
}
return d
}
func either[T Auditable, R any](old, newVal T, fn func(T) R, auditAction database.AuditAction) R {
switch {
case ResourceID(newVal) != uuid.Nil:
return fn(newVal)
case ResourceID(old) != uuid.Nil:
return fn(old)
case auditAction == database.AuditActionLogin || auditAction == database.AuditActionLogout:
// If the request action is a login or logout, we always want to audit it even if
// there is no diff. See the comment in audit.InitRequest for more detail.
return fn(old)
default:
panic("both old and new are nil")
}
}