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

feat: add API key metadata to audit logs#19996

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Draft
ThomasK33 wants to merge1 commit intothomask33/09-26-add_detailed_scope_auth_metrics
base:thomask33/09-26-add_detailed_scope_auth_metrics
Choose a base branch
Loading
fromthomask33/09-28-add_api_key_audit_metadata
Draft
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletionscoderd/apikey.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
return
}
aReq.New = *key
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, *key)))
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value})
}

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

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

aReq.New = updatedToken
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, updatedToken)))
httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(updatedToken))
}

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

err = api.Database.DeleteAPIKeyByID(ctx, keyID)
Expand Down
128 changes: 128 additions & 0 deletionscoderd/apikey_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,6 +93,134 @@ func TestTokenScoped(t *testing.T) {
require.Equal(t, "*:*", keys[0].AllowList[0].String())
}

func TestTokenCreateAuditAdditionalFields(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
_ = coderdtest.CreateFirstUser(t, client)
auditor.ResetLogs()

workspaceID := uuid.New()
scope := codersdk.APIKeyScopeWorkspaceRead
allowTarget := codersdk.AllowResourceTarget(codersdk.ResourceWorkspace, workspaceID)

_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
TokenName: "auditfields",
Scopes: []codersdk.APIKeyScope{scope},
AllowList: []codersdk.APIAllowListTarget{allowTarget},
})
require.NoError(t, err)

logs := auditor.AuditLogs()
var found *database.AuditLog
for i := len(logs) - 1; i >= 0; i-- {
if logs[i].ResourceType == database.ResourceTypeApiKey && logs[i].Action == database.AuditActionCreate {
found = &logs[i]
break
}
}
require.NotNil(t, found, "expected api key create audit log")

var payload struct {
APIKey audit.APIKeyAuditFields `json:"api_key"`
RequestAPIKey audit.APIKeyAuditFields `json:"request_api_key"`
}
require.NoError(t, json.Unmarshal(found.AdditionalFields, &payload))
require.NotEmpty(t, payload.APIKey.ID)
require.ElementsMatch(t, []string{string(scope)}, payload.APIKey.Scopes)
require.Equal(t, []string{allowTarget.String()}, payload.APIKey.AllowList)
require.NotNil(t, payload.APIKey.EffectiveScope)
assert.Contains(t, payload.APIKey.EffectiveScope.AllowList, allowTarget.String())
require.NotEmpty(t, payload.RequestAPIKey.ID)
}

func TestTokenUpdateAuditAdditionalFields(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
_ = coderdtest.CreateFirstUser(t, client)

_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{TokenName: "mutable"})
require.NoError(t, err)
auditor.ResetLogs()

scopes := []codersdk.APIKeyScope{codersdk.APIKeyScopeTemplateRead}
resourceID := uuid.New()
allow := codersdk.AllowResourceTarget(codersdk.ResourceTemplate, resourceID)
lifetime := 90 * time.Minute

_, err = client.UpdateToken(ctx, codersdk.Me, "mutable", codersdk.UpdateTokenRequest{
Scopes: &scopes,
AllowList: &[]codersdk.APIAllowListTarget{allow},
Lifetime: &lifetime,
})
require.NoError(t, err)

logs := auditor.AuditLogs()
var found *database.AuditLog
for i := len(logs) - 1; i >= 0; i-- {
if logs[i].ResourceType == database.ResourceTypeApiKey && logs[i].Action == database.AuditActionWrite {
found = &logs[i]
break
}
}
require.NotNil(t, found, "expected api key update audit log")

var payload struct {
APIKey audit.APIKeyAuditFields `json:"api_key"`
RequestAPIKey audit.APIKeyAuditFields `json:"request_api_key"`
}
require.NoError(t, json.Unmarshal(found.AdditionalFields, &payload))
require.Equal(t, []string{string(scopes[0])}, payload.APIKey.Scopes)
require.Equal(t, []string{allow.String()}, payload.APIKey.AllowList)
require.NotNil(t, payload.APIKey.EffectiveScope)
assert.Contains(t, payload.APIKey.EffectiveScope.Site, "template:read")
require.NotEmpty(t, payload.RequestAPIKey.ID)
}

