Skip to content

Commit 5ac8d9c

Browse files
committed
feat(coderd/audit): include API key metadata in audit logs
For any action authenticated via an API key, the audit log now includes metadata about the key used for the request. This provides visibility into the permissions used to perform an action. The metadata is stored in the `request_api_key` field within the `additional_fields` payload and includes the key's ID, name, scopes, allow list, and its effective/expanded scope. Additionally, when an API key is the subject of a create, update, or delete action, its own metadata is now stored in the `api_key` field to provide a more complete record of the change.
1 parent 0d0a02d commit 5ac8d9c

9 files changed

Lines changed: 296 additions & 6 deletions

File tree

coderd/apikey.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
131131
return
132132
}
133133
aReq.New = *key
134+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, *key)))
134135
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value})
135136
}
136137

@@ -182,6 +183,7 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) {
182183
}
183184

184185
aReq.New = *key
186+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, *key)))
185187
// We intentionally do not set the cookie on the response here.
186188
// Setting the cookie will couple the browser session to the API
187189
// key we return here, meaning logging out of the website would
@@ -386,6 +388,7 @@ func (api *API) patchToken(rw http.ResponseWriter, r *http.Request) {
386388
}
387389

388390
aReq.New = updatedToken
391+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, updatedToken)))
389392
httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(updatedToken))
390393
}
391394

@@ -492,6 +495,9 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
492495
api.Logger.Warn(ctx, "get API Key for audit log")
493496
}
494497
aReq.Old = key
498+
if err == nil {
499+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, key)))
500+
}
495501
defer commitAudit()
496502

497503
err = api.Database.DeleteAPIKeyByID(ctx, keyID)

coderd/apikey_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,134 @@ func TestTokenScoped(t *testing.T) {
9393
require.Equal(t, "*:*", keys[0].AllowList[0].String())
9494
}
9595

96+
func TestTokenCreateAuditAdditionalFields(t *testing.T) {
97+
t.Parallel()
98+
99+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
100+
defer cancel()
101+
auditor := audit.NewMock()
102+
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
103+
_ = coderdtest.CreateFirstUser(t, client)
104+
auditor.ResetLogs()
105+
106+
workspaceID := uuid.New()
107+
scope := codersdk.APIKeyScopeWorkspaceRead
108+
allowTarget := codersdk.AllowResourceTarget(codersdk.ResourceWorkspace, workspaceID)
109+
110+
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
111+
TokenName: "auditfields",
112+
Scopes: []codersdk.APIKeyScope{scope},
113+
AllowList: []codersdk.APIAllowListTarget{allowTarget},
114+
})
115+
require.NoError(t, err)
116+
117+
logs := auditor.AuditLogs()
118+
var found *database.AuditLog
119+
for i := len(logs) - 1; i >= 0; i-- {
120+
if logs[i].ResourceType == database.ResourceTypeApiKey && logs[i].Action == database.AuditActionCreate {
121+
found = &logs[i]
122+
break
123+
}
124+
}
125+
require.NotNil(t, found, "expected api key create audit log")
126+
127+
var payload struct {
128+
APIKey audit.APIKeyAuditFields `json:"api_key"`
129+
RequestAPIKey audit.APIKeyAuditFields `json:"request_api_key"`
130+
}
131+
require.NoError(t, json.Unmarshal(found.AdditionalFields, &payload))
132+
require.NotEmpty(t, payload.APIKey.ID)
133+
require.ElementsMatch(t, []string{string(scope)}, payload.APIKey.Scopes)
134+
require.Equal(t, []string{allowTarget.String()}, payload.APIKey.AllowList)
135+
require.NotNil(t, payload.APIKey.EffectiveScope)
136+
assert.Contains(t, payload.APIKey.EffectiveScope.AllowList, allowTarget.String())
137+
require.NotEmpty(t, payload.RequestAPIKey.ID)
138+
}
139+
140+
func TestTokenUpdateAuditAdditionalFields(t *testing.T) {
141+
t.Parallel()
142+
143+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
144+
defer cancel()
145+
auditor := audit.NewMock()
146+
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
147+
_ = coderdtest.CreateFirstUser(t, client)
148+
149+
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{TokenName: "mutable"})
150+
require.NoError(t, err)
151+
auditor.ResetLogs()
152+
153+
scopes := []codersdk.APIKeyScope{codersdk.APIKeyScopeTemplateRead}
154+
resourceID := uuid.New()
155+
allow := codersdk.AllowResourceTarget(codersdk.ResourceTemplate, resourceID)
156+
lifetime := 90 * time.Minute
157+
158+
_, err = client.UpdateToken(ctx, codersdk.Me, "mutable", codersdk.UpdateTokenRequest{
159+
Scopes: &scopes,
160+
AllowList: &[]codersdk.APIAllowListTarget{allow},
161+
Lifetime: &lifetime,
162+
})
163+
require.NoError(t, err)
164+
165+
logs := auditor.AuditLogs()
166+
var found *database.AuditLog
167+
for i := len(logs) - 1; i >= 0; i-- {
168+
if logs[i].ResourceType == database.ResourceTypeApiKey && logs[i].Action == database.AuditActionWrite {
169+
found = &logs[i]
170+
break
171+
}
172+
}
173+
require.NotNil(t, found, "expected api key update audit log")
174+
175+
var payload struct {
176+
APIKey audit.APIKeyAuditFields `json:"api_key"`
177+
RequestAPIKey audit.APIKeyAuditFields `json:"request_api_key"`
178+
}
179+
require.NoError(t, json.Unmarshal(found.AdditionalFields, &payload))
180+
require.Equal(t, []string{string(scopes[0])}, payload.APIKey.Scopes)
181+
require.Equal(t, []string{allow.String()}, payload.APIKey.AllowList)
182+
require.NotNil(t, payload.APIKey.EffectiveScope)
183+
assert.Contains(t, payload.APIKey.EffectiveScope.Site, "template:read")
184+
require.NotEmpty(t, payload.RequestAPIKey.ID)
185+
}
186+
187+
func TestAuditRequestIncludesAPIKeyMetadata(t *testing.T) {
188+
t.Parallel()
189+
190+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
191+
defer cancel()
192+
auditor := audit.NewMock()
193+
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
194+
user := coderdtest.CreateFirstUser(t, client)
195+
auditor.ResetLogs()
196+
197+
_, err := client.UpdateUserProfile(ctx, codersdk.Me, codersdk.UpdateUserProfileRequest{
198+
Username: coderdtest.FirstUserParams.Username,
199+
Name: "audit metadata",
200+
})
201+
require.NoError(t, err)
202+
203+
logs := auditor.AuditLogs()
204+
var found *database.AuditLog
205+
for i := len(logs) - 1; i >= 0; i-- {
206+
if logs[i].ResourceType == database.ResourceTypeUser && logs[i].Action == database.AuditActionWrite {
207+
found = &logs[i]
208+
break
209+
}
210+
}
211+
require.NotNil(t, found, "expected user update audit log")
212+
213+
var payload struct {
214+
APIKey audit.APIKeyAuditFields `json:"api_key"`
215+
RequestAPIKey audit.APIKeyAuditFields `json:"request_api_key"`
216+
}
217+
require.NoError(t, json.Unmarshal(found.AdditionalFields, &payload))
218+
require.NotEmpty(t, payload.RequestAPIKey.ID)
219+
require.Equal(t, user.UserID, found.UserID)
220+
require.NotNil(t, payload.RequestAPIKey.EffectiveScope)
221+
require.NotEmpty(t, payload.RequestAPIKey.EffectiveScope.AllowList)
222+
}
223+
96224
// Ensure backward-compat: when a token is created using the legacy singular
97225
// scope names ("all" or "application_connect"), the API returns the same
98226
// legacy value in the deprecated singular Scope field while also supporting

