Skip to content

fix(coderd): ensure correct RBAC when enqueueing notifications #15478

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions coderd/autobuild/lifecycle_executor_test.go
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: no changes are needed to lifecycle_executor.go as it has its own actor to which I've added the required permissions

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/util/ptr"
Expand Down Expand Up @@ -116,7 +117,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
tickCh = make(chan time.Time)
statsCh = make(chan autobuild.Stats)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: !tc.expectStart}).Leveled(slog.LevelDebug)
enqueuer = testutil.FakeNotificationsEnqueuer{}
enqueuer = notificationstest.FakeEnqueuer{}
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
IncludeProvisionerDaemon: true,
Expand Down Expand Up @@ -202,17 +203,18 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) {
}

if tc.expectNotification {
require.Len(t, enqueuer.Sent, 1)
require.Equal(t, enqueuer.Sent[0].UserID, workspace.OwnerID)
require.Contains(t, enqueuer.Sent[0].Targets, workspace.TemplateID)
require.Contains(t, enqueuer.Sent[0].Targets, workspace.ID)
require.Contains(t, enqueuer.Sent[0].Targets, workspace.OrganizationID)
require.Contains(t, enqueuer.Sent[0].Targets, workspace.OwnerID)
require.Equal(t, newVersion.Name, enqueuer.Sent[0].Labels["template_version_name"])
require.Equal(t, "autobuild", enqueuer.Sent[0].Labels["initiator"])
require.Equal(t, "autostart", enqueuer.Sent[0].Labels["reason"])
sent := enqueuer.Sent()
require.Len(t, sent, 1)
require.Equal(t, sent[0].UserID, workspace.OwnerID)
require.Contains(t, sent[0].Targets, workspace.TemplateID)
require.Contains(t, sent[0].Targets, workspace.ID)
require.Contains(t, sent[0].Targets, workspace.OrganizationID)
require.Contains(t, sent[0].Targets, workspace.OwnerID)
require.Equal(t, newVersion.Name, sent[0].Labels["template_version_name"])
require.Equal(t, "autobuild", sent[0].Labels["initiator"])
require.Equal(t, "autostart", sent[0].Labels["reason"])
} else {
require.Len(t, enqueuer.Sent, 0)
require.Empty(t, enqueuer.Sent())
}
})
}
Expand Down Expand Up @@ -1073,7 +1075,7 @@ func TestNotifications(t *testing.T) {
var (
ticker = make(chan time.Time)
statCh = make(chan autobuild.Stats)
notifyEnq = testutil.FakeNotificationsEnqueuer{}
notifyEnq = notificationstest.FakeEnqueuer{}
timeTilDormant = time.Minute
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: ticker,
Expand Down Expand Up @@ -1107,6 +1109,7 @@ func TestNotifications(t *testing.T) {
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)

// Wait for workspace to become dormant
notifyEnq.Clear()
ticker <- workspace.LastUsedAt.Add(timeTilDormant * 3)
_ = testutil.RequireRecvCtx(testutil.Context(t, testutil.WaitShort), t, statCh)

Expand All @@ -1115,14 +1118,14 @@ func TestNotifications(t *testing.T) {
require.NotNil(t, workspace.DormantAt)

// Check that a notification was enqueued
require.Len(t, notifyEnq.Sent, 2)
// notifyEnq.Sent[0] is an event for created user account
require.Equal(t, notifyEnq.Sent[1].UserID, workspace.OwnerID)
require.Equal(t, notifyEnq.Sent[1].TemplateID, notifications.TemplateWorkspaceDormant)
require.Contains(t, notifyEnq.Sent[1].Targets, template.ID)
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.ID)
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OrganizationID)
require.Contains(t, notifyEnq.Sent[1].Targets, workspace.OwnerID)
sent := notifyEnq.Sent()
require.Len(t, sent, 1)
require.Equal(t, sent[0].UserID, workspace.OwnerID)
require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceDormant)
require.Contains(t, sent[0].Targets, template.ID)
require.Contains(t, sent[0].Targets, workspace.ID)
require.Contains(t, sent[0].Targets, workspace.OrganizationID)
require.Contains(t, sent[0].Targets, workspace.OwnerID)
})
}

Expand Down Expand Up @@ -1168,7 +1171,7 @@ func mustSchedule(t *testing.T, s string) *cron.Schedule {
}

func mustWorkspaceParameters(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID) {
ctx := context.Background()
ctx := testutil.Context(t, testutil.WaitShort)
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceID)
require.NoError(t, err)
require.NotEmpty(t, buildParameters)
Expand Down
5 changes: 3 additions & 2 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import (
"github.com/coder/coder/v2/coderd/gitsshkey"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/runtimeconfig"
Expand Down Expand Up @@ -251,7 +252,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
}

if options.NotificationsEnqueuer == nil {
options.NotificationsEnqueuer = new(testutil.FakeNotificationsEnqueuer)
options.NotificationsEnqueuer = &notificationstest.FakeEnqueuer{}
}

accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
Expand Down Expand Up @@ -311,7 +312,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
t.Cleanup(closeBatcher)
}
if options.NotificationsEnqueuer == nil {
options.NotificationsEnqueuer = &testutil.FakeNotificationsEnqueuer{}
options.NotificationsEnqueuer = &notificationstest.FakeEnqueuer{}
}

if options.OneTimePasscodeValidityPeriod == 0 {
Expand Down
13 changes: 8 additions & 5 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ var (
// this can be reduced to read a specific org.
rbac.ResourceOrganization.Type: {policy.ActionRead},
rbac.ResourceGroup.Type: {policy.ActionRead},
// Provisionerd creates notification messages
rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
Expand All @@ -194,11 +196,12 @@ var (
Identifier: rbac.RoleIdentifier{Name: "autostart"},
DisplayName: "Autostart Daemon",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
rbac.ResourceUser.Type: {policy.ActionRead},
rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead},
rbac.ResourceSystem.Type: {policy.WildcardSymbol},
rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate},
rbac.ResourceUser.Type: {policy.ActionRead},
rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop},
rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
Expand Down
99 changes: 99 additions & 0 deletions coderd/notifications/notificationstest/fake_enqueuer.go
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: moved to its own package to avoid import cycle

Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package notificationstest

import (
"context"
"fmt"
"sync"

"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"

"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
)

type FakeEnqueuer struct {
authorizer rbac.Authorizer
mu sync.Mutex
sent []*FakeNotification
}

type FakeNotification struct {
UserID, TemplateID uuid.UUID
Labels map[string]string
Data map[string]any
CreatedBy string
Targets []uuid.UUID
}

// TODO: replace this with actual calls to dbauthz.
// See: https://github.com/coder/coder/issues/15481
func (f *FakeEnqueuer) assertRBACNoLock(ctx context.Context) {
if f.mu.TryLock() {
panic("Developer error: do not call assertRBACNoLock outside of a mutex lock!")
}

// If we get here, we are locked.
if f.authorizer == nil {
f.authorizer = rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
}

act, ok := dbauthz.ActorFromContext(ctx)
if !ok {
panic("Developer error: no actor in context, you may need to use dbauthz.AsNotifier(ctx)")
}

for _, a := range []policy.Action{policy.ActionCreate, policy.ActionRead} {
err := f.authorizer.Authorize(ctx, act, a, rbac.ResourceNotificationMessage)
if err == nil {
return
}

if rbac.IsUnauthorizedError(err) {
panic(fmt.Sprintf("Developer error: not authorized to %s %s. "+
"Ensure that you are using dbauthz.AsXXX with an actor that has "+
"policy.ActionCreate on rbac.ResourceNotificationMessage", a, rbac.ResourceNotificationMessage.Type))
}
panic("Developer error: failed to check auth:" + err.Error())
}
}

func (f *FakeEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) {
return f.EnqueueWithData(ctx, userID, templateID, labels, nil, createdBy, targets...)
}

func (f *FakeEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) {
return f.enqueueWithDataLock(ctx, userID, templateID, labels, data, createdBy, targets...)
}

func (f *FakeEnqueuer) enqueueWithDataLock(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.assertRBACNoLock(ctx)

f.sent = append(f.sent, &FakeNotification{
UserID: userID,
TemplateID: templateID,
Labels: labels,
Data: data,
CreatedBy: createdBy,
Targets: targets,
})

id := uuid.New()
return &id, nil
}

func (f *FakeEnqueuer) Clear() {
f.mu.Lock()
defer f.mu.Unlock()

f.sent = nil
}

func (f *FakeEnqueuer) Sent() []*FakeNotification {
f.mu.Lock()
defer f.mu.Unlock()
return append([]*FakeNotification{}, f.sent...)
}
Loading
Loading