func TestAuditRequestIncludesAPIKeyMetadata(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
user := coderdtest.CreateFirstUser(t, client)
auditor.ResetLogs()

_, err := client.UpdateUserProfile(ctx, codersdk.Me, codersdk.UpdateUserProfileRequest{
Username: coderdtest.FirstUserParams.Username,
Name: "audit metadata",
})
require.NoError(t, err)

logs := auditor.AuditLogs()
var found *database.AuditLog
for i := len(logs) - 1; i >= 0; i-- {
if logs[i].ResourceType == database.ResourceTypeUser && logs[i].Action == database.AuditActionWrite {
found = &logs[i]
break
}
}
require.NotNil(t, found, "expected user update audit log")

var payload struct {
APIKey audit.APIKeyAuditFields `json:"api_key"`
RequestAPIKey audit.APIKeyAuditFields `json:"request_api_key"`
}
require.NoError(t, json.Unmarshal(found.AdditionalFields, &payload))
require.NotEmpty(t, payload.RequestAPIKey.ID)
require.Equal(t, user.UserID, found.UserID)
require.NotNil(t, payload.RequestAPIKey.EffectiveScope)
require.NotEmpty(t, payload.RequestAPIKey.EffectiveScope.AllowList)
}

// Ensure backward-compat: when a token is created using the legacy singular
// scope names ("all" or "application_connect"), the API returns the same
// legacy value in the deprecated singular Scope field while also supporting
Expand Down
129 changes: 129 additions & 0 deletionscoderd/audit/apikey_fields.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
package audit

import (
"context"
"encoding/json"

"cdr.dev/slog"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/rbac"
)

type APIKeyAuditFields struct {
ID string `json:"id"`
TokenName string `json:"token_name,omitempty"`
Scopes []string `json:"scopes,omitempty"`
AllowList []string `json:"allow_list,omitempty"`
EffectiveScope *APIEffectiveScopeFields `json:"effective_scope,omitempty"`
}

type APIEffectiveScopeFields struct {
AllowList []string `json:"allow_list,omitempty"`
Site []string `json:"site_permissions,omitempty"`
Org map[string][]string `json:"org_permissions,omitempty"`
User []string `json:"user_permissions,omitempty"`
}

func APIKeyFields(ctx context.Context, log slog.Logger, key database.APIKey) APIKeyAuditFields {
fields := APIKeyAuditFields{
ID: key.ID,
TokenName: key.TokenName,
Scopes: apiKeyScopesToStrings(key.Scopes),
AllowList: allowListToStrings(key.AllowList),
}

expanded, err := key.ScopeSet().Expand()
if err != nil {
log.Warn(ctx, "expand api key effective scope", slog.Error(err))
return fields
}

fields.EffectiveScope = &APIEffectiveScopeFields{
AllowList: allowListElementsToStrings(expanded.AllowIDList),
Site: permissionsToStrings(expanded.Site),
Org: orgPermissionsToStrings(expanded.Org),
User: permissionsToStrings(expanded.User),
}

return fields
}

func WrapAPIKeyFields(fields APIKeyAuditFields) map[string]any {
return map[string]any{"api_key": fields}
}

func mergeAdditionalFields(ctx context.Context, log slog.Logger, existing json.RawMessage, apiKeyFields APIKeyAuditFields) json.RawMessage {
base := map[string]any{}
if len(existing) > 0 {
if err := json.Unmarshal(existing, &base); err != nil {
log.Warn(ctx, "unmarshal audit additional fields", slog.Error(err))
base = map[string]any{}
}
}

base["request_api_key"] = apiKeyFields

merged, err := json.Marshal(base)
if err != nil {
log.Warn(ctx, "marshal audit additional fields", slog.Error(err))
return existing
}

return json.RawMessage(merged)
}

func apiKeyScopesToStrings(scopes database.APIKeyScopes) []string {
if len(scopes) == 0 {
return nil
}
out := make([]string, 0, len(scopes))
for _, scope := range scopes {
out = append(out, string(scope))
}
return out
}

func allowListToStrings(list database.AllowList) []string {
if len(list) == 0 {
return nil
}
out := make([]string, 0, len(list))
for _, entry := range list {
out = append(out, entry.String())
}
return out
}

