Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitb00259f

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 includesmetadata about the key used for the request. This provides visibilityinto 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, ordelete action, its own metadata is now stored in the `api_key` fieldto provide a more complete record of the change.
1 parent8b7a31c commitb00259f

File tree

6 files changed

+276
-3
lines changed

6 files changed

+276
-3
lines changed

‎coderd/apikey.go‎

Lines changed: 5 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

@@ -386,6 +387,7 @@ func (api *API) patchToken(rw http.ResponseWriter, r *http.Request) {
386387
}
387388

388389
aReq.New=updatedToken
390+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx,api.Logger,updatedToken)))
389391
httpapi.Write(ctx,rw,http.StatusOK,convertAPIKey(updatedToken))
390392
}
391393

@@ -492,6 +494,9 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
492494
api.Logger.Warn(ctx,"get API Key for audit log")
493495
}
494496
aReq.Old=key
497+
iferr==nil {
498+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx,api.Logger,key)))
499+
}
495500
defercommitAudit()
496501

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

‎coderd/apikey_test.go‎

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

96+
funcTestTokenCreateAuditAdditionalFields(t*testing.T) {
97+
t.Parallel()
98+
99+
ctx,cancel:=context.WithTimeout(context.Background(),testutil.WaitLong)
100+
defercancel()
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+
varfound*database.AuditLog
119+
fori:=len(logs)-1;i>=0;i-- {
120+
iflogs[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+
varpayloadstruct {
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+
funcTestTokenUpdateAuditAdditionalFields(t*testing.T) {
141+
t.Parallel()
142+
143+
ctx,cancel:=context.WithTimeout(context.Background(),testutil.WaitLong)
144+
defercancel()
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+
varfound*database.AuditLog
167+
fori:=len(logs)-1;i>=0;i-- {
168+
iflogs[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+
varpayloadstruct {
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+
funcTestAuditRequestIncludesAPIKeyMetadata(t*testing.T) {
188+
t.Parallel()
189+
190+
ctx,cancel:=context.WithTimeout(context.Background(),testutil.WaitLong)
191+
defercancel()
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+
Name:"audit metadata",
199+
})
200+
require.NoError(t,err)
201+
202+
logs:=auditor.AuditLogs()
203+
varfound*database.AuditLog
204+
fori:=len(logs)-1;i>=0;i-- {
205+
iflogs[i].ResourceType==database.ResourceTypeUser&&logs[i].Action==database.AuditActionWrite {
206+
found=&logs[i]
207+
break
208+
}
209+
}
210+
require.NotNil(t,found,"expected user update audit log")
211+
212+
varpayloadstruct {
213+
APIKey audit.APIKeyAuditFields`json:"api_key"`
214+
RequestAPIKey audit.APIKeyAuditFields`json:"request_api_key"`
215+
}
216+
require.NoError(t,json.Unmarshal(found.AdditionalFields,&payload))
217+
require.NotEmpty(t,payload.RequestAPIKey.ID)
218+
require.Equal(t,user.UserID,found.UserID)
219+
require.NotNil(t,payload.RequestAPIKey.EffectiveScope)
220+
require.NotEmpty(t,payload.RequestAPIKey.EffectiveScope.AllowList)
221+
}
222+
96223
// Ensure backward-compat: when a token is created using the legacy singular
97224
// scope names ("all" or "application_connect"), the API returns the same
98225
// legacy value in the deprecated singular Scope field while also supporting

‎coderd/audit/apikey_fields.go‎

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

‎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(fieldsinterface{}) {
64+
r.params.AdditionalFields=fields
65+
}
66+
6167
typeBackgroundAuditParams[TAuditable]struct {
6268
AuditAuditor
6369
Log slog.Logger
@@ -397,6 +403,11 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
397403
}
398404
}
399405

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

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp