mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
Generated
+4
@@ -8719,6 +8719,10 @@ const docTemplate = `{
|
||||
}
|
||||
]
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"resource_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
|
||||
Generated
+4
@@ -7762,6 +7762,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"organization_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"resource_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
|
||||
+9
-1
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
@@ -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 | | |
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user