mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
08e17a07fc
### 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.
582 lines
17 KiB
Go
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")
|
|
}
|
|
}
|