func allowListElementsToStrings(list []rbac.AllowListElement) []string {
if len(list) == 0 {
return nil
}
out := make([]string, 0, len(list))
for _, entry := range list {
out = append(out, entry.String())
}
return out
}

func permissionsToStrings(perms []rbac.Permission) []string {
if len(perms) == 0 {
return nil
}
out := make([]string, 0, len(perms))
for _, perm := range perms {
out = append(out, perm.ResourceType+":"+string(perm.Action))
}
return out
}

func orgPermissionsToStrings(perms map[string][]rbac.Permission) map[string][]string {
if len(perms) == 0 {
return nil
}
out := make(map[string][]string, len(perms))
for orgID, list := range perms {
out[orgID] = permissionsToStrings(list)
}
return out
}
11 changes: 11 additions & 0 deletionscoderd/audit/request.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,6 +58,12 @@ func (r *Request[T]) UpdateOrganizationID(id uuid.UUID) {
r.params.OrganizationID = id
}

// SetAdditionalFields allows callers to attach custom metadata that will be
// merged into the audit log payload.
func (r *Request[T]) SetAdditionalFields(fields interface{}) {
r.params.AdditionalFields = fields
}

type BackgroundAuditParams[T Auditable] struct {
Audit Auditor
Log slog.Logger
Expand DownExpand Up@@ -397,6 +403,11 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
}
}

if key, ok := httpmw.APIKeyOptional(p.Request); ok {
fields := APIKeyFields(logCtx, p.Log, key)
additionalFieldsRaw = mergeAdditionalFields(logCtx, p.Log, additionalFieldsRaw, fields)
}
Comment on lines +406 to +409
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Is this is a lot of extra data to staple to every audit log?

If we are trying to debug rbac failures, the raw input is logged on authz failures. Do we need to have scope metadata on all audit log entries?


var userID uuid.UUID
key, ok := httpmw.APIKeyOptional(p.Request)
switch {
Expand Down
4 changes: 2 additions & 2 deletionscoderd/rbac/README.md
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,14 +88,14 @@ an unbounded set of resource IDs that be added to an "allow_list", as the number

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.

Example of a scope for a workspace agent token, using an `allow_list` containing a single resourceid.
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.

```javascript
"scope": {
"name": "workspace_agent",
"display_name": "Workspace_Agent",
// The ID of the given workspace the agent token correlates to.
"allow_list": ["10d03e62-7703-4df5-a358-4f76577d4e2f"],
"allow_list": ["workspace:10d03e62-7703-4df5-a358-4f76577d4e2f"],
"site": [/* ... perms ... */],
"org": {/* ... perms ... */},
"user": [/* ... perms ... */]
Expand Down
1 change: 1 addition & 0 deletionscoderd/userauth.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -547,6 +547,7 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) {
}

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

http.SetCookie(rw, cookie)

Expand Down
17 changes: 16 additions & 1 deletioncoderd/workspaceapps.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import (
"golang.org/x/xerrors"

"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
Expand DownExpand Up@@ -53,7 +54,19 @@ func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
// @Router /applications/auth-redirect [get]
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var (
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
})
)
aReq.Old = database.APIKey{}
defer commitAudit()
apiKey := httpmw.APIKey(r)
aReq.UserID = apiKey.UserID
if !api.Authorize(r, policy.ActionCreate, apiKey) {
httpapi.ResourceNotFound(rw)
return
Expand DownExpand Up@@ -107,7 +120,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
exp = dbtime.Now().Add(api.DeploymentValues.Sessions.DefaultDuration.Value())
lifetimeSeconds = int64(api.DeploymentValues.Sessions.DefaultDuration.Value().Seconds())
}
cookie,_, err := api.createAPIKey(ctx, apikey.CreateParams{
cookie,key, err := api.createAPIKey(ctx, apikey.CreateParams{
UserID: apiKey.UserID,
LoginType: database.LoginTypePassword,
DefaultLifetime: api.DeploymentValues.Sessions.DefaultDuration.Value(),
Expand All@@ -122,6 +135,8 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
})
return
}
aReq.New = *key
aReq.SetAdditionalFields(audit.WrapAPIKeyFields(audit.APIKeyFields(ctx, api.Logger, *key)))

payload := workspaceapps.EncryptedAPIKeyPayload{
APIKey: cookie.Value,
Expand Down
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp