mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat(audit): auditing token addition and removal (#6649)
* auditing tokens * adding diffs for token auditing * added test * generating docs * auditing owner field
This commit is contained in:
@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/tabbed/pqtype"
|
"github.com/tabbed/pqtype"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
|
"github.com/coder/coder/coderd/audit"
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/httpapi"
|
"github.com/coder/coder/coderd/httpapi"
|
||||||
"github.com/coder/coder/coderd/httpmw"
|
"github.com/coder/coder/coderd/httpmw"
|
||||||
@ -40,8 +41,19 @@ import (
|
|||||||
// @Success 201 {object} codersdk.GenerateAPIKeyResponse
|
// @Success 201 {object} codersdk.GenerateAPIKeyResponse
|
||||||
// @Router /users/{user}/keys/tokens [post]
|
// @Router /users/{user}/keys/tokens [post]
|
||||||
func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
var (
|
||||||
user := httpmw.UserParam(r)
|
ctx = r.Context()
|
||||||
|
user = httpmw.UserParam(r)
|
||||||
|
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()
|
||||||
|
|
||||||
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(user.ID.String())) {
|
if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(user.ID.String())) {
|
||||||
httpapi.ResourceNotFound(rw)
|
httpapi.ResourceNotFound(rw)
|
||||||
@ -79,7 +91,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, _, err := api.createAPIKey(ctx, createAPIKeyParams{
|
cookie, key, err := api.createAPIKey(ctx, createAPIKeyParams{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
LoginType: database.LoginTypeToken,
|
LoginType: database.LoginTypeToken,
|
||||||
ExpiresAt: database.Now().Add(lifeTime),
|
ExpiresAt: database.Now().Add(lifeTime),
|
||||||
@ -104,7 +116,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
aReq.New = *key
|
||||||
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value})
|
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,14 +329,27 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
|
|||||||
ctx = r.Context()
|
ctx = r.Context()
|
||||||
user = httpmw.UserParam(r)
|
user = httpmw.UserParam(r)
|
||||||
keyID = chi.URLParam(r, "keyid")
|
keyID = chi.URLParam(r, "keyid")
|
||||||
|
auditor = api.Auditor.Load()
|
||||||
|
aReq, commitAudit = audit.InitRequest[database.APIKey](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: api.Logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionDelete,
|
||||||
|
})
|
||||||
|
key, err = api.Database.GetAPIKeyByID(ctx, keyID)
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
api.Logger.Warn(ctx, "get API Key for audit log")
|
||||||
|
}
|
||||||
|
aReq.Old = key
|
||||||
|
defer commitAudit()
|
||||||
|
|
||||||
if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceAPIKey.WithIDString(keyID).WithOwner(user.ID.String())) {
|
if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceAPIKey.WithIDString(keyID).WithOwner(user.ID.String())) {
|
||||||
httpapi.ResourceNotFound(rw)
|
httpapi.ResourceNotFound(rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := api.Database.DeleteAPIKeyByID(ctx, keyID)
|
err = api.Database.DeleteAPIKeyByID(ctx, keyID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httpapi.ResourceNotFound(rw)
|
httpapi.ResourceNotFound(rw)
|
||||||
return
|
return
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/coder/coder/cli/clibase"
|
"github.com/coder/coder/cli/clibase"
|
||||||
|
"github.com/coder/coder/coderd/audit"
|
||||||
"github.com/coder/coder/coderd/coderdtest"
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/database/dbtestutil"
|
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||||
@ -23,8 +24,12 @@ func TestTokenCRUD(t *testing.T) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
client := coderdtest.New(t, nil)
|
auditor := audit.NewMock()
|
||||||
|
numLogs := len(auditor.AuditLogs)
|
||||||
|
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
numLogs++ // add an audit log for user creation
|
||||||
|
|
||||||
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Empty(t, keys)
|
require.Empty(t, keys)
|
||||||
@ -32,6 +37,7 @@ func TestTokenCRUD(t *testing.T) {
|
|||||||
res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
|
res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Greater(t, len(res.Key), 2)
|
require.Greater(t, len(res.Key), 2)
|
||||||
|
numLogs++ // add an audit log for token creation
|
||||||
|
|
||||||
keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -46,9 +52,15 @@ func TestTokenCRUD(t *testing.T) {
|
|||||||
|
|
||||||
err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
|
err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
numLogs++ // add an audit log for token deletion
|
||||||
keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Empty(t, keys)
|
require.Empty(t, keys)
|
||||||
|
|
||||||
|
// ensure audit log count is correct
|
||||||
|
require.Len(t, auditor.AuditLogs, numLogs)
|
||||||
|
require.Equal(t, database.AuditActionCreate, auditor.AuditLogs[numLogs-2].Action)
|
||||||
|
require.Equal(t, database.AuditActionDelete, auditor.AuditLogs[numLogs-1].Action)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenScoped(t *testing.T) {
|
func TestTokenScoped(t *testing.T) {
|
||||||
|
@ -254,9 +254,10 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
|
|||||||
codersdk.AuditAction(alog.Action).Friendly(),
|
codersdk.AuditAction(alog.Action).Friendly(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// API Key resources do not have targets and follow the below format:
|
// API Key resources (used for authentication) do not have targets and follow the below format:
|
||||||
// "User {logged in | logged out}"
|
// "User {logged in | logged out}"
|
||||||
if alog.ResourceType == database.ResourceTypeApiKey {
|
if alog.ResourceType == database.ResourceTypeApiKey &&
|
||||||
|
(alog.Action == database.AuditActionLogin || alog.Action == database.AuditActionLogout) {
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +70,11 @@ func ResourceTarget[T Auditable](tgt T) string {
|
|||||||
case database.AuditableGroup:
|
case database.AuditableGroup:
|
||||||
return typed.Group.Name
|
return typed.Group.Name
|
||||||
case database.APIKey:
|
case database.APIKey:
|
||||||
// this isn't used
|
if typed.TokenName != "nil" {
|
||||||
|
return typed.TokenName
|
||||||
|
}
|
||||||
|
// API Keys without names are used for auth
|
||||||
|
// and don't have a target
|
||||||
return ""
|
return ""
|
||||||
case database.License:
|
case database.License:
|
||||||
return strconv.Itoa(int(typed.ID))
|
return strconv.Itoa(int(typed.ID))
|
||||||
@ -159,8 +163,10 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
diffRaw := []byte("{}")
|
diffRaw := []byte("{}")
|
||||||
// Only generate diffs if the request succeeded.
|
// Only generate diffs if the request succeeded
|
||||||
if sw.Status < 400 {
|
// and only if we aren't auditing authentication actions
|
||||||
|
if sw.Status < 400 &&
|
||||||
|
req.params.Action != database.AuditActionLogin && req.params.Action != database.AuditActionLogout {
|
||||||
diff := Diff(p.Audit, req.Old, req.New)
|
diff := Diff(p.Audit, req.Old, req.New)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
@ -42,7 +42,7 @@ func (r ResourceType) FriendlyString() string {
|
|||||||
case ResourceTypeGitSSHKey:
|
case ResourceTypeGitSSHKey:
|
||||||
return "git ssh key"
|
return "git ssh key"
|
||||||
case ResourceTypeAPIKey:
|
case ResourceTypeAPIKey:
|
||||||
return "api key"
|
return "token"
|
||||||
case ResourceTypeGroup:
|
case ResourceTypeGroup:
|
||||||
return "group"
|
return "group"
|
||||||
case ResourceTypeLicense:
|
case ResourceTypeLicense:
|
||||||
|
@ -10,8 +10,8 @@ We track the following resources:
|
|||||||
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
|
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
|
||||||
|
|
||||||
| <b>Resource<b> | |
|
| <b>Resource<b> | |
|
||||||
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| APIKey<br><i>write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>expires_at</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>false</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>false</td></tr></tbody></table> |
|
| APIKey<br><i>login, logout, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
|
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
|
||||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||||
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
||||||
|
@ -21,7 +21,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{
|
|||||||
"Workspace": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
"Workspace": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||||
"WorkspaceBuild": {codersdk.AuditActionStart, codersdk.AuditActionStop},
|
"WorkspaceBuild": {codersdk.AuditActionStart, codersdk.AuditActionStop},
|
||||||
"Group": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
"Group": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete},
|
||||||
"APIKey": {codersdk.AuditActionWrite},
|
"APIKey": {codersdk.AuditActionLogin, codersdk.AuditActionLogout, codersdk.AuditActionCreate, codersdk.AuditActionDelete},
|
||||||
"License": {codersdk.AuditActionCreate, codersdk.AuditActionDelete},
|
"License": {codersdk.AuditActionCreate, codersdk.AuditActionDelete},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,14 +137,13 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
|||||||
"quota_allowance": ActionTrack,
|
"quota_allowance": ActionTrack,
|
||||||
"members": ActionTrack,
|
"members": ActionTrack,
|
||||||
},
|
},
|
||||||
// We don't show any diff for the APIKey resource
|
|
||||||
&database.APIKey{}: {
|
&database.APIKey{}: {
|
||||||
"id": ActionIgnore,
|
"id": ActionIgnore,
|
||||||
"hashed_secret": ActionIgnore,
|
"hashed_secret": ActionIgnore,
|
||||||
"user_id": ActionIgnore,
|
"user_id": ActionTrack,
|
||||||
"last_used": ActionIgnore,
|
"last_used": ActionTrack,
|
||||||
"expires_at": ActionIgnore,
|
"expires_at": ActionTrack,
|
||||||
"created_at": ActionIgnore,
|
"created_at": ActionTrack,
|
||||||
"updated_at": ActionIgnore,
|
"updated_at": ActionIgnore,
|
||||||
"login_type": ActionIgnore,
|
"login_type": ActionIgnore,
|
||||||
"lifetime_seconds": ActionIgnore,
|
"lifetime_seconds": ActionIgnore,
|
||||||
|
Reference in New Issue
Block a user