@@ -3,27 +3,33 @@ package workspaceapps
3
3
import (
4
4
"context"
5
5
"database/sql"
6
+ "encoding/json"
6
7
"fmt"
7
8
"net/http"
8
9
"net/url"
9
10
"path"
10
11
"slices"
11
12
"strings"
13
+ "sync/atomic"
12
14
"time"
13
15
14
- "golang.org/x/xerrors"
15
-
16
16
"github.com/go-jose/go-jose/v4/jwt"
17
+ "github.com/google/uuid"
18
+ "github.com/sqlc-dev/pqtype"
19
+ "golang.org/x/xerrors"
17
20
18
21
"cdr.dev/slog"
22
+ "github.com/coder/coder/v2/coderd/audit"
19
23
"github.com/coder/coder/v2/coderd/cryptokeys"
20
24
"github.com/coder/coder/v2/coderd/database"
21
25
"github.com/coder/coder/v2/coderd/database/dbauthz"
26
+ "github.com/coder/coder/v2/coderd/database/dbtime"
22
27
"github.com/coder/coder/v2/coderd/httpapi"
23
28
"github.com/coder/coder/v2/coderd/httpmw"
24
29
"github.com/coder/coder/v2/coderd/jwtutils"
25
30
"github.com/coder/coder/v2/coderd/rbac"
26
31
"github.com/coder/coder/v2/coderd/rbac/policy"
32
+ "github.com/coder/coder/v2/coderd/tracing"
27
33
"github.com/coder/coder/v2/codersdk"
28
34
)
29
35
@@ -35,6 +41,7 @@ type DBTokenProvider struct {
35
41
// DashboardURL is the main dashboard access URL for error pages.
36
42
DashboardURL * url.URL
37
43
Authorizer rbac.Authorizer
44
+ Auditor * atomic.Pointer [audit.Auditor ]
38
45
Database database.Store
39
46
DeploymentValues * codersdk.DeploymentValues
40
47
OAuth2Configs * httpmw.OAuth2Configs
@@ -47,6 +54,7 @@ var _ SignedTokenProvider = &DBTokenProvider{}
47
54
func NewDBTokenProvider (log slog.Logger ,
48
55
accessURL * url.URL ,
49
56
authz rbac.Authorizer ,
57
+ auditor * atomic.Pointer [audit.Auditor ],
50
58
db database.Store ,
51
59
cfg * codersdk.DeploymentValues ,
52
60
oauth2Cfgs * httpmw.OAuth2Configs ,
@@ -61,6 +69,7 @@ func NewDBTokenProvider(log slog.Logger,
61
69
Logger : log ,
62
70
DashboardURL : accessURL ,
63
71
Authorizer : authz ,
72
+ Auditor : auditor ,
64
73
Database : db ,
65
74
DeploymentValues : cfg ,
66
75
OAuth2Configs : oauth2Cfgs ,
@@ -81,6 +90,8 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
81
90
// // permissions.
82
91
dangerousSystemCtx := dbauthz .AsSystemRestricted (ctx )
83
92
93
+ aReq := p .auditInitAutocommitRequest (ctx , rw , r )
94
+
84
95
appReq := issueReq .AppRequest .Normalize ()
85
96
err := appReq .Check ()
86
97
if err != nil {
@@ -111,6 +122,8 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
111
122
return nil , "" , false
112
123
}
113
124
125
+ aReq .apiKey = apiKey // Update audit request.
126
+
114
127
// Lookup workspace app details from DB.
115
128
dbReq , err := appReq .getDatabase (dangerousSystemCtx , p .Database )
116
129
if xerrors .Is (err , sql .ErrNoRows ) {
@@ -123,6 +136,9 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
123
136
WriteWorkspaceApp500 (p .Logger , p .DashboardURL , rw , r , & appReq , err , "get app details from database" )
124
137
return nil , "" , false
125
138
}
139
+
140
+ aReq .dbReq = dbReq // Update audit request.
141
+
126
142
token .UserID = dbReq .User .ID
127
143
token .WorkspaceID = dbReq .Workspace .ID
128
144
token .AgentID = dbReq .Agent .ID
@@ -133,6 +149,7 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
133
149
// Verify the user has access to the app.
134
150
authed , warnings , err := p .authorizeRequest (r .Context (), authz , dbReq )
135
151
if err != nil {
152
+ // TODO(mafredri): Audit?
136
153
WriteWorkspaceApp500 (p .Logger , p .DashboardURL , rw , r , & appReq , err , "verify authz" )
137
154
return nil , "" , false
138
155
}
@@ -341,3 +358,181 @@ func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *rbac.Subj
341
358
// No checks were successful.
342
359
return false , warnings , nil
343
360
}
361
+
362
+ type auditRequest struct {
363
+ time time.Time
364
+ ip pqtype.Inet
365
+ apiKey * database.APIKey
366
+ dbReq * databaseRequest
367
+ }
368
+
369
+ // auditInitAutocommitRequest creates a new audit session and audit log for the
370
+ // given request, if one does not already exist. If an audit session already
371
+ // exists, it will be updated with the current timestamp. A session is used to
372
+ // reduce the number of audit logs created.
373
+ //
374
+ // A session is unique to the agent, app, user and users IP. If any of these
375
+ // values change, a new session and audit log is created.
376
+ func (p * DBTokenProvider ) auditInitAutocommitRequest (ctx context.Context , w http.ResponseWriter , r * http.Request ) (aReq * auditRequest ) {
377
+ // Get the status writer from the request context so we can figure
378
+ // out the HTTP status and autocommit the audit log.
379
+ sw , ok := w .(* tracing.StatusWriter )
380
+ if ! ok {
381
+ panic ("dev error: http.ResponseWriter is not *tracing.StatusWriter" )
382
+ }
383
+
384
+ aReq = & auditRequest {
385
+ time : dbtime .Now (),
386
+ ip : audit .ParseIP (r .RemoteAddr ),
387
+ }
388
+
389
+ // Set the commit function on the status writer to create an audit
390
+ // log, this ensures that the status and response body are available.
391
+ sw .Done = append (sw .Done , func () {
392
+ p .Logger .Info (ctx , "workspace app audit session" , slog .F ("status" , sw .Status ), slog .F ("body" , string (sw .ResponseBody ())), slog .F ("api_key" , aReq .apiKey ), slog .F ("db_req" , aReq .dbReq ))
393
+
394
+ if sw .Status == http .StatusSeeOther {
395
+ // Redirects aren't interesting as we will capture the audit
396
+ // log after the redirect.
397
+ //
398
+ // There's a case where we call httpmw.RedirectToLogin for
399
+ // path-based apps the user doesn't have access to, in which
400
+ // case the dashboard login redirect is used and we end up
401
+ // not hitting the workspaceapps API again due to dashboard
402
+ // showing 404. (Bug?)
403
+ return
404
+ }
405
+
406
+ if aReq .dbReq == nil {
407
+ // App doesn't exist, there's information in the Request
408
+ // struct but we need UUIDs for audit logging.
409
+ return
410
+ }
411
+
412
+ type additionalFields struct {
413
+ audit.AdditionalFields
414
+ App string `json:"app"`
415
+ }
416
+ appInfo := additionalFields {
417
+ AdditionalFields : audit.AdditionalFields {
418
+ WorkspaceOwner : aReq .dbReq .Workspace .OwnerUsername ,
419
+ WorkspaceName : aReq .dbReq .Workspace .Name ,
420
+ WorkspaceID : aReq .dbReq .Workspace .ID ,
421
+ },
422
+ App : aReq .dbReq .AppSlugOrPort ,
423
+ }
424
+
425
+ appInfoBytes , err := json .Marshal (appInfo )
426
+ if err != nil {
427
+ p .Logger .Error (ctx , "marshal additional fields failed" , slog .Error (err ))
428
+ }
429
+
430
+ userID := uuid.NullUUID {}
431
+ if aReq .apiKey != nil {
432
+ userID = uuid.NullUUID {Valid : true , UUID : aReq .apiKey .UserID }
433
+ }
434
+
435
+ var (
436
+ updatedIDs []uuid.UUID
437
+ sessionID = uuid .Nil
438
+ )
439
+ err = p .Database .InTx (func (tx database.Store ) error {
440
+ // nolint:gocritic // System context is needed to write audit sessions.
441
+ dangerousSystemCtx := dbauthz .AsSystemRestricted (ctx )
442
+
443
+ updatedIDs , err = tx .UpdateWorkspaceAppAuditSession (dangerousSystemCtx , database.UpdateWorkspaceAppAuditSessionParams {
444
+ AgentID : aReq .dbReq .Agent .ID ,
445
+ AppID : uuid.NullUUID {Valid : aReq .dbReq .App .ID != uuid .Nil , UUID : aReq .dbReq .App .ID },
446
+ UserID : userID ,
447
+ Ip : aReq .ip ,
448
+ UpdatedAt : aReq .time ,
449
+ StaleIntervalMS : (2 * time .Hour ).Milliseconds (),
450
+ })
451
+ if err != nil {
452
+ return xerrors .Errorf ("update workspace app audit session: %w" , err )
453
+ }
454
+ if len (updatedIDs ) > 0 {
455
+ // Session is valid and got updated, no need to create a new audit log.
456
+ return nil
457
+ }
458
+
459
+ sessionID , err = tx .InsertWorkspaceAppAuditSession (dangerousSystemCtx , database.InsertWorkspaceAppAuditSessionParams {
460
+ AgentID : aReq .dbReq .Agent .ID ,
461
+ AppID : uuid.NullUUID {Valid : aReq .dbReq .App .ID != uuid .Nil , UUID : aReq .dbReq .App .ID },
462
+ UserID : userID ,
463
+ Ip : aReq .ip ,
464
+ StartedAt : aReq .time ,
465
+ UpdatedAt : aReq .time ,
466
+ })
467
+ if err != nil {
468
+ return xerrors .Errorf ("insert workspace app audit session: %w" , err )
469
+ }
470
+
471
+ return nil
472
+ }, nil )
473
+ if err != nil {
474
+ p .Logger .Error (ctx , "update workspace app audit session failed" , slog .Error (err ))
475
+ }
476
+
477
+ p .Logger .Info (ctx , "workspace app audit session" , slog .F ("session_id" , sessionID ), slog .F ("status" , sw .Status ), slog .F ("body" , string (sw .ResponseBody ())))
478
+
479
+ if sessionID == uuid .Nil {
480
+ if sw .Status < 400 {
481
+ // Session was updated and no error occurred, no need to
482
+ // create a new audit log.
483
+ return
484
+ }
485
+ if len (updatedIDs ) > 0 {
486
+ // Session was updated but an error occurred, we need to
487
+ // create a new audit log.
488
+ sessionID = updatedIDs [0 ]
489
+ } else {
490
+ // This shouldn't happen, but fall-back to request so it
491
+ // can be correlated to _something_.
492
+ sessionID = httpmw .RequestID (r )
493
+ }
494
+ }
495
+
496
+ // We use the background audit function instead of init request
497
+ // here because we don't know the resource type ahead of time.
498
+ // This also allows us to log unauthenticated access.
499
+ auditor := * p .Auditor .Load ()
500
+ switch {
501
+ case aReq .dbReq .App .ID != uuid .Nil :
502
+ audit .BackgroundAudit (ctx , & audit.BackgroundAuditParams [database.WorkspaceApp ]{
503
+ Audit : auditor ,
504
+ Log : p .Logger ,
505
+
506
+ Action : database .AuditActionOpen ,
507
+ OrganizationID : aReq .dbReq .Workspace .OrganizationID ,
508
+ UserID : userID .UUID ,
509
+ RequestID : sessionID ,
510
+ Time : aReq .time ,
511
+ Status : sw .Status ,
512
+ IP : aReq .ip .IPNet .IP .String (),
513
+ UserAgent : r .UserAgent (),
514
+ New : aReq .dbReq .App ,
515
+ AdditionalFields : appInfoBytes ,
516
+ })
517
+ default :
518
+ // Web terminal, port app, etc.
519
+ audit .BackgroundAudit (ctx , & audit.BackgroundAuditParams [database.WorkspaceAgent ]{
520
+ Audit : auditor ,
521
+ Log : p .Logger ,
522
+
523
+ Action : database .AuditActionOpen ,
524
+ OrganizationID : aReq .dbReq .Workspace .OrganizationID ,
525
+ UserID : userID .UUID ,
526
+ RequestID : sessionID ,
527
+ Time : aReq .time ,
528
+ Status : sw .Status ,
529
+ IP : aReq .ip .IPNet .IP .String (),
530
+ UserAgent : r .UserAgent (),
531
+ New : aReq .dbReq .Agent ,
532
+ AdditionalFields : appInfoBytes ,
533
+ })
534
+ }
535
+ })
536
+
537
+ return aReq
538
+ }
0 commit comments