mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat(enterprise): add auditing to SCIM (#13614)
This commit is contained in:
@ -31,7 +31,7 @@ type RequestParams struct {
|
|||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
Request *http.Request
|
Request *http.Request
|
||||||
Action database.AuditAction
|
Action database.AuditAction
|
||||||
AdditionalFields json.RawMessage
|
AdditionalFields interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Request[T Auditable] struct {
|
type Request[T Auditable] struct {
|
||||||
@ -283,8 +283,15 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.AdditionalFields == nil {
|
additionalFieldsRaw := json.RawMessage("{}")
|
||||||
p.AdditionalFields = json.RawMessage("{}")
|
|
||||||
|
if p.AdditionalFields != nil {
|
||||||
|
data, err := json.Marshal(p.AdditionalFields)
|
||||||
|
if err != nil {
|
||||||
|
p.Log.Warn(logCtx, "marshal additional fields", slog.Error(err))
|
||||||
|
} else {
|
||||||
|
additionalFieldsRaw = json.RawMessage(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var userID uuid.UUID
|
var userID uuid.UUID
|
||||||
@ -319,7 +326,7 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
|
|||||||
Diff: diffRaw,
|
Diff: diffRaw,
|
||||||
StatusCode: int32(sw.Status),
|
StatusCode: int32(sw.Status),
|
||||||
RequestID: httpmw.RequestID(p.Request),
|
RequestID: httpmw.RequestID(p.Request),
|
||||||
AdditionalFields: p.AdditionalFields,
|
AdditionalFields: additionalFieldsRaw,
|
||||||
OrganizationID: requireOrgID[T](logCtx, p.OrganizationID, p.Log),
|
OrganizationID: requireOrgID[T](logCtx, p.OrganizationID, p.Log),
|
||||||
}
|
}
|
||||||
err := p.Audit.Export(ctx, auditLog)
|
err := p.Audit.Export(ctx, auditLog)
|
||||||
|
@ -361,17 +361,12 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
wriBytes, err := json.Marshal(workspaceResourceInfo)
|
|
||||||
if err != nil {
|
|
||||||
api.Logger.Warn(ctx, "marshal workspace owner name")
|
|
||||||
}
|
|
||||||
|
|
||||||
aReq, commitAudit := audit.InitRequest[database.Workspace](rw, &audit.RequestParams{
|
aReq, commitAudit := audit.InitRequest[database.Workspace](rw, &audit.RequestParams{
|
||||||
Audit: *auditor,
|
Audit: *auditor,
|
||||||
Log: api.Logger,
|
Log: api.Logger,
|
||||||
Request: r,
|
Request: r,
|
||||||
Action: database.AuditActionCreate,
|
Action: database.AuditActionCreate,
|
||||||
AdditionalFields: wriBytes,
|
AdditionalFields: workspaceResourceInfo,
|
||||||
OrganizationID: organization.ID,
|
OrganizationID: organization.ID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
agpl "github.com/coder/coder/v2/coderd"
|
agpl "github.com/coder/coder/v2/coderd"
|
||||||
|
"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/dbauthz"
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
@ -118,6 +119,11 @@ type SCIMUser struct {
|
|||||||
} `json:"meta"`
|
} `json:"meta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var SCIMAuditAdditionalFields = map[string]string{
|
||||||
|
"automatic_actor": "coder",
|
||||||
|
"automatic_subsystem": "scim",
|
||||||
|
}
|
||||||
|
|
||||||
// scimPostUser creates a new user, or returns the existing user if it exists.
|
// scimPostUser creates a new user, or returns the existing user if it exists.
|
||||||
//
|
//
|
||||||
// @Summary SCIM 2.0: Create new user
|
// @Summary SCIM 2.0: Create new user
|
||||||
@ -135,6 +141,16 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditor := *api.AGPL.Auditor.Load()
|
||||||
|
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
|
||||||
|
Audit: auditor,
|
||||||
|
Log: api.Logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionCreate,
|
||||||
|
AdditionalFields: SCIMAuditAdditionalFields,
|
||||||
|
})
|
||||||
|
defer commitAudit()
|
||||||
|
|
||||||
var sUser SCIMUser
|
var sUser SCIMUser
|
||||||
err := json.NewDecoder(r.Body).Decode(&sUser)
|
err := json.NewDecoder(r.Body).Decode(&sUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -170,7 +186,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if sUser.Active && dbUser.Status == database.UserStatusSuspended {
|
if sUser.Active && dbUser.Status == database.UserStatusSuspended {
|
||||||
//nolint:gocritic
|
//nolint:gocritic
|
||||||
_, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
newUser, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||||
ID: dbUser.ID,
|
ID: dbUser.ID,
|
||||||
// The user will get transitioned to Active after logging in.
|
// The user will get transitioned to Active after logging in.
|
||||||
Status: database.UserStatusDormant,
|
Status: database.UserStatusDormant,
|
||||||
@ -180,8 +196,13 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
_ = handlerutil.WriteError(rw, err)
|
_ = handlerutil.WriteError(rw, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
aReq.New = newUser
|
||||||
|
} else {
|
||||||
|
aReq.New = dbUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aReq.Old = dbUser
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, sUser)
|
httpapi.Write(ctx, rw, http.StatusOK, sUser)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -223,6 +244,8 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
_ = handlerutil.WriteError(rw, err)
|
_ = handlerutil.WriteError(rw, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
aReq.New = dbUser
|
||||||
|
aReq.UserID = dbUser.ID
|
||||||
|
|
||||||
sUser.ID = dbUser.ID.String()
|
sUser.ID = dbUser.ID.String()
|
||||||
sUser.UserName = dbUser.Username
|
sUser.UserName = dbUser.Username
|
||||||
@ -248,6 +271,15 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditor := *api.AGPL.Auditor.Load()
|
||||||
|
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
|
||||||
|
Audit: auditor,
|
||||||
|
Log: api.Logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionWrite,
|
||||||
|
})
|
||||||
|
defer commitAudit()
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
var sUser SCIMUser
|
var sUser SCIMUser
|
||||||
@ -270,6 +302,8 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
_ = handlerutil.WriteError(rw, err)
|
_ = handlerutil.WriteError(rw, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
aReq.Old = dbUser
|
||||||
|
aReq.UserID = dbUser.ID
|
||||||
|
|
||||||
var status database.UserStatus
|
var status database.UserStatus
|
||||||
if sUser.Active {
|
if sUser.Active {
|
||||||
@ -280,7 +314,7 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocritic // needed for SCIM
|
//nolint:gocritic // needed for SCIM
|
||||||
_, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
|
||||||
ID: dbUser.ID,
|
ID: dbUser.ID,
|
||||||
Status: status,
|
Status: status,
|
||||||
UpdatedAt: dbtime.Now(),
|
UpdatedAt: dbtime.Now(),
|
||||||
@ -289,6 +323,7 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
_ = handlerutil.WriteError(rw, err)
|
_ = handlerutil.WriteError(rw, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
aReq.New = userNew
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, sUser)
|
httpapi.Write(ctx, rw, http.StatusOK, sUser)
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,9 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||||
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/cryptorand"
|
"github.com/coder/coder/v2/cryptorand"
|
||||||
"github.com/coder/coder/v2/enterprise/coderd"
|
"github.com/coder/coder/v2/enterprise/coderd"
|
||||||
@ -109,21 +112,34 @@ func TestScim(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
scimAPIKey := []byte("hi")
|
scimAPIKey := []byte("hi")
|
||||||
|
mockAudit := audit.NewMock()
|
||||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
SCIMAPIKey: scimAPIKey,
|
Options: &coderdtest.Options{Auditor: mockAudit},
|
||||||
|
SCIMAPIKey: scimAPIKey,
|
||||||
|
AuditLogging: true,
|
||||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
AccountID: "coolin",
|
AccountID: "coolin",
|
||||||
Features: license.Features{
|
Features: license.Features{
|
||||||
codersdk.FeatureSCIM: 1,
|
codersdk.FeatureSCIM: 1,
|
||||||
|
codersdk.FeatureAuditLog: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
mockAudit.ResetLogs()
|
||||||
|
|
||||||
sUser := makeScimUser(t)
|
sUser := makeScimUser(t)
|
||||||
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
|
||||||
|
aLogs := mockAudit.AuditLogs()
|
||||||
|
require.Len(t, aLogs, 1)
|
||||||
|
af := map[string]string{}
|
||||||
|
err = json.Unmarshal([]byte(aLogs[0].AdditionalFields), &af)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, coderd.SCIMAuditAdditionalFields, af)
|
||||||
|
assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
|
||||||
|
|
||||||
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
|
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -306,21 +322,27 @@ func TestScim(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
scimAPIKey := []byte("hi")
|
scimAPIKey := []byte("hi")
|
||||||
|
mockAudit := audit.NewMock()
|
||||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
SCIMAPIKey: scimAPIKey,
|
Options: &coderdtest.Options{Auditor: mockAudit},
|
||||||
|
SCIMAPIKey: scimAPIKey,
|
||||||
|
AuditLogging: true,
|
||||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
AccountID: "coolin",
|
AccountID: "coolin",
|
||||||
Features: license.Features{
|
Features: license.Features{
|
||||||
codersdk.FeatureSCIM: 1,
|
codersdk.FeatureSCIM: 1,
|
||||||
|
codersdk.FeatureAuditLog: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
mockAudit.ResetLogs()
|
||||||
|
|
||||||
sUser := makeScimUser(t)
|
sUser := makeScimUser(t)
|
||||||
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
mockAudit.ResetLogs()
|
||||||
|
|
||||||
err = json.NewDecoder(res.Body).Decode(&sUser)
|
err = json.NewDecoder(res.Body).Decode(&sUser)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -333,6 +355,10 @@ func TestScim(t *testing.T) {
|
|||||||
_ = res.Body.Close()
|
_ = res.Body.Close()
|
||||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
|
||||||
|
aLogs := mockAudit.AuditLogs()
|
||||||
|
require.Len(t, aLogs, 1)
|
||||||
|
assert.Equal(t, database.AuditActionWrite, aLogs[0].Action)
|
||||||
|
|
||||||
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
|
userRes, err := client.Users(ctx, codersdk.UsersRequest{Search: sUser.Emails[0].Value})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, userRes.Users, 1)
|
require.Len(t, userRes.Users, 1)
|
||||||
|
@ -56,3 +56,45 @@ export const UnsuccessfulLoginForUnknownUser: Story = {
|
|||||||
auditLog: MockAuditLogUnsuccessfulLoginKnownUser,
|
auditLog: MockAuditLogUnsuccessfulLoginKnownUser,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CreateUser: Story = {
|
||||||
|
args: {
|
||||||
|
auditLog: {
|
||||||
|
...MockAuditLog,
|
||||||
|
resource_type: "user",
|
||||||
|
resource_target: "colin",
|
||||||
|
description: "{user} created user {target}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SCIMCreateUser: Story = {
|
||||||
|
args: {
|
||||||
|
auditLog: {
|
||||||
|
...MockAuditLog,
|
||||||
|
resource_type: "user",
|
||||||
|
resource_target: "colin",
|
||||||
|
description: "{user} created user {target}",
|
||||||
|
additional_fields: {
|
||||||
|
automatic_actor: "coder",
|
||||||
|
automatic_subsystem: "scim",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SCIMUpdateUser: Story = {
|
||||||
|
args: {
|
||||||
|
auditLog: {
|
||||||
|
...MockAuditLog,
|
||||||
|
action: "write",
|
||||||
|
resource_type: "user",
|
||||||
|
resource_target: "colin",
|
||||||
|
description: "{user} updated user {target}",
|
||||||
|
additional_fields: {
|
||||||
|
automatic_actor: "coder",
|
||||||
|
automatic_subsystem: "scim",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -12,7 +12,7 @@ export const AuditLogDescription: FC<AuditLogDescriptionProps> = ({
|
|||||||
auditLog,
|
auditLog,
|
||||||
}) => {
|
}) => {
|
||||||
let target = auditLog.resource_target.trim();
|
let target = auditLog.resource_target.trim();
|
||||||
const user = auditLog.user?.username.trim();
|
let user = auditLog.user?.username.trim();
|
||||||
|
|
||||||
if (auditLog.resource_type === "workspace_build") {
|
if (auditLog.resource_type === "workspace_build") {
|
||||||
return <BuildAuditDescription auditLog={auditLog} />;
|
return <BuildAuditDescription auditLog={auditLog} />;
|
||||||
@ -23,6 +23,14 @@ export const AuditLogDescription: FC<AuditLogDescriptionProps> = ({
|
|||||||
target = "";
|
target = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This occurs when SCIM creates a user.
|
||||||
|
if (
|
||||||
|
auditLog.resource_type === "user" &&
|
||||||
|
auditLog.additional_fields?.automatic_actor === "coder"
|
||||||
|
) {
|
||||||
|
user = "Coder automatically";
|
||||||
|
}
|
||||||
|
|
||||||
const truncatedDescription = auditLog.description
|
const truncatedDescription = auditLog.description
|
||||||
.replace("{user}", `${user}`)
|
.replace("{user}", `${user}`)
|
||||||
.replace("{target}", "");
|
.replace("{target}", "");
|
||||||
|
Reference in New Issue
Block a user