chore: implement organization scoped audit log requests (#13663)

* chore: add organization_id filter to audit logs
* chore: implement organization scoped audit log requests
This commit is contained in:
Steven Masley
2024-06-26 07:38:46 -10:00
committed by GitHub
parent 20e59e0797
commit 08e728bcb2
13 changed files with 123 additions and 25 deletions

4
coderd/apidoc/docs.go generated
View File

@@ -8719,6 +8719,10 @@ const docTemplate = `{
} }
] ]
}, },
"organization_id": {
"type": "string",
"format": "uuid"
},
"resource_id": { "resource_id": {
"type": "string", "type": "string",
"format": "uuid" "format": "uuid"

View File

@@ -7762,6 +7762,10 @@
} }
] ]
}, },
"organization_id": {
"type": "string",
"format": "uuid"
},
"resource_id": { "resource_id": {
"type": "string", "type": "string",
"format": "uuid" "format": "uuid"

View File

@@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk" "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/httpapi"
"github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/searchquery"
@@ -61,6 +62,10 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
} }
dblogs, err := api.Database.GetAuditLogsOffset(ctx, filter) dblogs, err := api.Database.GetAuditLogsOffset(ctx, filter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil { if err != nil {
httpapi.InternalServerError(rw, err) httpapi.InternalServerError(rw, err)
return return
@@ -139,6 +144,9 @@ func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
if len(params.AdditionalFields) == 0 { if len(params.AdditionalFields) == 0 {
params.AdditionalFields = json.RawMessage("{}") params.AdditionalFields = json.RawMessage("{}")
} }
if params.OrganizationID == uuid.Nil {
params.OrganizationID = uuid.New()
}
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{ _, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
ID: uuid.New(), ID: uuid.New(),
@@ -155,7 +163,7 @@ func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
AdditionalFields: params.AdditionalFields, AdditionalFields: params.AdditionalFields,
RequestID: uuid.Nil, // no request ID to attach this to RequestID: uuid.Nil, // no request ID to attach this to
ResourceIcon: "", ResourceIcon: "",
OrganizationID: uuid.New(), OrganizationID: params.OrganizationID,
}) })
if err != nil { if err != nil {
httpapi.InternalServerError(rw, err) httpapi.InternalServerError(rw, err)

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@@ -11,6 +12,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
@@ -135,6 +137,54 @@ func TestAuditLogs(t *testing.T) {
require.Equal(t, auditLogs.AuditLogs[0].ResourceLink, fmt.Sprintf("/@%s/%s/builds/%s", require.Equal(t, auditLogs.AuditLogs[0].ResourceLink, fmt.Sprintf("/@%s/%s/builds/%s",
workspace.OwnerName, workspace.Name, buildNumberString)) workspace.OwnerName, workspace.Name, buildNumberString))
}) })
t.Run("Organization", func(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, &slogtest.Options{
IgnoreErrors: true,
})
ctx := context.Background()
client := coderdtest.New(t, &coderdtest.Options{
Logger: &logger,
})
owner := coderdtest.CreateFirstUser(t, client)
orgAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID))
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
ResourceID: owner.UserID,
OrganizationID: owner.OrganizationID,
})
require.NoError(t, err)
// Add an extra audit log in another organization
err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
ResourceID: owner.UserID,
OrganizationID: uuid.New(),
})
require.NoError(t, err)
// Fetching audit logs without an organization selector should fail
_, err = orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 5,
},
})
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
// Using the organization selector allows the org admin to fetch audit logs
alogs, err := orgAdmin.AuditLogs(ctx, codersdk.AuditLogsRequest{
SearchQuery: fmt.Sprintf("organization_id:%s", owner.OrganizationID.String()),
Pagination: codersdk.Pagination{
Limit: 5,
},
})
require.NoError(t, err)
require.Len(t, alogs.AuditLogs, 1)
})
} }
func TestAuditLogsFilter(t *testing.T) { func TestAuditLogsFilter(t *testing.T) {

View File

@@ -1200,12 +1200,21 @@ func (q *querier) GetApplicationName(ctx context.Context) (string, error) {
} }
func (q *querier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) { func (q *querier) GetAuditLogsOffset(ctx context.Context, arg database.GetAuditLogsOffsetParams) ([]database.GetAuditLogsOffsetRow, error) {
// To optimize audit logs, we only check the global audit log permission once. // To optimize the authz checks for audit logs, do not run an authorize
// This is because we expect a large unbounded set of audit logs, and applying a SQL // check on each individual audit log row. In practice, audit logs are either
// filter would slow down the query for no benefit. // fetched from a global or an organization scope.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { // Applying a SQL filter would slow down the query for no benefit on how this query is
// actually used.
object := rbac.ResourceAuditLog
if arg.OrganizationID != uuid.Nil {
object = object.InOrg(arg.OrganizationID)
}
if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil {
return nil, err return nil, err
} }
return q.db.GetAuditLogsOffset(ctx, arg) return q.db.GetAuditLogsOffset(ctx, arg)
} }

View File

@@ -1928,6 +1928,9 @@ func (q *FakeQuerier) GetAuditLogsOffset(_ context.Context, arg database.GetAudi
arg.Offset-- arg.Offset--
continue continue
} }
if arg.OrganizationID != uuid.Nil && arg.OrganizationID != alog.OrganizationID {
continue
}
if arg.Action != "" && !strings.Contains(string(alog.Action), arg.Action) { if arg.Action != "" && !strings.Contains(string(alog.Action), arg.Action) {
continue continue
} }

View File

@@ -500,52 +500,58 @@ WHERE
resource_id = $4 resource_id = $4
ELSE true ELSE true
END END
-- Filter organization_id
AND CASE
WHEN $5 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
audit_logs.organization_id = $5
ELSE true
END
-- Filter by resource_target -- Filter by resource_target
AND CASE AND CASE
WHEN $5 :: text != '' THEN WHEN $6 :: text != '' THEN
resource_target = $5 resource_target = $6
ELSE true ELSE true
END END
-- Filter action -- Filter action
AND CASE AND CASE
WHEN $6 :: text != '' THEN WHEN $7 :: text != '' THEN
action = $6 :: audit_action action = $7 :: audit_action
ELSE true ELSE true
END END
-- Filter by user_id -- Filter by user_id
AND CASE AND CASE
WHEN $7 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN WHEN $8 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = $7 user_id = $8
ELSE true ELSE true
END END
-- Filter by username -- Filter by username
AND CASE AND CASE
WHEN $8 :: text != '' THEN WHEN $9 :: text != '' THEN
user_id = (SELECT id FROM users WHERE lower(username) = lower($8) AND deleted = false) user_id = (SELECT id FROM users WHERE lower(username) = lower($9) AND deleted = false)
ELSE true ELSE true
END END
-- Filter by user_email -- Filter by user_email
AND CASE AND CASE
WHEN $9 :: text != '' THEN WHEN $10 :: text != '' THEN
users.email = $9 users.email = $10
ELSE true ELSE true
END END
-- Filter by date_from -- Filter by date_from
AND CASE AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
"time" >= $10 "time" >= $11
ELSE true ELSE true
END END
-- Filter by date_to -- Filter by date_to
AND CASE AND CASE
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN WHEN $12 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
"time" <= $11 "time" <= $12
ELSE true ELSE true
END END
-- Filter by build_reason -- Filter by build_reason
AND CASE AND CASE
WHEN $12::text != '' THEN WHEN $13::text != '' THEN
workspace_builds.reason::text = $12 workspace_builds.reason::text = $13
ELSE true ELSE true
END END
ORDER BY ORDER BY
@@ -561,6 +567,7 @@ type GetAuditLogsOffsetParams struct {
Offset int32 `db:"offset" json:"offset"` Offset int32 `db:"offset" json:"offset"`
ResourceType string `db:"resource_type" json:"resource_type"` ResourceType string `db:"resource_type" json:"resource_type"`
ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` ResourceID uuid.UUID `db:"resource_id" json:"resource_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
ResourceTarget string `db:"resource_target" json:"resource_target"` ResourceTarget string `db:"resource_target" json:"resource_target"`
Action string `db:"action" json:"action"` Action string `db:"action" json:"action"`
UserID uuid.UUID `db:"user_id" json:"user_id"` UserID uuid.UUID `db:"user_id" json:"user_id"`
@@ -611,6 +618,7 @@ func (q *sqlQuerier) GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOff
arg.Offset, arg.Offset,
arg.ResourceType, arg.ResourceType,
arg.ResourceID, arg.ResourceID,
arg.OrganizationID,
arg.ResourceTarget, arg.ResourceTarget,
arg.Action, arg.Action,
arg.UserID, arg.UserID,

View File

@@ -59,6 +59,12 @@ WHERE
resource_id = @resource_id resource_id = @resource_id
ELSE true ELSE true
END END
-- Filter organization_id
AND CASE
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
audit_logs.organization_id = @organization_id
ELSE true
END
-- Filter by resource_target -- Filter by resource_target
AND CASE AND CASE
WHEN @resource_target :: text != '' THEN WHEN @resource_target :: text != '' THEN

View File

@@ -176,10 +176,11 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
apiKey = httpmw.APIKey(r) apiKey = httpmw.APIKey(r)
auditor = api.Auditor.Load() auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{ aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{
Audit: *auditor, OrganizationID: organization.ID,
Log: api.Logger, Audit: *auditor,
Request: r, Log: api.Logger,
Action: database.AuditActionWrite, Request: r,
Action: database.AuditActionWrite,
}) })
) )
aReq.Old = member.OrganizationMember.Auditable(member.Username) aReq.Old = member.OrganizationMember.Auditable(member.Username)

View File

@@ -30,6 +30,7 @@ func AuditLogs(query string) (database.GetAuditLogsOffsetParams, []codersdk.Vali
const dateLayout = "2006-01-02" const dateLayout = "2006-01-02"
parser := httpapi.NewQueryParamParser() parser := httpapi.NewQueryParamParser()
filter := database.GetAuditLogsOffsetParams{ filter := database.GetAuditLogsOffsetParams{
OrganizationID: parser.UUID(values, uuid.Nil, "organization_id"),
ResourceID: parser.UUID(values, uuid.Nil, "resource_id"), ResourceID: parser.UUID(values, uuid.Nil, "resource_id"),
ResourceTarget: parser.String(values, "", "resource_target"), ResourceTarget: parser.String(values, "", "resource_target"),
Username: parser.String(values, "", "username"), Username: parser.String(values, "", "username"),

View File

@@ -161,6 +161,7 @@ type CreateTestAuditLogRequest struct {
AdditionalFields json.RawMessage `json:"additional_fields,omitempty"` AdditionalFields json.RawMessage `json:"additional_fields,omitempty"`
Time time.Time `json:"time,omitempty" format:"date-time"` Time time.Time `json:"time,omitempty" format:"date-time"`
BuildReason BuildReason `json:"build_reason,omitempty" enums:"autostart,autostop,initiator"` BuildReason BuildReason `json:"build_reason,omitempty" enums:"autostart,autostop,initiator"`
OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid"`
} }
// AuditLogs retrieves audit logs from the given page. // AuditLogs retrieves audit logs from the given page.

