diff --git a/site/site.go b/site/site.go index ae54cce99b..0c49a12214 100644 --- a/site/site.go +++ b/site/site.go @@ -436,9 +436,10 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht return err }) eg.Go(func() error { - orgs, err := h.opts.Database.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{ - UserID: apiKey.UserID, - }) + // Match the /api/v2/organizations endpoint: RBAC-filtered and + // excluding deleted organizations. Membership-scoped queries would + // hide organizations that roles like owner can view. + orgs, err := h.opts.Database.GetOrganizations(ctx, database.GetOrganizationsParams{}) if err == nil { userOrgs = orgs } diff --git a/site/site_test.go b/site/site_test.go index 32d4bd27a2..e8f693001e 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -28,8 +28,10 @@ import ( "golang.org/x/exp/maps" "github.com/coder/coder/v2/coderd/appearance" + "github.com/coder/coder/v2/coderd/coderdtest" "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/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -349,6 +351,98 @@ func TestInjectionFailureProducesCleanHTML(t *testing.T) { assert.Equal(t, "", body) } +func TestOrganizationsMetadata(t *testing.T) { + t.Parallel() + + // GIVEN: a site handler backed by an authz-wrapped database, + // matching production wiring, and a template that renders only + // the organizations metadata. + siteFS := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte("{{ .Organizations }}"), + }, + } + rawDB, _ := dbtestutil.NewDB(t) + db := dbauthz.New( + rawDB, + rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), + testutil.Logger(t), + coderdtest.AccessControlStorePointer(), + ) + handler, err := site.New(&site.Options{ + Telemetry: telemetry.NewNoop(), + Database: db, + SiteFS: siteFS, + }) + require.NoError(t, err) + + ctx := testutil.Context(t, testutil.WaitShort) + + // GIVEN: an org with members, another org, and a soft-deleted org. + memberOrg := dbgen.Organization(t, rawDB, database.Organization{}) + otherOrg := dbgen.Organization(t, rawDB, database.Organization{}) + deletedOrg := dbgen.Organization(t, rawDB, database.Organization{}) + err = rawDB.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + ID: deletedOrg.ID, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + fetchOrgIDs := func(t *testing.T, token string) []uuid.UUID { + t.Helper() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, token) + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, r) + require.Equal(t, http.StatusOK, rw.Code) + var orgs []codersdk.Organization + err := json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &orgs) + require.NoError(t, err) + ids := make([]uuid.UUID, 0, len(orgs)) + for _, org := range orgs { + ids = append(ids, org.ID) + } + return ids + } + + // WHEN: an owner who is a member of only one org loads the page. + owner := dbgen.User(t, rawDB, database.User{ + RBACRoles: []string{codersdk.RoleOwner}, + }) + dbgen.OrganizationMember(t, rawDB, database.OrganizationMember{ + OrganizationID: memberOrg.ID, + UserID: owner.ID, + }) + _, ownerToken := dbgen.APIKey(t, rawDB, database.APIKey{ + UserID: owner.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + + // THEN: the metadata includes every non-deleted org, not just the + // orgs the owner is a member of. + ownerOrgIDs := fetchOrgIDs(t, ownerToken) + assert.Contains(t, ownerOrgIDs, memberOrg.ID) + assert.Contains(t, ownerOrgIDs, otherOrg.ID) + assert.NotContains(t, ownerOrgIDs, deletedOrg.ID) + + // WHEN: a regular member of a single org loads the page. + member := dbgen.User(t, rawDB, database.User{}) + dbgen.OrganizationMember(t, rawDB, database.OrganizationMember{ + OrganizationID: memberOrg.ID, + UserID: member.ID, + }) + _, memberToken := dbgen.APIKey(t, rawDB, database.APIKey{ + UserID: member.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + + // THEN: the metadata only includes orgs the member can read. + memberOrgIDs := fetchOrgIDs(t, memberToken) + assert.Contains(t, memberOrgIDs, memberOrg.ID) + assert.NotContains(t, memberOrgIDs, otherOrg.ID) + assert.NotContains(t, memberOrgIDs, deletedOrg.ID) +} + func TestCaching(t *testing.T) { t.Parallel()