Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
25 changes: 12 additions & 13 deletions pkg/cmd/grantssh/grantssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -43,8 +43,7 @@ type grantSSHDeps struct {
}

type resolvedMember struct {
user *entity.User
attachment entity.OrgRoleAttachment
user *entity.User
}

func defaultGrantSSHDeps() grantSSHDeps {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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 {
Expand Down
36 changes: 18 additions & 18 deletions pkg/cmd/grantssh/grantssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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},
}
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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{},
}
Expand Down
15 changes: 9 additions & 6 deletions pkg/cmd/revokessh/revokessh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}
Expand All @@ -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
}
Expand Down
11 changes: 6 additions & 5 deletions pkg/cmd/revokessh/revokessh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 0 additions & 11 deletions pkg/entity/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
66 changes: 51 additions & 15 deletions pkg/store/organization.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading