Skip to content

chore: ensure proper rbac permissions on 'Acquire' file in the cache #18348

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 17 commits into from
Jun 16, 2025
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
8 changes: 4 additions & 4 deletions coderd/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// objects that the user is authorized to perform the given action on.
// This is faster than calling Authorize() on each object.
func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action policy.Action, objects []O) ([]O, error) {
roles := httpmw.UserAuthorization(r)
roles := httpmw.UserAuthorization(r.Context())
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles, action, objects)
if err != nil {
// Log the error as Filter should not be erroring.
Expand Down Expand Up @@ -65,7 +65,7 @@ func (api *API) Authorize(r *http.Request, action policy.Action, object rbac.Obj
// return
// }
func (h *HTTPAuthorizer) Authorize(r *http.Request, action policy.Action, object rbac.Objecter) bool {
roles := httpmw.UserAuthorization(r)
roles := httpmw.UserAuthorization(r.Context())
err := h.Authorizer.Authorize(r.Context(), roles, action, object.RBACObject())
if err != nil {
// Log the errors for debugging
Expand Down Expand Up @@ -97,7 +97,7 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action policy.Action, object
// call 'Authorize()' on the returned objects.
// Note the authorization is only for the given action and object type.
func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) {
roles := httpmw.UserAuthorization(r)
roles := httpmw.UserAuthorization(r.Context())
prepared, err := h.Authorizer.Prepare(r.Context(), roles, action, objectType)
if err != nil {
return nil, xerrors.Errorf("prepare filter: %w", err)
Expand All @@ -120,7 +120,7 @@ func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action policy.Actio
// @Router /authcheck [post]
func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
auth := httpmw.UserAuthorization(r)
auth := httpmw.UserAuthorization(r.Context())

var params codersdk.AuthorizationRequest
if !httpapi.Read(ctx, rw, r, &params) {
Expand Down
2 changes: 1 addition & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ func New(options *Options) *API {
TemplateScheduleStore: options.TemplateScheduleStore,
UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore,
AccessControlStore: options.AccessControlStore,
FileCache: files.NewFromStore(options.Database, options.PrometheusRegistry),
FileCache: files.NewFromStore(options.Database, options.PrometheusRegistry, options.Authorizer),
Experiments: experiments,
WebpushDispatcher: options.WebPushDispatcher,
healthCheckGroup: &singleflight.Group[string, *healthsdk.HealthcheckReport]{},
Expand Down
6 changes: 5 additions & 1 deletion coderd/coderdtest/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ func (r *RecordingAuthorizer) AssertOutOfOrder(t *testing.T, actor rbac.Subject,
// AssertActor asserts in order. If the order of authz calls does not match,
// this will fail.
func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did ...ActionObjectPair) {
r.AssertActorID(t, actor.ID, did...)
}

func (r *RecordingAuthorizer) AssertActorID(t *testing.T, id string, did ...ActionObjectPair) {
r.Lock()
defer r.Unlock()
ptr := 0
Expand All @@ -242,7 +246,7 @@ func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did
// Finished all assertions
return
}
if call.Actor.ID == actor.ID {
if call.Actor.ID == id {
action, object := did[ptr].Action, did[ptr].Object
assert.Equalf(t, action, call.Action, "assert action %d", ptr)
assert.Equalf(t, object, call.Object, "assert object %d", ptr)
Expand Down
23 changes: 23 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,25 @@ var (
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()

subjectFileReader = rbac.Subject{
Type: rbac.SubjectTypeFileReader,
FriendlyName: "Can Read All Files",
// Arbitrary uuid to have a unique ID for this subject.
ID: rbac.SubjectTypeFileReaderID,
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "file-reader"},
DisplayName: "FileReader",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceFile.Type: {policy.ActionRead},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
},
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
)

// AsProvisionerd returns a context with an actor that has permissions required
Expand Down Expand Up @@ -498,6 +517,10 @@ func AsPrebuildsOrchestrator(ctx context.Context) context.Context {
return As(ctx, subjectPrebuildsOrchestrator)
}

func AsFileReader(ctx context.Context) context.Context {
return As(ctx, subjectFileReader)
}

var AsRemoveActor = rbac.Subject{
ID: "remove-actor",
}
Expand Down
57 changes: 39 additions & 18 deletions coderd/files/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,41 @@ import (

archivefs "github.com/coder/coder/v2/archive/fs"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/lazy"
)

// NewFromStore returns a file cache that will fetch files from the provided
// database.
func NewFromStore(store database.Store, registerer prometheus.Registerer) *Cache {
fetch := func(ctx context.Context, fileID uuid.UUID) (cacheEntryValue, error) {
file, err := store.GetFileByID(ctx, fileID)
func NewFromStore(store database.Store, registerer prometheus.Registerer, authz rbac.Authorizer) *Cache {
fetch := func(ctx context.Context, fileID uuid.UUID) (CacheEntryValue, error) {
// Make sure the read does not fail due to authorization issues.
// Authz is checked on the Acquire call, so this is safe.
//nolint:gocritic
file, err := store.GetFileByID(dbauthz.AsFileReader(ctx), fileID)
if err != nil {
return cacheEntryValue{}, xerrors.Errorf("failed to read file from database: %w", err)
return CacheEntryValue{}, xerrors.Errorf("failed to read file from database: %w", err)
}

content := bytes.NewBuffer(file.Data)
return cacheEntryValue{
FS: archivefs.FromTarReader(content),
size: int64(content.Len()),
return CacheEntryValue{
Object: file.RBACObject(),
FS: archivefs.FromTarReader(content),
Size: int64(content.Len()),
}, nil
}

return New(fetch, registerer)
return New(fetch, registerer, authz)
}

func New(fetch fetcher, registerer prometheus.Registerer) *Cache {
func New(fetch fetcher, registerer prometheus.Registerer, authz rbac.Authorizer) *Cache {
return (&Cache{
lock: sync.Mutex{},
data: make(map[uuid.UUID]*cacheEntry),
fetcher: fetch,
authz: authz,
}).registerMetrics(registerer)
}

Expand Down Expand Up @@ -101,6 +109,7 @@ type Cache struct {
lock sync.Mutex
data map[uuid.UUID]*cacheEntry
fetcher
authz rbac.Authorizer

// metrics
cacheMetrics
Expand All @@ -117,18 +126,19 @@ type cacheMetrics struct {
totalCacheSize prometheus.Counter
}

type cacheEntryValue struct {
type CacheEntryValue struct {
fs.FS
size int64
Object rbac.Object
Size int64
}

type cacheEntry struct {
// refCount must only be accessed while the Cache lock is held.
refCount int
value *lazy.ValueWithError[cacheEntryValue]
value *lazy.ValueWithError[CacheEntryValue]
}

type fetcher func(context.Context, uuid.UUID) (cacheEntryValue, error)
type fetcher func(context.Context, uuid.UUID) (CacheEntryValue, error)

// Acquire will load the fs.FS for the given file. It guarantees that parallel
// calls for the same fileID will only result in one fetch, and that parallel
Expand All @@ -146,22 +156,33 @@ func (c *Cache) Acquire(ctx context.Context, fileID uuid.UUID) (fs.FS, error) {
c.Release(fileID)
return nil, err
}

subject, ok := dbauthz.ActorFromContext(ctx)
if !ok {
return nil, dbauthz.ErrNoActor
}
// Always check the caller can actually read the file.
if err := c.authz.Authorize(ctx, subject, policy.ActionRead, it.Object); err != nil {
c.Release(fileID)
return nil, err
}

return it.FS, err
}

func (c *Cache) prepare(ctx context.Context, fileID uuid.UUID) *lazy.ValueWithError[cacheEntryValue] {
func (c *Cache) prepare(ctx context.Context, fileID uuid.UUID) *lazy.ValueWithError[CacheEntryValue] {
c.lock.Lock()
defer c.lock.Unlock()

entry, ok := c.data[fileID]
if !ok {
value := lazy.NewWithError(func() (cacheEntryValue, error) {
value := lazy.NewWithError(func() (CacheEntryValue, error) {
val, err := c.fetcher(ctx, fileID)

// Always add to the cache size the bytes of the file loaded.
if err == nil {
c.currentCacheSize.Add(float64(val.size))
c.totalCacheSize.Add(float64(val.size))
c.currentCacheSize.Add(float64(val.Size))
c.totalCacheSize.Add(float64(val.Size))
}

return val, err
Expand Down Expand Up @@ -206,7 +227,7 @@ func (c *Cache) Release(fileID uuid.UUID) {

ev, err := entry.value.Load()
if err == nil {
c.currentCacheSize.Add(-1 * float64(ev.size))
c.currentCacheSize.Add(-1 * float64(ev.Size))
}

delete(c.data, fileID)
Expand Down
Loading
Loading