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

Commit0f2c153

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 parent06d4957 commit0f2c153

File tree

9 files changed

+296
-6
lines changed

9 files changed

+296
-6
lines changed

‎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+
iferr==nil {
499+
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx,api.Logger,key)))
500+
}
495501
defercommitAudit()
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+
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+
Username:coderdtest.FirstUserParams.Username,
199+
Name:"audit metadata",
200+
})
201+
require.NoError(t,err)
202+
203+
logs:=auditor.AuditLogs()
204+
varfound*database.AuditLog
205+
fori:=len(logs)-1;i>=0;i-- {
206+
iflogs[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+
varpayloadstruct {
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+
typeAPIKeyAuditFieldsstruct {
14+
IDstring`json:"id"`
15+
TokenNamestring`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+
typeAPIEffectiveScopeFieldsstruct {
22+
AllowList []string`json:"allow_list,omitempty"`
23+
Site []string`json:"site_permissions,omitempty"`
24+
Orgmap[string][]string`json:"org_permissions,omitempty"`
25+
User []string`json:"user_permissions,omitempty"`
26+
}
27+
28+
funcAPIKeyFields(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:=key.ScopeSet().Expand()
37+
iferr!=nil {
38+
log.Warn(ctx,"expand api key effective scope",slog.Error(err))
39+
returnfields
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+
returnfields
50+
}
51+
52+
funcWrapAPIKeyFields(fieldsAPIKeyAuditFields)map[string]any {
53+
returnmap[string]any{"api_key":fields}
54+
}
55+
56+
funcmergeAdditionalFields(ctx context.Context,log slog.Logger,existing json.RawMessage,apiKeyFieldsAPIKeyAuditFields) json.RawMessage {
57+
base:=map[string]any{}
58+
iflen(existing)>0 {
59+
iferr:=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+
iferr!=nil {
69+
log.Warn(ctx,"marshal audit additional fields",slog.Error(err))
70+
returnexisting
71+
}
72+
73+
returnjson.RawMessage(merged)
74+
}
75+
76+
funcapiKeyScopesToStrings(scopes database.APIKeyScopes) []string {
77+
iflen(scopes)==0 {
78+
returnnil
79+
}
80+
out:=make([]string,0,len(scopes))
81+
for_,scope:=rangescopes {
82+
out=append(out,string(scope))
83+
}
84+
returnout
85+
}
86+
87+
funcallowListToStrings(list database.AllowList) []string {
88+
iflen(list)==0 {
89+
returnnil
90+
}
91+
out:=make([]string,0,len(list))
92+
for_,entry:=rangelist {
93+
out=append(out,entry.String())
94+
}
95+
returnout
96+
}
97+
98+
funcallowListElementsToStrings(list []rbac.AllowListElement) []string {
99+
iflen(list)==0 {
100+
returnnil
101+
}
102+
out:=make([]string,0,len(list))
103+
for_,entry:=rangelist {
104+
out=append(out,entry.String())
105+
}
106+
returnout
107+
}
108+
109+
funcpermissionsToStrings(perms []rbac.Permission) []string {
110+
iflen(perms)==0 {
111+
returnnil
112+
}
113+
out:=make([]string,0,len(perms))
114+
for_,perm:=rangeperms {
115+
out=append(out,perm.ResourceType+":"+string(perm.Action))
116+
}
117+
returnout
118+
}
119+
120+
funcorgPermissionsToStrings(permsmap[string][]rbac.Permission)map[string][]string {
121+
iflen(perms)==0 {
122+
returnnil
123+
}
124+
out:=make(map[string][]string,len(perms))
125+
fororgID,list:=rangeperms {
126+
out[orgID]=permissionsToStrings(list)
127+
}
128+
returnout
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(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 {

‎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 resourceid.
91+
Example of a scope for a workspace agent token, using an`allow_list` containing a single resourcetyped 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+
defercommitAudit()
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)

[8]ページ先頭

©2009-2025 Movatter.jp