coderd/audit/apikey_fields.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package audit
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
7+
"cdr.dev/slog"
8+
9+
"github.com/coder/coder/v2/coderd/database"
10+
"github.com/coder/coder/v2/coderd/rbac"
11+
)
12+
13+
type APIKeyAuditFields struct {
14+
ID string `json:"id"`
15+
TokenName string `json:"token_name,omitempty"`
16+
Scopes []string `json:"scopes,omitempty"`
17+
AllowList []string `json:"allow_list,omitempty"`
18+
EffectiveScope *APIEffectiveScopeFields `json:"effective_scope,omitempty"`
19+
}
20+
21+
type APIEffectiveScopeFields struct {
22+
AllowList []string `json:"allow_list,omitempty"`
23+
Site []string `json:"site_permissions,omitempty"`
24+
Org map[string][]string `json:"org_permissions,omitempty"`
25+
User []string `json:"user_permissions,omitempty"`
26+
}
27+
28+
func APIKeyFields(ctx context.Context, log slog.Logger, key database.APIKey) APIKeyAuditFields {
29+
fields := APIKeyAuditFields{
30+
ID: key.ID,
31+
TokenName: key.TokenName,
32+
Scopes: apiKeyScopesToStrings(key.Scopes),
33+
AllowList: allowListToStrings(key.AllowList),
34+
}
35+
36+
expanded, err := database.APIKeyEffectiveScope{Scopes: key.Scopes, AllowList: key.AllowList}.Expand()
37+
if err != nil {
38+
log.Warn(ctx, "expand api key effective scope", slog.Error(err))
39+
return fields
40+
}
41+
42+
fields.EffectiveScope = &APIEffectiveScopeFields{
43+
AllowList: allowListElementsToStrings(expanded.AllowIDList),
44+
Site: permissionsToStrings(expanded.Site),
45+
Org: orgPermissionsToStrings(expanded.Org),
46+
User: permissionsToStrings(expanded.User),
47+
}
48+
49+
return fields
50+
}
51+
52+
func WrapAPIKeyFields(fields APIKeyAuditFields) map[string]any {
53+
return map[string]any{"api_key": fields}
54+
}
55+
56+
func mergeAdditionalFields(ctx context.Context, log slog.Logger, existing json.RawMessage, apiKeyFields APIKeyAuditFields) json.RawMessage {
57+
base := map[string]any{}
58+
if len(existing) > 0 {
59+
if err := json.Unmarshal(existing, &base); err != nil {
60+
log.Warn(ctx, "unmarshal audit additional fields", slog.Error(err))
61+
base = map[string]any{}
62+
}
63+
}
64+
65+
base["request_api_key"] = apiKeyFields
66+
67+
merged, err := json.Marshal(base)
68+
if err != nil {
69+
log.Warn(ctx, "marshal audit additional fields", slog.Error(err))
70+
return existing
71+
}
72+
73+
return json.RawMessage(merged)
74+
}
75+
76+
func apiKeyScopesToStrings(scopes database.APIKeyScopes) []string {
77+
if len(scopes) == 0 {
78+
return nil
79+
}
80+
out := make([]string, 0, len(scopes))
81+
for _, scope := range scopes {
82+
out = append(out, string(scope))
83+
}
84+
return out
85+
}
86+
87+
func allowListToStrings(list database.AllowList) []string {
88+
if len(list) == 0 {
89+
return nil
90+
}
91+
out := make([]string, 0, len(list))
92+
for _, entry := range list {
93+
out = append(out, entry.String())
94+
}
95+
return out
96+
}
97+
98+
func allowListElementsToStrings(list []rbac.AllowListElement) []string {
99+
if len(list) == 0 {
100+
return nil
101+
}
102+
out := make([]string, 0, len(list))
103+
for _, entry := range list {
104+
out = append(out, entry.String())
105+
}
106+
return out
107+
}
108+
109+
func permissionsToStrings(perms []rbac.Permission) []string {
110+
if len(perms) == 0 {
111+
return nil
112+
}
113+
out := make([]string, 0, len(perms))
114+
for _, perm := range perms {
115+
out = append(out, perm.ResourceType+":"+string(perm.Action))
116+
}
117+
return out
118+
}
119+
120+
func orgPermissionsToStrings(perms map[string][]rbac.Permission) map[string][]string {
121+
if len(perms) == 0 {
122+
return nil
123+
}
124+
out := make(map[string][]string, len(perms))
125+
for orgID, list := range perms {
126+
out[orgID] = permissionsToStrings(list)
127+
}
128+
return out
129+
}

coderd/audit/request.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ func (r *Request[T]) UpdateOrganizationID(id uuid.UUID) {
5858
r.params.OrganizationID = id
5959
}
6060

61+
// SetAdditionalFields allows callers to attach custom metadata that will be
62+
// merged into the audit log payload.
63+
func (r *Request[T]) SetAdditionalFields(fields interface{}) {
64+
r.params.AdditionalFields = fields
65+
}
66+
6167
type BackgroundAuditParams[T Auditable] struct {
6268
Audit Auditor
6369
Log slog.Logger
@@ -397,6 +403,11 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
397403
}
398404
}
399405

406+
if key, ok := httpmw.APIKeyOptional(p.Request); ok {
407+
fields := APIKeyFields(logCtx, p.Log, key)
408+
additionalFieldsRaw = mergeAdditionalFields(logCtx, p.Log, additionalFieldsRaw, fields)
409+
}
410+
400411
var userID uuid.UUID
401412
key, ok := httpmw.APIKeyOptional(p.Request)
402413
switch {

coderd/rbac/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,14 @@ an unbounded set of resource IDs that be added to an "allow_list", as the number
8888

8989
The use case for specifying this type of permission in a role is limited, and does not justify the extra cost. To solve this for the remaining cases (eg. workspace agent tokens), we can apply an `allow_list` on a scope. For most cases, the `allow_list` will just be `["*"]` which means the scope is allowed to be applied to any resource. This adds negligible cost to the role evaluation logic and 0 cost to partial evaluations.
9090

91-
Example of a scope for a workspace agent token, using an `allow_list` containing a single resource id.
91+
Example of a scope for a workspace agent token, using an `allow_list` containing a single resource typed entry. Create operations only require the allow_list to include the resource type (or a wildcard entry); read, update, and delete operations still demand explicit ID membership.
9292

9393
```javascript
9494
"scope": {
9595
"name": "workspace_agent",
9696
"display_name": "Workspace_Agent",
9797
// The ID of the given workspace the agent token correlates to.
98-
"allow_list": ["10d03e62-7703-4df5-a358-4f76577d4e2f"],
98+
"allow_list": ["workspace:10d03e62-7703-4df5-a358-4f76577d4e2f"],
9999
"site": [/* ... perms ... */],
100100
"org": {/* ... perms ... */},
101101
"user": [/* ... perms ... */]

coderd/userauth.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
547547
}
548548

549549
aReq.New = *key
550+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, *key)))
550551

551552
http.SetCookie(rw, cookie)
552553

coderd/workspaceapps.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"golang.org/x/xerrors"
1212

1313
"github.com/coder/coder/v2/coderd/apikey"
14+
"github.com/coder/coder/v2/coderd/audit"
1415
"github.com/coder/coder/v2/coderd/database"
1516
"github.com/coder/coder/v2/coderd/database/dbauthz"
1617
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -53,7 +54,19 @@ func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
5354
// @Router /applications/auth-redirect [get]
5455
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
5556
ctx := r.Context()
57+
var (
58+
auditor = api.Auditor.Load()
59+
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
60+
Audit: *auditor,
61+
Log: api.Logger,
62+
Request: r,
63+
Action: database.AuditActionCreate,
64+
})
65+
)
66+
aReq.Old = database.APIKey{}
67+
defer commitAudit()
5668
apiKey := httpmw.APIKey(r)
69+
aReq.UserID = apiKey.UserID
5770
if !api.Authorize(r, policy.ActionCreate, apiKey) {
5871
httpapi.ResourceNotFound(rw)
5972
return
@@ -107,7 +120,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
107120
exp = dbtime.Now().Add(api.DeploymentValues.Sessions.DefaultDuration.Value())
108121
lifetimeSeconds = int64(api.DeploymentValues.Sessions.DefaultDuration.Value().Seconds())
109122
}
110-
cookie, _, err := api.createAPIKey(ctx, apikey.CreateParams{
123+
cookie, key, err := api.createAPIKey(ctx, apikey.CreateParams{
111124
UserID: apiKey.UserID,
112125
LoginType: database.LoginTypePassword,
113126
DefaultLifetime: api.DeploymentValues.Sessions.DefaultDuration.Value(),
@@ -122,6 +135,8 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
122135
})
123136
return
124137
}
138+
aReq.New = *key
139+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, *key)))
125140

126141
payload := workspaceapps.EncryptedAPIKeyPayload{
127142
APIKey: cookie.Value,

0 commit comments

Comments
 (0)