2
docs/api/schemas.md generated
View File

@@ -1181,6 +1181,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"action": "create", "action": "create",
"additional_fields": [0], "additional_fields": [0],
"build_reason": "autostart", "build_reason": "autostart",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f",
"resource_type": "template", "resource_type": "template",
"time": "2019-08-24T14:15:22Z" "time": "2019-08-24T14:15:22Z"
@@ -1194,6 +1195,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `action` | [codersdk.AuditAction](#codersdkauditaction) | false | | | | `action` | [codersdk.AuditAction](#codersdkauditaction) | false | | |
| `additional_fields` | array of integer | false | | | | `additional_fields` | array of integer | false | | |
| `build_reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | | | `build_reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
| `organization_id` | string | false | | |
| `resource_id` | string | false | | | | `resource_id` | string | false | | |
| `resource_type` | [codersdk.ResourceType](#codersdkresourcetype) | false | | | | `resource_type` | [codersdk.ResourceType](#codersdkresourcetype) | false | | |
| `time` | string | false | | | | `time` | string | false | | |

View File

@@ -282,6 +282,7 @@ export interface CreateTestAuditLogRequest {
readonly additional_fields?: Record<string, string>; readonly additional_fields?: Record<string, string>;
readonly time?: string; readonly time?: string;
readonly build_reason?: BuildReason; readonly build_reason?: BuildReason;
readonly organization_id?: string;
} }
// From codersdk/apikey.go // From codersdk/apikey.go