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 12:38:46 -05:00
committed by GitHub
parent 20e59e0797
commit 08e728bcb2
13 changed files with 123 additions and 25 deletions
+4
View File
@@ -8719,6 +8719,10 @@ const docTemplate = `{
}
]
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"resource_id": {
"type": "string",
"format": "uuid"
+4
View File
@@ -7762,6 +7762,10 @@
}
]
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"resource_id": {
"type": "string",
"format": "uuid"
+9 -1
View File
@@ -18,6 +18,7 @@ import (
"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/searchquery"
@@ -61,6 +62,10 @@ func (api *API) auditLogs(rw http.ResponseWriter, r *http.Request) {
}
dblogs, err := api.Database.GetAuditLogsOffset(ctx, filter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
@@ -139,6 +144,9 @@ func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
if len(params.AdditionalFields) == 0 {
params.AdditionalFields = json.RawMessage("{}")
}
if params.OrganizationID == uuid.Nil {
params.OrganizationID = uuid.New()
}
_, err = api.Database.InsertAuditLog(ctx, database.InsertAuditLogParams{
ID: uuid.New(),
@@ -155,7 +163,7 @@ func (api *API) generateFakeAuditLog(rw http.ResponseWriter, r *http.Request) {
AdditionalFields: params.AdditionalFields,
RequestID: uuid.Nil, // no request ID to attach this to
ResourceIcon: "",
OrganizationID: uuid.New(),
OrganizationID: params.OrganizationID,
})
if err != nil {
httpapi.InternalServerError(rw, err)
+50
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"testing"
"time"
@@ -11,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"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",
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) {
+13 -4
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) {
// To optimize audit logs, we only check the global audit log permission once.
// This is because we expect a large unbounded set of audit logs, and applying a SQL
// filter would slow down the query for no benefit.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil {
// To optimize the authz checks for audit logs, do not run an authorize
// check on each individual audit log row. In practice, audit logs are either
// fetched from a global or an organization scope.
// 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 q.db.GetAuditLogsOffset(ctx, arg)
}
+3
View File
@@ -1928,6 +1928,9 @@ func (q *FakeQuerier) GetAuditLogsOffset(_ context.Context, arg database.GetAudi
arg.Offset--
continue
}
if arg.OrganizationID != uuid.Nil && arg.OrganizationID != alog.OrganizationID {
continue
}
if arg.Action != "" && !strings.Contains(string(alog.Action), arg.Action) {
continue
}
+24 -16
View File
@@ -500,52 +500,58 @@ WHERE
resource_id = $4
ELSE true
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
AND CASE
WHEN $5 :: text != '' THEN
resource_target = $5
WHEN $6 :: text != '' THEN
resource_target = $6
ELSE true
END
-- Filter action
AND CASE
WHEN $6 :: text != '' THEN
action = $6 :: audit_action
WHEN $7 :: text != '' THEN
action = $7 :: audit_action
ELSE true
END
-- Filter by user_id
AND CASE
WHEN $7 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = $7
WHEN $8 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
user_id = $8
ELSE true
END
-- Filter by username
AND CASE
WHEN $8 :: text != '' THEN
user_id = (SELECT id FROM users WHERE lower(username) = lower($8) AND deleted = false)
WHEN $9 :: text != '' THEN
user_id = (SELECT id FROM users WHERE lower(username) = lower($9) AND deleted = false)
ELSE true
END
-- Filter by user_email
AND CASE
WHEN $9 :: text != '' THEN
users.email = $9
WHEN $10 :: text != '' THEN
users.email = $10
ELSE true
END
-- Filter by date_from
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
"time" >= $10
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
"time" >= $11
ELSE true
END
-- Filter by date_to
AND CASE
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
"time" <= $11
WHEN $12 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
"time" <= $12
ELSE true
END
-- Filter by build_reason
AND CASE
WHEN $12::text != '' THEN
workspace_builds.reason::text = $12
WHEN $13::text != '' THEN
workspace_builds.reason::text = $13
ELSE true
END
ORDER BY
@@ -561,6 +567,7 @@ type GetAuditLogsOffsetParams struct {
Offset int32 `db:"offset" json:"offset"`
ResourceType string `db:"resource_type" json:"resource_type"`
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"`
Action string `db:"action" json:"action"`
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.ResourceType,
arg.ResourceID,
arg.OrganizationID,
arg.ResourceTarget,
arg.Action,
arg.UserID,
+6
View File
@@ -59,6 +59,12 @@ WHERE
resource_id = @resource_id
ELSE true
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
AND CASE
WHEN @resource_target :: text != '' THEN
+5 -4
View File
@@ -176,10 +176,11 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
apiKey = httpmw.APIKey(r)
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: organization.ID,
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
aReq.Old = member.OrganizationMember.Auditable(member.Username)
+1
View File
@@ -30,6 +30,7 @@ func AuditLogs(query string) (database.GetAuditLogsOffsetParams, []codersdk.Vali
const dateLayout = "2006-01-02"
parser := httpapi.NewQueryParamParser()
filter := database.GetAuditLogsOffsetParams{
OrganizationID: parser.UUID(values, uuid.Nil, "organization_id"),
ResourceID: parser.UUID(values, uuid.Nil, "resource_id"),
ResourceTarget: parser.String(values, "", "resource_target"),
Username: parser.String(values, "", "username"),
+1
View File
@@ -161,6 +161,7 @@ type CreateTestAuditLogRequest struct {
AdditionalFields json.RawMessage `json:"additional_fields,omitempty"`
Time time.Time `json:"time,omitempty" format:"date-time"`
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.
+2
View File
@@ -1181,6 +1181,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"action": "create",
"additional_fields": [0],
"build_reason": "autostart",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f",
"resource_type": "template",
"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 | | |
| `additional_fields` | array of integer | false | | |
| `build_reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
| `organization_id` | string | false | | |
| `resource_id` | string | false | | |
| `resource_type` | [codersdk.ResourceType](#codersdkresourcetype) | false | | |
| `time` | string | false | | |
+1
View File
@@ -282,6 +282,7 @@ export interface CreateTestAuditLogRequest {
readonly additional_fields?: Record<string, string>;
readonly time?: string;
readonly build_reason?: BuildReason;
readonly organization_id?: string;
}
// From codersdk/apikey.go