mirror of
https://github.com/coder/coder.git
synced 2025-07-29 10:35:52 +00: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:
4
coderd/apidoc/docs.go
generated
4
coderd/apidoc/docs.go
generated
@@ -8719,6 +8719,10 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"organization_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
"resource_id": {
|
"resource_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
|
4
coderd/apidoc/swagger.json
generated
4
coderd/apidoc/swagger.json
generated
@@ -7762,6 +7762,10 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"organization_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
"resource_id": {
|
"resource_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
|
@@ -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)
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
@@ -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"),
|
||||||
|
@@ -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
2
docs/api/schemas.md
generated
@@ -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 | | |
|
||||||
|
1
site/src/api/typesGenerated.ts
generated
1
site/src/api/typesGenerated.ts
generated
@@ -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
|
||||||
|
Reference in New Issue
Block a user