mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add support for workspace app audit (#16801)
This change adds support for workspace app auditing. To avoid audit log spam, we introduce the concept of app audit sessions. An audit session is unique per workspace app, user, ip, user agent and http status code. The sessions are stored in a separate table from audit logs to allow use-case specific optimizations. Sessions are ephemeral and the table does not function as a log. The logic for auditing is placed in the DBTokenProvider for workspace apps so that wsproxies are included. This is the final change affecting the API fo #15139. Updates #15139
This commit is contained in:
+211
-17
@@ -3,27 +3,32 @@ package workspaceapps
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -33,13 +38,15 @@ type DBTokenProvider struct {
|
||||
Logger slog.Logger
|
||||
|
||||
// DashboardURL is the main dashboard access URL for error pages.
|
||||
DashboardURL *url.URL
|
||||
Authorizer rbac.Authorizer
|
||||
Database database.Store
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
OAuth2Configs *httpmw.OAuth2Configs
|
||||
WorkspaceAgentInactiveTimeout time.Duration
|
||||
Keycache cryptokeys.SigningKeycache
|
||||
DashboardURL *url.URL
|
||||
Authorizer rbac.Authorizer
|
||||
Auditor *atomic.Pointer[audit.Auditor]
|
||||
Database database.Store
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
OAuth2Configs *httpmw.OAuth2Configs
|
||||
WorkspaceAgentInactiveTimeout time.Duration
|
||||
WorkspaceAppAuditSessionTimeout time.Duration
|
||||
Keycache cryptokeys.SigningKeycache
|
||||
}
|
||||
|
||||
var _ SignedTokenProvider = &DBTokenProvider{}
|
||||
@@ -47,25 +54,32 @@ var _ SignedTokenProvider = &DBTokenProvider{}
|
||||
func NewDBTokenProvider(log slog.Logger,
|
||||
accessURL *url.URL,
|
||||
authz rbac.Authorizer,
|
||||
auditor *atomic.Pointer[audit.Auditor],
|
||||
db database.Store,
|
||||
cfg *codersdk.DeploymentValues,
|
||||
oauth2Cfgs *httpmw.OAuth2Configs,
|
||||
workspaceAgentInactiveTimeout time.Duration,
|
||||
workspaceAppAuditSessionTimeout time.Duration,
|
||||
signer cryptokeys.SigningKeycache,
|
||||
) SignedTokenProvider {
|
||||
if workspaceAgentInactiveTimeout == 0 {
|
||||
workspaceAgentInactiveTimeout = 1 * time.Minute
|
||||
}
|
||||
if workspaceAppAuditSessionTimeout == 0 {
|
||||
workspaceAppAuditSessionTimeout = time.Hour
|
||||
}
|
||||
|
||||
return &DBTokenProvider{
|
||||
Logger: log,
|
||||
DashboardURL: accessURL,
|
||||
Authorizer: authz,
|
||||
Database: db,
|
||||
DeploymentValues: cfg,
|
||||
OAuth2Configs: oauth2Cfgs,
|
||||
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
|
||||
Keycache: signer,
|
||||
Logger: log,
|
||||
DashboardURL: accessURL,
|
||||
Authorizer: authz,
|
||||
Auditor: auditor,
|
||||
Database: db,
|
||||
DeploymentValues: cfg,
|
||||
OAuth2Configs: oauth2Cfgs,
|
||||
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
|
||||
WorkspaceAppAuditSessionTimeout: workspaceAppAuditSessionTimeout,
|
||||
Keycache: signer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +95,9 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
|
||||
// // permissions.
|
||||
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
aReq, commitAudit := p.auditInitRequest(ctx, rw, r)
|
||||
defer commitAudit()
|
||||
|
||||
appReq := issueReq.AppRequest.Normalize()
|
||||
err := appReq.Check()
|
||||
if err != nil {
|
||||
@@ -111,6 +128,8 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
aReq.apiKey = apiKey // Update audit request.
|
||||
|
||||
// Lookup workspace app details from DB.
|
||||
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
@@ -123,6 +142,9 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app details from database")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
aReq.dbReq = dbReq // Update audit request.
|
||||
|
||||
token.UserID = dbReq.User.ID
|
||||
token.WorkspaceID = dbReq.Workspace.ID
|
||||
token.AgentID = dbReq.Agent.ID
|
||||
@@ -341,3 +363,175 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj
|
||||
// No checks were successful.
|
||||
return false, warnings, nil
|
||||
}
|
||||
|
||||
type auditRequest struct {
|
||||
time time.Time
|
||||
apiKey *database.APIKey
|
||||
dbReq *databaseRequest
|
||||
}
|
||||
|
||||
// auditInitRequest creates a new audit session and audit log for the given
|
||||
// request, if one does not already exist. If an audit session already exists,
|
||||
// it will be updated with the current timestamp. A session is used to reduce
|
||||
// the number of audit logs created.
|
||||
//
|
||||
// A session is unique to the agent, app, user and users IP. If any of these
|
||||
// values change, a new session and audit log is created.
|
||||
func (p *DBTokenProvider) auditInitRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (aReq *auditRequest, commit func()) {
|
||||
// Get the status writer from the request context so we can figure
|
||||
// out the HTTP status and autocommit the audit log.
|
||||
sw, ok := w.(*tracing.StatusWriter)
|
||||
if !ok {
|
||||
panic("dev error: http.ResponseWriter is not *tracing.StatusWriter")
|
||||
}
|
||||
|
||||
aReq = &auditRequest{
|
||||
time: dbtime.Now(),
|
||||
}
|
||||
|
||||
// Set the commit function on the status writer to create an audit
|
||||
// log, this ensures that the status and response body are available.
|
||||
var committed bool
|
||||
return aReq, func() {
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
if aReq.dbReq == nil {
|
||||
// App doesn't exist, there's information in the Request
|
||||
// struct but we need UUIDs for audit logging.
|
||||
return
|
||||
}
|
||||
|
||||
userID := uuid.Nil
|
||||
if aReq.apiKey != nil {
|
||||
userID = aReq.apiKey.UserID
|
||||
}
|
||||
userAgent := r.UserAgent()
|
||||
ip := r.RemoteAddr
|
||||
|
||||
// Approximation of the status code.
|
||||
statusCode := sw.Status
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
type additionalFields struct {
|
||||
audit.AdditionalFields
|
||||
SlugOrPort string `json:"slug_or_port,omitempty"`
|
||||
}
|
||||
appInfo := additionalFields{
|
||||
AdditionalFields: audit.AdditionalFields{
|
||||
WorkspaceOwner: aReq.dbReq.Workspace.OwnerUsername,
|
||||
WorkspaceName: aReq.dbReq.Workspace.Name,
|
||||
WorkspaceID: aReq.dbReq.Workspace.ID,
|
||||
},
|
||||
}
|
||||
switch {
|
||||
case aReq.dbReq.AccessMethod == AccessMethodTerminal:
|
||||
appInfo.SlugOrPort = "terminal"
|
||||
case aReq.dbReq.App.ID == uuid.Nil:
|
||||
// If this isn't an app or a terminal, it's a port.
|
||||
appInfo.SlugOrPort = aReq.dbReq.AppSlugOrPort
|
||||
}
|
||||
|
||||
// If we end up logging, ensure relevant fields are set.
|
||||
logger := p.Logger.With(
|
||||
slog.F("workspace_id", aReq.dbReq.Workspace.ID),
|
||||
slog.F("agent_id", aReq.dbReq.Agent.ID),
|
||||
slog.F("app_id", aReq.dbReq.App.ID),
|
||||
slog.F("user_id", userID),
|
||||
slog.F("user_agent", userAgent),
|
||||
slog.F("app_slug_or_port", appInfo.SlugOrPort),
|
||||
slog.F("status_code", statusCode),
|
||||
)
|
||||
|
||||
var startedAt time.Time
|
||||
err := p.Database.InTx(func(tx database.Store) (err error) {
|
||||
// nolint:gocritic // System context is needed to write audit sessions.
|
||||
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
startedAt, err = tx.UpsertWorkspaceAppAuditSession(dangerousSystemCtx, database.UpsertWorkspaceAppAuditSessionParams{
|
||||
// Config.
|
||||
StaleIntervalMS: p.WorkspaceAppAuditSessionTimeout.Milliseconds(),
|
||||
|
||||
// Data.
|
||||
AgentID: aReq.dbReq.Agent.ID,
|
||||
AppID: aReq.dbReq.App.ID, // Can be unset, in which case uuid.Nil is fine.
|
||||
UserID: userID, // Can be unset, in which case uuid.Nil is fine.
|
||||
Ip: ip,
|
||||
UserAgent: userAgent,
|
||||
SlugOrPort: appInfo.SlugOrPort,
|
||||
StatusCode: int32(statusCode),
|
||||
StartedAt: aReq.time,
|
||||
UpdatedAt: aReq.time,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert workspace app audit session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "update workspace app audit session failed", slog.Error(err))
|
||||
|
||||
// Avoid spamming the audit log if deduplication failed, this should
|
||||
// only happen if there are problems communicating with the database.
|
||||
return
|
||||
}
|
||||
|
||||
if !startedAt.Equal(aReq.time) {
|
||||
// If the unique session wasn't renewed, we don't want to log a new
|
||||
// audit event for it.
|
||||
return
|
||||
}
|
||||
|
||||
// Marshal additional fields only if we're writing an audit log entry.
|
||||
appInfoBytes, err := json.Marshal(appInfo)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "marshal additional fields failed", slog.Error(err))
|
||||
}
|
||||
|
||||
// We use the background audit function instead of init request
|
||||
// here because we don't know the resource type ahead of time.
|
||||
// This also allows us to log unauthenticated access.
|
||||
auditor := *p.Auditor.Load()
|
||||
requestID := httpmw.RequestID(r)
|
||||
switch {
|
||||
case aReq.dbReq.App.ID != uuid.Nil:
|
||||
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceApp]{
|
||||
Audit: auditor,
|
||||
Log: logger,
|
||||
|
||||
Action: database.AuditActionOpen,
|
||||
OrganizationID: aReq.dbReq.Workspace.OrganizationID,
|
||||
UserID: userID,
|
||||
RequestID: requestID,
|
||||
Time: aReq.time,
|
||||
Status: statusCode,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
New: aReq.dbReq.App,
|
||||
AdditionalFields: appInfoBytes,
|
||||
})
|
||||
default:
|
||||
// Web terminal, port app, etc.
|
||||
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceAgent]{
|
||||
Audit: auditor,
|
||||
Log: logger,
|
||||
|
||||
Action: database.AuditActionOpen,
|
||||
OrganizationID: aReq.dbReq.Workspace.OrganizationID,
|
||||
UserID: userID,
|
||||
RequestID: requestID,
|
||||
Time: aReq.time,
|
||||
Status: statusCode,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
New: aReq.dbReq.Agent,
|
||||
AdditionalFields: appInfoBytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user