mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
Adds enterprise audit logging for MCP server config create, update, and
delete, with strict secret redaction. MCP configs hold credentials
(OAuth2 client secrets, API keys, custom headers), so admin changes to
them need an audit trail.
## Summary
- `enterprise/audit/table.go` gains an `MCPServerConfig` entry
enumerating every column: `oauth2_client_secret`, `api_key_value`, and
`custom_headers` are `ActionSecret` (never appear in diffs); dbcrypt
`*_key_id` bookkeeping, IDs, and timestamps are ignored; the remaining
config fields, including the endpoint URL fields, are tracked so
auditors can see which endpoints a config points at.
- Type registration in `coderd/audit` (diff, request, resource target
with org attribution), `codersdk/audit.go`, and a `resource_type` enum
migration.
- Handlers wire `audit.InitRequest`: create records `New`; update and
delete record `Old` from the param middleware before the
write-authorization check, so a readable-but-not-writable caller
produces an audited 403 while read-denied callers stay concealed as
unaudited 404s.
- Tests: create/update/delete audit entries, write-denied and
delete-denied 403 auditing, cross-org concealment producing zero
entries, and a serializer-level regression test proving none of the
three secret classes can reach a serialized diff.
- Review round: MCP config audit entries link to
`/ai/settings/mcp-servers/{id}`, audit table comments are trimmed per
review, and a fault-injection test pins that a config row surviving a
failed post-discovery credential update still gets its creation audit
entry.
Stacked on #27942 (org-scoped MCP configs). Part of the MCP
org-separation stack.
Closes https://linear.app/codercom/issue/CODAGT-717
UAT: verified on a trial-licensed dogfood instance: audit entries for
the full CRUD lifecycle with correct actor/org/target, redacted secrets
in the update and OAuth2 create diffs, and a full plaintext scan of the
audit dump finding zero secret leaks.
> Mux (AI agent) authored this PR on Mike's behalf.
<!-- mux-attribution: model=claude-fable-5 thinking=high -->
656 lines
21 KiB
Go
656 lines
21 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/searchquery"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// Limit the count query to avoid a slow sequential scan due to joins
|
|
// on a large table. Set to 0 to disable capping (but also see the note
|
|
// in the SQL query).
|
|
const auditLogCountCap = 2000
|
|
|
|
// @Summary Get audit logs
|
|
// @ID get-audit-logs
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Audit
|
|
// @Param q query string false "Search query"
|
|
// @Param limit query int true "Page limit"
|
|
// @Param offset query int false "Page offset"
|
|
// @Success 200 {object} codersdk.AuditLogResponse
|
|
// @Router /api/v2/audit [get]
|
|
func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
apiKey := httpmw.APIKey(r)
|
|
|
|
page, ok := ParsePagination(rw, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
queryStr := r.URL.Query().Get("q")
|
|
filter, countFilter, errs := searchquery.AuditLogs(ctx, api.Database, queryStr)
|
|
if len(errs) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid audit search query.",
|
|
Validations: errs,
|
|
})
|
|
return
|
|
}
|
|
// #nosec G115 - Safe conversion as pagination offset is expected to be within int32 range
|
|
filter.OffsetOpt = int32(page.Offset)
|
|
// #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range
|
|
filter.LimitOpt = int32(page.Limit)
|
|
|
|
if filter.Username == "me" {
|
|
filter.UserID = apiKey.UserID
|
|
filter.Username = ""
|
|
countFilter.UserID = apiKey.UserID
|
|
countFilter.Username = ""
|
|
}
|
|
|
|
countFilter.CountCap = auditLogCountCap
|
|
count, err := api.Database.CountAuditLogs(ctx, countFilter)
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
// If count is 0, then we don't need to query audit logs
|
|
if count == 0 {
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
|
|
AuditLogs: []codersdk.AuditLog{},
|
|
Count: 0,
|
|
CountCap: auditLogCountCap,
|
|
})
|
|
return
|
|
}
|
|
|
|
dblogs, err := api.Database.GetAuditLogsOffset(ctx, filter)
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
httpapi.Forbidden(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AuditLogResponse{
|
|
AuditLogs: api.convertAuditLogs(ctx, dblogs),
|
|
Count: count,
|
|
CountCap: auditLogCountCap,
|
|
})
|
|
}
|
|
|
|
// @Summary Generate fake audit log
|
|
// @ID generate-fake-audit-log
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Tags Audit
|
|
// @Param request body codersdk.CreateTestAuditLogRequest true "Audit log request"
|
|
// @Success 204
|
|
// @Router /api/v2/audit/testgenerate [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
key := httpmw.APIKey(r)
|
|
user, err := api.Database.GetUserByID(ctx, key.UserID)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
diff, err := json.Marshal(codersdk.AuditDiff{
|
|
"foo": codersdk.AuditDiffField{Old: "bar", New: "baz"},
|
|
})
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
ip := net.ParseIP(r.RemoteAddr)
|
|
ipNet := pqtype.Inet{}
|
|
if ip != nil {
|
|
ipNet = pqtype.Inet{
|
|
IPNet: net.IPNet{
|
|
IP: ip,
|
|
Mask: net.CIDRMask(len(ip)*8, len(ip)*8),
|
|
},
|
|
Valid: true,
|
|
}
|
|
}
|
|
|
|
var params codersdk.CreateTestAuditLogRequest
|
|
if !httpapi.Read(ctx, rw, r, ¶ms) {
|
|
return
|
|
}
|
|
if params.Action == "" {
|
|
params.Action = codersdk.AuditActionWrite
|
|
}
|
|
if params.ResourceType == "" {
|
|
params.ResourceType = codersdk.ResourceTypeUser
|
|
}
|
|
if params.ResourceID == uuid.Nil {
|
|
params.ResourceID = uuid.New()
|
|
}
|
|
if params.Time.IsZero() {
|
|
params.Time = time.Now()
|
|
}
|
|
if len(params.AdditionalFields) == 0 {
|
|
params.AdditionalFields = json.RawMessage("{}")
|
|
}
|
|
|
|
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
|
|
ID: uuid.New(),
|
|
Time: params.Time,
|
|
UserID: user.ID,
|
|
Ip: ipNet,
|
|
UserAgent: sql.NullString{String: r.UserAgent(), Valid: true},
|
|
ResourceType: database.ResourceType(params.ResourceType),
|
|
ResourceID: params.ResourceID,
|
|
ResourceTarget: user.Username,
|
|
Action: database.AuditAction(params.Action),
|
|
Diff: diff,
|
|
StatusCode: http.StatusOK,
|
|
AdditionalFields: params.AdditionalFields,
|
|
RequestID: params.RequestID,
|
|
ResourceIcon: "",
|
|
OrganizationID: params.OrganizationID,
|
|
})
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (api *API) convertAuditLogs(ctx context.Context, dblogs []database.GetAuditLogsOffsetRow) []codersdk.AuditLog {
|
|
alogs := make([]codersdk.AuditLog, 0, len(dblogs))
|
|
|
|
for _, dblog := range dblogs {
|
|
alogs = append(alogs, api.convertAuditLog(ctx, dblog))
|
|
}
|
|
|
|
return alogs
|
|
}
|
|
|
|
func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogsOffsetRow) codersdk.AuditLog {
|
|
ip, _ := netip.AddrFromSlice(dblog.AuditLog.Ip.IPNet.IP)
|
|
|
|
diff := codersdk.AuditDiff{}
|
|
_ = json.Unmarshal(dblog.AuditLog.Diff, &diff)
|
|
|
|
var user *codersdk.User
|
|
if dblog.UserUsername.Valid {
|
|
// Leaving the organization IDs blank for now; not sure they are useful for
|
|
// the audit query anyway?
|
|
sdkUser := db2sdk.User(database.User{
|
|
ID: dblog.AuditLog.UserID,
|
|
Email: dblog.UserEmail.String,
|
|
Username: dblog.UserUsername.String,
|
|
CreatedAt: dblog.UserCreatedAt.Time,
|
|
UpdatedAt: dblog.UserUpdatedAt.Time,
|
|
Status: dblog.UserStatus.UserStatus,
|
|
RBACRoles: dblog.UserRoles,
|
|
LoginType: dblog.UserLoginType.LoginType,
|
|
AvatarURL: dblog.UserAvatarUrl.String,
|
|
Deleted: dblog.UserDeleted.Bool,
|
|
LastSeenAt: dblog.UserLastSeenAt.Time,
|
|
QuietHoursSchedule: dblog.UserQuietHoursSchedule.String,
|
|
Name: dblog.UserName.String,
|
|
}, []uuid.UUID{})
|
|
user = &sdkUser
|
|
}
|
|
|
|
var (
|
|
additionalFieldsBytes = []byte(dblog.AuditLog.AdditionalFields)
|
|
additionalFields audit.AdditionalFields
|
|
err = json.Unmarshal(additionalFieldsBytes, &additionalFields)
|
|
)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "unmarshal additional fields", slog.Error(err))
|
|
resourceInfo := audit.AdditionalFields{
|
|
WorkspaceName: "unknown",
|
|
BuildNumber: "unknown",
|
|
BuildReason: "unknown",
|
|
WorkspaceOwner: "unknown",
|
|
}
|
|
|
|
dblog.AuditLog.AdditionalFields, err = json.Marshal(resourceInfo)
|
|
api.Logger.Error(ctx, "marshal additional fields", slog.Error(err))
|
|
}
|
|
|
|
var (
|
|
isDeleted = api.auditLogIsResourceDeleted(ctx, dblog)
|
|
resourceLink string
|
|
)
|
|
if isDeleted {
|
|
resourceLink = ""
|
|
} else {
|
|
resourceLink = api.auditLogResourceLink(ctx, dblog, additionalFields)
|
|
}
|
|
|
|
alog := codersdk.AuditLog{
|
|
ID: dblog.AuditLog.ID,
|
|
RequestID: dblog.AuditLog.RequestID,
|
|
Time: dblog.AuditLog.Time,
|
|
// OrganizationID is deprecated.
|
|
OrganizationID: dblog.AuditLog.OrganizationID,
|
|
IP: ip,
|
|
UserAgent: dblog.AuditLog.UserAgent.String,
|
|
ResourceType: codersdk.ResourceType(dblog.AuditLog.ResourceType),
|
|
ResourceID: dblog.AuditLog.ResourceID,
|
|
ResourceTarget: dblog.AuditLog.ResourceTarget,
|
|
ResourceIcon: dblog.AuditLog.ResourceIcon,
|
|
Action: codersdk.AuditAction(dblog.AuditLog.Action),
|
|
Diff: diff,
|
|
StatusCode: dblog.AuditLog.StatusCode,
|
|
AdditionalFields: dblog.AuditLog.AdditionalFields,
|
|
User: user,
|
|
Description: auditLogDescription(dblog),
|
|
ResourceLink: resourceLink,
|
|
IsDeleted: isDeleted,
|
|
}
|
|
|
|
if dblog.AuditLog.OrganizationID != uuid.Nil {
|
|
alog.Organization = &codersdk.MinimalOrganization{
|
|
ID: dblog.AuditLog.OrganizationID,
|
|
Name: dblog.OrganizationName,
|
|
DisplayName: dblog.OrganizationDisplayName,
|
|
Icon: dblog.OrganizationIcon,
|
|
}
|
|
}
|
|
|
|
return alog
|
|
}
|
|
|
|
func auditLogDescription(alog database.GetAuditLogsOffsetRow) string {
|
|
b := strings.Builder{}
|
|
|
|
// NOTE: WriteString always returns a nil error, so we never check it
|
|
|
|
// Requesting a password reset can be performed by anyone that knows the email
|
|
// of a user so saying the user performed this action might be slightly misleading.
|
|
if alog.AuditLog.Action != database.AuditActionRequestPasswordReset {
|
|
_, _ = b.WriteString("{user} ")
|
|
}
|
|
|
|
// Chat write operations get semantic descriptions derived from the diff.
|
|
if desc, ok := chatAuditLogDescription(alog); ok {
|
|
_, _ = b.WriteString(desc)
|
|
return b.String()
|
|
}
|
|
|
|
switch {
|
|
case alog.AuditLog.StatusCode == int32(http.StatusSeeOther):
|
|
_, _ = b.WriteString("was redirected attempting to ")
|
|
_, _ = b.WriteString(string(alog.AuditLog.Action))
|
|
case alog.AuditLog.StatusCode >= 400:
|
|
_, _ = b.WriteString("unsuccessfully attempted to ")
|
|
_, _ = b.WriteString(string(alog.AuditLog.Action))
|
|
default:
|
|
_, _ = b.WriteString(codersdk.AuditAction(alog.AuditLog.Action).Friendly())
|
|
}
|
|
|
|
// API Key resources (used for authentication) do not have targets and follow the below format:
|
|
// "User {logged in | logged out | registered}"
|
|
if alog.AuditLog.ResourceType == database.ResourceTypeApiKey &&
|
|
(alog.AuditLog.Action == database.AuditActionLogin || alog.AuditLog.Action == database.AuditActionLogout || alog.AuditLog.Action == database.AuditActionRegister) {
|
|
return b.String()
|
|
}
|
|
|
|
// We don't display the name (target) for git ssh keys. It's fairly long and doesn't
|
|
// make too much sense to display.
|
|
if alog.AuditLog.ResourceType == database.ResourceTypeGitSshKey {
|
|
_, _ = b.WriteString(" the ")
|
|
_, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString())
|
|
return b.String()
|
|
}
|
|
|
|
if alog.AuditLog.Action == database.AuditActionRequestPasswordReset {
|
|
_, _ = b.WriteString(" for")
|
|
} else {
|
|
_, _ = b.WriteString(" ")
|
|
_, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString())
|
|
}
|
|
|
|
if alog.AuditLog.ResourceType == database.ResourceTypeConvertLogin {
|
|
_, _ = b.WriteString(" to")
|
|
}
|
|
|
|
_, _ = b.WriteString(" {target}")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// chatAuditLogDescription returns a description for successful chat write
|
|
// operations based on the diff contents. It returns false for non-chat
|
|
// resources, non-write actions, or error/redirect status codes, letting
|
|
// the caller fall through to the generic description.
|
|
func chatAuditLogDescription(alog database.GetAuditLogsOffsetRow) (string, bool) {
|
|
if alog.AuditLog.ResourceType != database.ResourceTypeChat ||
|
|
alog.AuditLog.Action != database.AuditActionWrite ||
|
|
alog.AuditLog.StatusCode >= 400 ||
|
|
alog.AuditLog.StatusCode == int32(http.StatusSeeOther) {
|
|
return "", false
|
|
}
|
|
|
|
var diff codersdk.AuditDiff
|
|
if err := json.Unmarshal(alog.AuditLog.Diff, &diff); err != nil {
|
|
return "", false
|
|
}
|
|
|
|
// Single "archived" field: archive or unarchive.
|
|
if len(diff) == 1 {
|
|
if field, ok := diff["archived"]; ok {
|
|
oldVal, oldOK := field.Old.(bool)
|
|
newVal, newOK := field.New.(bool)
|
|
if oldOK && newOK {
|
|
if !oldVal && newVal {
|
|
return "archived chat {target}", true
|
|
}
|
|
if oldVal && !newVal {
|
|
return "unarchived chat {target}", true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// All fields are ACL changes: sharing update.
|
|
if len(diff) > 0 {
|
|
aclOnly := true
|
|
for field := range diff {
|
|
if field != "user_acl" && field != "group_acl" {
|
|
aclOnly = false
|
|
break
|
|
}
|
|
}
|
|
if aclOnly {
|
|
return "updated sharing for chat {target}", true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func (api *API) auditLogIsResourceDeleted(ctx context.Context, alog database.GetAuditLogsOffsetRow) bool {
|
|
switch alog.AuditLog.ResourceType {
|
|
case database.ResourceTypeTemplate:
|
|
template, err := api.Database.GetTemplateByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch template", slog.Error(err))
|
|
}
|
|
return template.Deleted
|
|
case database.ResourceTypeUser:
|
|
user, err := api.Database.GetUserByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch user", slog.Error(err))
|
|
}
|
|
return user.Deleted
|
|
case database.ResourceTypeWorkspace:
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeWorkspaceBuild:
|
|
workspaceBuild, err := api.Database.GetWorkspaceBuildByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace build", slog.Error(err))
|
|
}
|
|
// We use workspace as a proxy for workspace build here
|
|
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeWorkspaceAgent:
|
|
// We use workspace as a proxy for workspace agents.
|
|
workspace, err := api.Database.GetWorkspaceByAgentID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeWorkspaceApp:
|
|
// We use workspace as a proxy for workspace apps.
|
|
workspace, err := api.Database.GetWorkspaceByWorkspaceAppID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
api.Logger.Error(ctx, "unable to fetch workspace", slog.Error(err))
|
|
}
|
|
return workspace.Deleted
|
|
case database.ResourceTypeOauth2ProviderApp:
|
|
_, err := api.Database.GetOAuth2ProviderAppByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
} else if err != nil {
|
|
api.Logger.Error(ctx, "unable to fetch oauth2 app", slog.Error(err))
|
|
}
|
|
return false
|
|
case database.ResourceTypeOauth2ProviderAppSecret:
|
|
_, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
} else if err != nil {
|
|
api.Logger.Error(ctx, "unable to fetch oauth2 app secret", slog.Error(err))
|
|
}
|
|
return false
|
|
case database.ResourceTypeTask:
|
|
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
} else if err != nil {
|
|
api.Logger.Error(ctx, "unable to fetch task", slog.Error(err))
|
|
}
|
|
return task.DeletedAt.Valid && task.DeletedAt.Time.Before(time.Now())
|
|
case database.ResourceTypeChat:
|
|
// Chats are hard-deleted, so a 404 means deleted.
|
|
_, err := api.Database.GetChatByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "unable to fetch chat", slog.Error(err))
|
|
}
|
|
return false
|
|
case database.ResourceTypeMCPServerConfig:
|
|
// MCP server configs are hard-deleted, so a 404 means deleted.
|
|
_, err := api.Database.GetMCPServerConfigByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
// Config reads are org-scoped, so an auditor can lack read on
|
|
// the config's organization. That is not worth logging.
|
|
if err != nil && !dbauthz.IsNotAuthorizedError(err) {
|
|
api.Logger.Error(ctx, "unable to fetch mcp server config", slog.Error(err))
|
|
}
|
|
return false
|
|
case database.ResourceTypeUserSecret:
|
|
_, err := api.Database.GetUserSecretByID(ctx, alog.AuditLog.ResourceID)
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
// Only users have user_secret:read on their own secrets. If dbauthz returns
|
|
// ErrUnauthorized, it's not an error worth logging because we have enough
|
|
// information to know it's not deleted.
|
|
if err != nil && !dbauthz.IsNotAuthorizedError(err) {
|
|
api.Logger.Error(ctx, "unable to fetch user secret", slog.Error(err))
|
|
}
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAuditLogsOffsetRow, additionalFields audit.AdditionalFields) string {
|
|
switch alog.AuditLog.ResourceType {
|
|
case database.ResourceTypeTemplate:
|
|
return fmt.Sprintf("/templates/%s",
|
|
alog.AuditLog.ResourceTarget)
|
|
|
|
case database.ResourceTypeUser:
|
|
return fmt.Sprintf("/users?filter=%s",
|
|
alog.AuditLog.ResourceTarget)
|
|
|
|
case database.ResourceTypeWorkspace:
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, alog.AuditLog.ResourceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
workspaceOwner, getWorkspaceOwnerErr := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
|
if getWorkspaceOwnerErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s",
|
|
workspaceOwner.Username, alog.AuditLog.ResourceTarget)
|
|
|
|
case database.ResourceTypeWorkspaceBuild:
|
|
if len(additionalFields.WorkspaceName) == 0 || len(additionalFields.BuildNumber) == 0 {
|
|
return ""
|
|
}
|
|
workspaceBuild, getWorkspaceBuildErr := api.Database.GetWorkspaceBuildByID(ctx, alog.AuditLog.ResourceID)
|
|
if getWorkspaceBuildErr != nil {
|
|
return ""
|
|
}
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
workspaceOwner, getWorkspaceOwnerErr := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
|
if getWorkspaceOwnerErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s/builds/%s",
|
|
workspaceOwner.Username, additionalFields.WorkspaceName, additionalFields.BuildNumber)
|
|
|
|
case database.ResourceTypeWorkspaceAgent:
|
|
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
|
|
return fmt.Sprintf("/@%s/%s", additionalFields.WorkspaceOwner, additionalFields.WorkspaceName)
|
|
}
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByAgentID(ctx, alog.AuditLog.ResourceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
|
|
|
|
case database.ResourceTypeWorkspaceApp:
|
|
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
|
|
return fmt.Sprintf("/@%s/%s", additionalFields.WorkspaceOwner, additionalFields.WorkspaceName)
|
|
}
|
|
workspace, getWorkspaceErr := api.Database.GetWorkspaceByWorkspaceAppID(ctx, alog.AuditLog.ResourceID)
|
|
if getWorkspaceErr != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
|
|
|
|
case database.ResourceTypeOauth2ProviderApp:
|
|
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.AuditLog.ResourceID)
|
|
|
|
case database.ResourceTypeOauth2ProviderAppSecret:
|
|
secret, err := api.Database.GetOAuth2ProviderAppSecretByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", secret.AppID)
|
|
|
|
case database.ResourceTypeTask:
|
|
task, err := api.Database.GetTaskByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
user, err := api.Database.GetUserByID(ctx, task.OwnerID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/tasks/%s/%s", user.Username, task.ID)
|
|
|
|
case database.ResourceTypeChat:
|
|
// Chats are surfaced at /agents/{id}. They are owner-scoped but
|
|
// not username-scoped in the URL like workspaces or tasks.
|
|
return fmt.Sprintf("/agents/%s", alog.AuditLog.ResourceID)
|
|
case database.ResourceTypeMCPServerConfig:
|
|
actor, ok := dbauthz.ActorFromContext(ctx)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
// The MCP settings page admits only deployment-config managers,
|
|
// so emit the link only for callers the page will accept.
|
|
if err := api.HTTPAuth.Authorizer.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/ai/settings/mcp-servers/%s", alog.AuditLog.ResourceID)
|
|
case database.ResourceTypeUserSecret:
|
|
// TODO(PLAT-102): point at the user secrets management page once
|
|
// it ships. Until then, the audit row links nowhere.
|
|
return ""
|
|
case database.ResourceTypeGroupAIBudget:
|
|
// The resource_id is the group's UUID; link to the group's
|
|
// settings page.
|
|
group, err := api.Database.GetGroupByID(ctx, alog.AuditLog.ResourceID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/organizations/%s/groups/%s", org.Name, group.Name)
|
|
case database.ResourceTypeUserAIBudgetOverride:
|
|
// TODO: point at the user's AI budget override management page
|
|
// once it ships. Until then, the audit row links nowhere.
|
|
return ""
|
|
default:
|
|
return ""
|
|
}
|
|
}
|