From 840e2e26b958f247f73995bcf97a812b17a4034f Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Thu, 25 Jun 2026 17:04:46 -0700 Subject: [PATCH 1/2] use org access instead of role attachments --- pkg/cmd/grantssh/grantssh.go | 25 ++++++----- pkg/cmd/grantssh/grantssh_test.go | 36 ++++++++-------- pkg/cmd/revokessh/revokessh.go | 15 ++++--- pkg/cmd/revokessh/revokessh_test.go | 11 ++--- pkg/entity/entity.go | 11 ----- pkg/store/organization.go | 66 ++++++++++++++++++++++------- pkg/store/organization_test.go | 54 +++++++++++++++++++++++ 7 files changed, 150 insertions(+), 68 deletions(-) diff --git a/pkg/cmd/grantssh/grantssh.go b/pkg/cmd/grantssh/grantssh.go index 74971700..35985834 100644 --- a/pkg/cmd/grantssh/grantssh.go +++ b/pkg/cmd/grantssh/grantssh.go @@ -30,7 +30,7 @@ type GrantSSHStore interface { GetOrganizationsByName(name string) ([]entity.Organization, error) ListOrganizations() ([]entity.Organization, error) GetAccessToken() (string, error) - GetOrgRoleAttachments(orgID string) ([]entity.OrgRoleAttachment, error) + ListOrganizationMembers(ctx context.Context, orgID string) ([]*nodev1.OrganizationMember, error) GetUserByID(userID string) (*entity.User, error) } @@ -43,8 +43,7 @@ type grantSSHDeps struct { } type resolvedMember struct { - user *entity.User - attachment entity.OrgRoleAttachment + user *entity.User } func defaultGrantSSHDeps() grantSSHDeps { @@ -161,7 +160,7 @@ func runGrantSSH(ctx context.Context, t *terminal.Terminal, s GrantSSHStore, opt return breverrors.WrapAndTrace(err) } - orgMembers, err := getOrgMembers(currentUser, t, s, org.ID) + orgMembers, err := getOrgMembers(ctx, currentUser, t, s, org.ID) if err != nil { return err } @@ -291,16 +290,16 @@ func findUserByIDOrEmail(members []resolvedMember, idOrEmail string) (*entity.Us return nil, fmt.Errorf("no org member found matching %q", idOrEmail) } -func getOrgMembers(currentUser *entity.User, t *terminal.Terminal, s GrantSSHStore, orgID string) ([]resolvedMember, error) { - attachments, err := s.GetOrgRoleAttachments(orgID) +func getOrgMembers(ctx context.Context, currentUser *entity.User, t *terminal.Terminal, s GrantSSHStore, orgID string) ([]resolvedMember, error) { + members, err := s.ListOrganizationMembers(ctx, orgID) if err != nil { return nil, fmt.Errorf("failed to fetch org members: %w", err) } - var otherMembers []entity.OrgRoleAttachment - for _, a := range attachments { - if a.Subject != currentUser.ID { - otherMembers = append(otherMembers, a) + var otherMembers []*nodev1.OrganizationMember + for _, member := range members { + if member.GetUserId() != currentUser.ID { + otherMembers = append(otherMembers, member) } } @@ -309,12 +308,12 @@ func getOrgMembers(currentUser *entity.User, t *terminal.Terminal, s GrantSSHSto } var resolved []resolvedMember for _, m := range otherMembers { - memberUser, err := s.GetUserByID(m.Subject) + memberUser, err := s.GetUserByID(m.GetUserId()) if err != nil { - t.Vprintf(" Warning: could not resolve user %s: %v\n", m.Subject, err) + t.Vprintf(" Warning: could not resolve user %s: %v\n", m.GetUserId(), err) continue } - resolved = append(resolved, resolvedMember{user: memberUser, attachment: m}) + resolved = append(resolved, resolvedMember{user: memberUser}) } if len(resolved) == 0 { diff --git a/pkg/cmd/grantssh/grantssh_test.go b/pkg/cmd/grantssh/grantssh_test.go index f8b22b69..10b3cbab 100644 --- a/pkg/cmd/grantssh/grantssh_test.go +++ b/pkg/cmd/grantssh/grantssh_test.go @@ -62,12 +62,12 @@ func (m *mockRegistrationStore) Exists() (bool, error) { // mockGrantSSHStore satisfies GrantSSHStore. type mockGrantSSHStore struct { - user *entity.User - org *entity.Organization - token string - attachments []entity.OrgRoleAttachment - users map[string]*entity.User - err error + user *entity.User + org *entity.Organization + token string + members []*nodev1.OrganizationMember + users map[string]*entity.User + err error } func (m *mockGrantSSHStore) GetCurrentUser() (*entity.User, error) { @@ -83,8 +83,8 @@ func (m *mockGrantSSHStore) GetActiveOrganizationOrDefault() (*entity.Organizati func (m *mockGrantSSHStore) GetAccessToken() (string, error) { return m.token, nil } -func (m *mockGrantSSHStore) GetOrgRoleAttachments(_ string) ([]entity.OrgRoleAttachment, error) { - return m.attachments, nil +func (m *mockGrantSSHStore) ListOrganizationMembers(_ context.Context, _ string) ([]*nodev1.OrganizationMember, error) { + return m.members, nil } func (m *mockGrantSSHStore) GetUserByID(userID string) (*entity.User, error) { @@ -226,9 +226,9 @@ func Test_runGrantSSH_HappyPath(t *testing.T) { user: &entity.User{ID: "user_1", PublicKey: "ssh-ed25519 testkey"}, org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, token: "tok", - attachments: []entity.OrgRoleAttachment{ - {Subject: "user_1"}, // current user, should be filtered - {Subject: "user_2"}, + members: []*nodev1.OrganizationMember{ + {UserId: "user_1"}, // current user, should be filtered + {UserId: "user_2"}, }, users: map[string]*entity.User{ "user_2": targetUser, @@ -305,9 +305,9 @@ func Test_runGrantSSH_NonInteractiveWithPortID(t *testing.T) { user: &entity.User{ID: "user_1"}, org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, token: "tok", - attachments: []entity.OrgRoleAttachment{ - {Subject: "user_1"}, - {Subject: "user_2"}, + members: []*nodev1.OrganizationMember{ + {UserId: "user_1"}, + {UserId: "user_2"}, }, users: map[string]*entity.User{"user_2": targetUser}, } @@ -368,8 +368,8 @@ func Test_runGrantSSH_RPCFailure(t *testing.T) { user: &entity.User{ID: "user_1"}, org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, token: "tok", - attachments: []entity.OrgRoleAttachment{ - {Subject: "user_2"}, + members: []*nodev1.OrganizationMember{ + {UserId: "user_2"}, }, users: map[string]*entity.User{ "user_2": {ID: "user_2", Name: "Alice", Email: "alice@example.com"}, @@ -406,8 +406,8 @@ func Test_runGrantSSH_NoOtherMembers(t *testing.T) { user: &entity.User{ID: "user_1"}, org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, token: "tok", - attachments: []entity.OrgRoleAttachment{ - {Subject: "user_1"}, // only current user, no others + members: []*nodev1.OrganizationMember{ + {UserId: "user_1"}, // only current user, no others }, users: map[string]*entity.User{}, } diff --git a/pkg/cmd/revokessh/revokessh.go b/pkg/cmd/revokessh/revokessh.go index 61d90e86..f40b3782 100644 --- a/pkg/cmd/revokessh/revokessh.go +++ b/pkg/cmd/revokessh/revokessh.go @@ -28,7 +28,7 @@ type RevokeSSHStore interface { GetOrganizationsByName(name string) ([]entity.Organization, error) ListOrganizations() ([]entity.Organization, error) GetUserByID(userID string) (*entity.User, error) - GetOrgRoleAttachments(orgID string) ([]entity.OrgRoleAttachment, error) + ListOrganizationMembers(ctx context.Context, orgID string) ([]*nodev1.OrganizationMember, error) } // revokeSSHDeps bundles the side-effecting dependencies of runRevokeSSH so they @@ -183,7 +183,7 @@ func runRevokeSSH(ctx context.Context, t *terminal.Terminal, s RevokeSSHStore, o targetPortID = selectedAccess.GetPortId() portLabel = portLabelForAccess(selectedNode, selectedAccess) } else { - resolvedUserID, err := resolveUserID(s, selectedOrg.ID, opts.userIDOrEmail) + resolvedUserID, err := resolveUserID(ctx, s, selectedOrg.ID, opts.userIDOrEmail) if err != nil { return err } @@ -268,7 +268,7 @@ func portLabelForAccess(node *nodev1.ExternalNode, sa *nodev1.SSHAccess) string } // resolveUserID resolves idOrEmail to a Brev user ID using org members when it looks like an email. -func resolveUserID(s RevokeSSHStore, orgID string, idOrEmail string) (string, error) { +func resolveUserID(ctx context.Context, s RevokeSSHStore, orgID string, idOrEmail string) (string, error) { if idOrEmail == "" { return "", fmt.Errorf("user is required") } @@ -281,12 +281,15 @@ func resolveUserID(s RevokeSSHStore, orgID string, idOrEmail string) (string, er return idOrEmail, nil } - attachments, err := s.GetOrgRoleAttachments(orgID) + members, err := s.ListOrganizationMembers(ctx, orgID) if err != nil { return "", fmt.Errorf("failed to list org members: %w", err) } - for _, a := range attachments { - u, err := s.GetUserByID(a.Subject) + for _, member := range members { + if strings.EqualFold(member.GetDefaultEmail(), idOrEmail) { + return member.GetUserId(), nil + } + u, err := s.GetUserByID(member.GetUserId()) if err != nil { continue } diff --git a/pkg/cmd/revokessh/revokessh_test.go b/pkg/cmd/revokessh/revokessh_test.go index d9743251..447c53cb 100644 --- a/pkg/cmd/revokessh/revokessh_test.go +++ b/pkg/cmd/revokessh/revokessh_test.go @@ -60,9 +60,10 @@ func (m *mockRegistrationStore) Exists() (bool, error) { } type mockRevokeSSHStore struct { - token string - org *entity.Organization - users map[string]*entity.User + token string + org *entity.Organization + members []*nodev1.OrganizationMember + users map[string]*entity.User } func (m *mockRevokeSSHStore) GetAccessToken() (string, error) { return m.token, nil } @@ -94,8 +95,8 @@ func (m *mockRevokeSSHStore) GetOrganizationsByName(name string) ([]entity.Organ return nil, nil } -func (m *mockRevokeSSHStore) GetOrgRoleAttachments(_ string) ([]entity.OrgRoleAttachment, error) { - return nil, nil +func (m *mockRevokeSSHStore) ListOrganizationMembers(_ context.Context, _ string) ([]*nodev1.OrganizationMember, error) { + return m.members, nil } // fakeNodeService implements the server side of ExternalNodeService for testing. diff --git a/pkg/entity/entity.go b/pkg/entity/entity.go index ba18ec5f..4a979567 100644 --- a/pkg/entity/entity.go +++ b/pkg/entity/entity.go @@ -577,17 +577,6 @@ func (u User) GetOnboardingData() (*OnboardingData, error) { return x, nil } -type OrgRoleAttachment struct { - Subject string `json:"subject"` - Object string `json:"object"` - Role OrgRoleAttachmentRole `json:"role"` -} - -type OrgRoleAttachmentRole struct { - ID string `json:"id"` - Actions []string `json:"actions"` -} - type ModifyWorkspaceRequest struct { WorkspaceClass string `json:"workspaceClassId"` IsStoppable *bool `json:"isStoppable"` diff --git a/pkg/store/organization.go b/pkg/store/organization.go index f41c760c..f9bc09c9 100644 --- a/pkg/store/organization.go +++ b/pkg/store/organization.go @@ -1,9 +1,15 @@ package store import ( + "context" "fmt" + "net/http" "strings" + nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" @@ -251,28 +257,58 @@ func (s AuthHTTPStore) CreateInviteLink(organizationID string) (string, error) { return result, nil } -func GetDefaultOrNilOrg(orgs []entity.Organization) *entity.Organization { - if len(orgs) > 0 { - return &orgs[0] - } else { - return nil - } +type authHTTPStoreTransport struct { + store *AuthHTTPStore + base http.RoundTripper } -func (s AuthHTTPStore) GetOrgRoleAttachments(orgID string) ([]entity.OrgRoleAttachment, error) { - var result []entity.OrgRoleAttachment - res, err := s.authHTTPClient.restyClient.R(). - SetHeader("Content-Type", "application/json"). - SetResult(&result). - Get(fmt.Sprintf("api/organizations/%s/role_attachments", orgID)) +func (t *authHTTPStoreTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.store.GetAccessToken() if err != nil { return nil, breverrors.WrapAndTrace(err) } - if res.IsError() { - return nil, NewHTTPResponseError(res) + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, breverrors.WrapAndTrace(err) } + return resp, nil +} - return result, nil +func (s *AuthHTTPStore) ListOrganizationMembers(ctx context.Context, orgID string) ([]*nodev1.OrganizationMember, error) { + client := nodev1connect.NewOrganizationServiceClient( + &http.Client{Transport: &authHTTPStoreTransport{store: s, base: http.DefaultTransport}}, + s.authHTTPClient.restyClient.BaseURL, + ) + + var members []*nodev1.OrganizationMember + var pageToken string + for { + resp, err := client.ListOrganizationMembers(ctx, connect.NewRequest(&nodev1.ListOrganizationMembersRequest{ + OrganizationId: orgID, + PageParams: &nodev1.PageParams{ + PageSize: 1000, + PageToken: pageToken, + }, + })) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + members = append(members, resp.Msg.GetItems()...) + pageToken = resp.Msg.GetNextPageToken() + if pageToken == "" { + return members, nil + } + } +} + +func GetDefaultOrNilOrg(orgs []entity.Organization) *entity.Organization { + if len(orgs) > 0 { + return &orgs[0] + } else { + return nil + } } type RedeemCouponCodeRequest struct { diff --git a/pkg/store/organization_test.go b/pkg/store/organization_test.go index ea6be05c..391247bf 100644 --- a/pkg/store/organization_test.go +++ b/pkg/store/organization_test.go @@ -1,9 +1,17 @@ package store import ( + "context" "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" + nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + authpkg "github.com/brevdev/brev-cli/pkg/auth" "github.com/brevdev/brev-cli/pkg/entity" "github.com/jarcoal/httpmock" @@ -12,6 +20,19 @@ import ( "github.com/stretchr/testify/require" ) +type fakeOrganizationService struct { + nodev1connect.UnimplementedOrganizationServiceHandler + listMembersFn func(http.Header, *nodev1.ListOrganizationMembersRequest) (*nodev1.ListOrganizationMembersResponse, error) +} + +func (f *fakeOrganizationService) ListOrganizationMembers(_ context.Context, req *connect.Request[nodev1.ListOrganizationMembersRequest]) (*connect.Response[nodev1.ListOrganizationMembersResponse], error) { + resp, err := f.listMembersFn(req.Header(), req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + func TestGetActiveOrganization(t *testing.T) { fs := MakeMockAuthHTTPStore() org, err := fs.GetActiveOrganizationOrNil() @@ -52,6 +73,39 @@ func TestGetOrganizations(t *testing.T) { } } +func TestListOrganizationMembersUsesDevPlaneRPC(t *testing.T) { + var gotAuth string + var gotOrgID string + svc := &fakeOrganizationService{ + listMembersFn: func(header http.Header, msg *nodev1.ListOrganizationMembersRequest) (*nodev1.ListOrganizationMembersResponse, error) { + gotAuth = header.Get("Authorization") + gotOrgID = msg.GetOrganizationId() + return &nodev1.ListOrganizationMembersResponse{ + Items: []*nodev1.OrganizationMember{ + {UserId: "user_1", DisplayName: "Alice", DefaultEmail: "alice@example.com"}, + {UserId: "user_2", DisplayName: "Bob", DefaultEmail: "bob@example.com"}, + }, + }, nil + }, + } + _, handler := nodev1connect.NewOrganizationServiceHandler(svc) + server := httptest.NewServer(handler) + defer server.Close() + + token := "tok" + fileStore, _, _ := newAuthTokenTestStore(t) + s := fileStore.WithAuthHTTPClient(NewAuthHTTPClient(MockAuth{token: &token}, server.URL)) + + members, err := s.ListOrganizationMembers(context.Background(), "org_123") + + require.NoError(t, err) + require.Len(t, members, 2) + assert.Equal(t, "Bearer tok", gotAuth) + assert.Equal(t, "org_123", gotOrgID) + assert.Equal(t, "user_1", members[0].GetUserId()) + assert.True(t, strings.Contains(members[1].GetDefaultEmail(), "@")) +} + func TestGetActiveOrganization_APIKeyUsesCredentialOrg(t *testing.T) { apiKey := authpkg.BrevAPIKeyPrefix + "test-key" fileStore, _, _ := newAuthTokenTestStore(t) From 476ade4e27d84d168525ae27ba830216c7b8a0dd Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 26 Jun 2026 16:49:30 -0700 Subject: [PATCH 2/2] uptake latest devplane --- Makefile | 2 +- go.mod | 4 ++-- go.sum | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index d45b54c1..77d29eb9 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ endif fast-build: ## go build -o brev $(call print-target) echo ${VERSION} - CGO_ENABLED=1 go build -o brev -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" + $(_BUILD_PREFIX) go build -o brev -ldflags "-X github.com/brevdev/brev-cli/pkg/cmd/version.Version=${VERSION}" .PHONY: local local: ## build with env wrapper (use: make local env=dev0|dev1|dev2|stg arch=linux/amd64, or make local for defaults) diff --git a/go.mod b/go.mod index f5cb6b34..172108c2 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/brevdev/brev-cli go 1.25.0 require ( - buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1 - buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1 + buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260626205643-49b0d20e08f1.1 + buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260626205643-49b0d20e08f1.1 connectrpc.com/connect v1.20.0 github.com/NVIDIA/go-nvml v0.13.0-1 github.com/alessio/shellescape v1.4.1 diff --git a/go.sum b/go.sum index 72efd758..22ba4d7e 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1 h1:p2gDnCmIeMzMuRNP05Jh143Q8iiSq0/oXG8eckzCkSY= -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1/go.mod h1:CwGL+2J9G36DvGlMYW/5f+LTnGAOGJPcAw3S/Zy7lbk= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1 h1:NyJ55L5BmM+AOC77hUrLysVvzU4m9YO+g93YwvZS3Y4= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260626205643-49b0d20e08f1.1 h1:Qj4BTbhIF0KE5YHiJJ+SN2goGYF8dJC1l8cv69YU/Ms= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260626205643-49b0d20e08f1.1/go.mod h1:KW+lsYUmrF994Z/zj/wibrS7zhitXrYLicqR5BbVSp0= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260626205643-49b0d20e08f1.1 h1:+GNKe6qV3aRH+N/FBlH6NfqyKOxMecAtbHndj3NPZc4= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260626205643-49b0d20e08f1.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 h1:6amhprQmCKJ4wgJ6ngkh32d9V+dQcOLUZ/SfHdOnYgo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1/go.mod h1:O+pnSHMru/naTMrm4tmpBoH3wz6PHa+R75HR7Mv8X2g= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=