Skip to content

Commit f41d40a

Browse files
committed
sharing ssh
1 parent fba34a0 commit f41d40a

6 files changed

Lines changed: 295 additions & 3 deletions

File tree

pkg/cmd/cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/brevdev/brev-cli/pkg/cmd/fu"
2121
"github.com/brevdev/brev-cli/pkg/cmd/gpucreate"
2222
"github.com/brevdev/brev-cli/pkg/cmd/gpusearch"
23+
"github.com/brevdev/brev-cli/pkg/cmd/grantssh"
2324
"github.com/brevdev/brev-cli/pkg/cmd/healthcheck"
2425
"github.com/brevdev/brev-cli/pkg/cmd/hello"
2526
"github.com/brevdev/brev-cli/pkg/cmd/importideconfig"
@@ -310,6 +311,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor
310311
cmd.AddCommand(register.NewCmdRegister(t, loginCmdStore))
311312
cmd.AddCommand(deregister.NewCmdDeregister(t, loginCmdStore))
312313
cmd.AddCommand(enablessh.NewCmdEnableSSH(t, loginCmdStore))
314+
cmd.AddCommand(grantssh.NewCmdGrantSSH(t, loginCmdStore))
313315
cmd.AddCommand(runtasks.NewCmdRunTasks(t, noLoginCmdStore))
314316
cmd.AddCommand(proxy.NewCmdProxy(t, noLoginCmdStore))
315317
cmd.AddCommand(healthcheck.NewCmdHealthcheck(t, noLoginCmdStore))

pkg/cmd/enablessh/enablessh.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func EnableSSH(
121121
t.Vprint("")
122122

123123
if brevUser.PublicKey != "" {
124-
if err := installAuthorizedKey(u, brevUser.PublicKey); err != nil {
124+
if err := InstallAuthorizedKey(u, brevUser.PublicKey); err != nil {
125125
t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to install SSH public key: %v", err)))
126126
} else {
127127
t.Vprint(" Brev public key added to authorized_keys.")
@@ -142,9 +142,9 @@ func EnableSSH(
142142
return nil
143143
}
144144

145-
// installAuthorizedKey appends the given public key to the user's
145+
// InstallAuthorizedKey appends the given public key to the user's
146146
// ~/.ssh/authorized_keys if it isn't already present.
147-
func installAuthorizedKey(u *user.User, pubKey string) error {
147+
func InstallAuthorizedKey(u *user.User, pubKey string) error {
148148
pubKey = strings.TrimSpace(pubKey)
149149
if pubKey == "" {
150150
return nil

pkg/cmd/grantssh/grantssh.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Package grantssh provides the brev grant-ssh command for granting SSH access
2+
// to a registered device for another org member.
3+
package grantssh
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"os"
9+
"os/user"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
14+
nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect"
15+
nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1"
16+
"connectrpc.com/connect"
17+
18+
"github.com/brevdev/brev-cli/pkg/cmd/enablessh"
19+
"github.com/brevdev/brev-cli/pkg/cmd/register"
20+
"github.com/brevdev/brev-cli/pkg/config"
21+
"github.com/brevdev/brev-cli/pkg/entity"
22+
breverrors "github.com/brevdev/brev-cli/pkg/errors"
23+
"github.com/brevdev/brev-cli/pkg/terminal"
24+
25+
"github.com/spf13/cobra"
26+
)
27+
28+
// GrantSSHStore defines the store methods needed by the grant-ssh command.
29+
type GrantSSHStore interface {
30+
GetCurrentUser() (*entity.User, error)
31+
GetActiveOrganizationOrDefault() (*entity.Organization, error)
32+
GetBrevHomePath() (string, error)
33+
GetAccessToken() (string, error)
34+
GetOrgRoleAttachments(orgID string) ([]entity.OrgRoleAttachment, error)
35+
GetUserByID(userID string) (*entity.User, error)
36+
}
37+
38+
// grantSSHDeps bundles the side-effecting dependencies of runGrantSSH so they
39+
// can be replaced in tests.
40+
type grantSSHDeps struct {
41+
goos string
42+
promptSelect func(label string, items []string) string
43+
newNodeClient func(provider register.TokenProvider, baseURL string) nodev1connect.ExternalNodeServiceClient
44+
registrationStore register.RegistrationStore
45+
}
46+
47+
type resolvedMember struct {
48+
user *entity.User
49+
attachment entity.OrgRoleAttachment
50+
}
51+
52+
func prodGrantSSHDeps(brevHome string) grantSSHDeps {
53+
return grantSSHDeps{
54+
goos: runtime.GOOS,
55+
promptSelect: func(label string, items []string) string {
56+
return terminal.PromptSelectInput(terminal.PromptSelectContent{
57+
Label: label,
58+
Items: items,
59+
})
60+
},
61+
newNodeClient: register.NewNodeServiceClient,
62+
registrationStore: register.NewFileRegistrationStore(brevHome),
63+
}
64+
}
65+
66+
func NewCmdGrantSSH(t *terminal.Terminal, store GrantSSHStore) *cobra.Command {
67+
cmd := &cobra.Command{
68+
Annotations: map[string]string{"configuration": ""},
69+
Use: "grant-ssh",
70+
DisableFlagsInUseLine: true,
71+
Short: "Grant SSH access to this device for another org member",
72+
Long: "Grant SSH access to this registered device for another member of your organization.",
73+
Example: " brev grant-ssh",
74+
RunE: func(cmd *cobra.Command, args []string) error {
75+
brevHome, err := store.GetBrevHomePath()
76+
if err != nil {
77+
return breverrors.WrapAndTrace(err)
78+
}
79+
return runGrantSSH(cmd.Context(), t, store, prodGrantSSHDeps(brevHome))
80+
},
81+
}
82+
83+
return cmd
84+
}
85+
86+
func runGrantSSH(ctx context.Context, t *terminal.Terminal, s GrantSSHStore, deps grantSSHDeps) error { //nolint:funlen // grant-ssh flow
87+
if deps.goos != "linux" {
88+
return fmt.Errorf("brev grant-ssh is only supported on Linux")
89+
}
90+
91+
reg, err := getRegistration(deps)
92+
if err != nil {
93+
return breverrors.WrapAndTrace(err)
94+
}
95+
96+
currentUser, err := s.GetCurrentUser()
97+
if err != nil {
98+
return breverrors.WrapAndTrace(err)
99+
}
100+
101+
if err := checkSSHEnabled(currentUser.PublicKey); err != nil {
102+
return err
103+
}
104+
105+
u, err := user.Current()
106+
if err != nil {
107+
return fmt.Errorf("failed to determine current Linux user: %w", err)
108+
}
109+
linuxUser := u.Username
110+
111+
org, err := s.GetActiveOrganizationOrDefault()
112+
if err != nil {
113+
return breverrors.WrapAndTrace(err)
114+
}
115+
if org == nil {
116+
return fmt.Errorf("no organization found; please create or join an organization first")
117+
}
118+
119+
orgMembers, err := getOrgMembers(currentUser, t, s, org.ID)
120+
// Resolve user details for each member.
121+
if err != nil {
122+
return breverrors.WrapAndTrace(err)
123+
}
124+
125+
// Build selection list.
126+
items := make([]string, len(orgMembers))
127+
for i, r := range orgMembers {
128+
items[i] = fmt.Sprintf("%s (%s)", r.user.Name, r.user.Email)
129+
}
130+
131+
selected := deps.promptSelect("Select a user to grant SSH access:", items)
132+
133+
// Find the selected user.
134+
var selectedIdx int
135+
for i, item := range items {
136+
if item == selected {
137+
selectedIdx = i
138+
break
139+
}
140+
}
141+
selectedUser := orgMembers[selectedIdx].user
142+
143+
t.Vprint("")
144+
t.Vprint(t.Green("Granting SSH access"))
145+
t.Vprint("")
146+
t.Vprintf(" Node: %s (%s)\n", reg.DisplayName, reg.ExternalNodeID)
147+
t.Vprintf(" Brev user: %s (%s)\n", selectedUser.Name, selectedUser.ID)
148+
t.Vprintf(" Linux user: %s\n", linuxUser)
149+
t.Vprint("")
150+
151+
if selectedUser.PublicKey != "" {
152+
if err := enablessh.InstallAuthorizedKey(u, selectedUser.PublicKey); err != nil {
153+
t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to install SSH public key: %v", err)))
154+
} else {
155+
t.Vprint(" Brev public key added to authorized_keys.")
156+
}
157+
}
158+
159+
client := deps.newNodeClient(s, config.GlobalConfig.GetBrevPublicAPIURL())
160+
if _, err := client.GrantNodeSSHAccess(ctx, connect.NewRequest(&nodev1.GrantNodeSSHAccessRequest{
161+
ExternalNodeId: reg.ExternalNodeID,
162+
UserId: selectedUser.ID,
163+
LinuxUser: linuxUser,
164+
OrganizationId: reg.OrgID,
165+
})); err != nil {
166+
return fmt.Errorf("failed to grant SSH access: %w", err)
167+
}
168+
169+
t.Vprint(t.Green(fmt.Sprintf("SSH access granted for %s. They can now SSH to this device via: brev shell %s", selectedUser.Name, reg.DisplayName)))
170+
return nil
171+
}
172+
173+
func getOrgMembers(currentUser *entity.User, t *terminal.Terminal, s GrantSSHStore, orgId string) ([]resolvedMember, error) {
174+
attachments, err := s.GetOrgRoleAttachments(orgId)
175+
if err != nil {
176+
return nil, fmt.Errorf("failed to fetch org members: %w", err)
177+
}
178+
179+
// Filter out current user.
180+
var otherMembers []entity.OrgRoleAttachment
181+
for _, a := range attachments {
182+
if a.Subject != currentUser.ID {
183+
otherMembers = append(otherMembers, a)
184+
}
185+
}
186+
187+
if len(otherMembers) == 0 {
188+
return nil, fmt.Errorf("no other members found in current organization")
189+
}
190+
var resolved []resolvedMember
191+
for _, m := range otherMembers {
192+
memberUser, err := s.GetUserByID(m.Subject)
193+
if err != nil {
194+
t.Vprintf(" Warning: could not resolve user %s: %v\n", m.Subject, err)
195+
continue
196+
}
197+
resolved = append(resolved, resolvedMember{user: memberUser, attachment: m})
198+
}
199+
200+
if len(resolved) == 0 {
201+
return nil, fmt.Errorf("could not resolve any org member details")
202+
}
203+
204+
return resolved, nil
205+
}
206+
207+
func getRegistration(deps grantSSHDeps) (*register.DeviceRegistration, error) {
208+
registered, err := deps.registrationStore.Exists()
209+
if err != nil {
210+
return nil, breverrors.WrapAndTrace(err)
211+
}
212+
if !registered {
213+
return nil, fmt.Errorf("no registration found; this machine does not appear to be registered\nRun 'brev register' to register your device first")
214+
}
215+
216+
reg, err := deps.registrationStore.Load()
217+
if err != nil {
218+
return nil, fmt.Errorf("failed to read registration file: %w", err)
219+
}
220+
return reg, nil
221+
}
222+
223+
// checkSSHEnabled verifies that SSH has been enabled on this device by checking
224+
// if the current user's public key is present in authorized_keys.
225+
func checkSSHEnabled(currentUserPubKey string) error {
226+
currentUserPubKey = strings.TrimSpace(currentUserPubKey)
227+
if currentUserPubKey == "" {
228+
return fmt.Errorf("SSH has not been enabled on this device. Run 'brev enable-ssh' first.")
229+
}
230+
231+
u, err := user.Current()
232+
if err != nil {
233+
return fmt.Errorf("failed to determine current Linux user: %w", err)
234+
}
235+
236+
authKeysPath := filepath.Join(u.HomeDir, ".ssh", "authorized_keys")
237+
existing, err := os.ReadFile(authKeysPath) // #nosec G304
238+
if err != nil {
239+
return fmt.Errorf("SSH has not been enabled on this device. Run 'brev enable-ssh' first.")
240+
}
241+
242+
if !strings.Contains(string(existing), currentUserPubKey) {
243+
return fmt.Errorf("SSH has not been enabled on this device. Run 'brev enable-ssh' first.")
244+
}
245+
246+
return nil
247+
}

pkg/entity/entity.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,17 @@ func (u User) GetOnboardingData() (*OnboardingData, error) {
570570
return x, nil
571571
}
572572

573+
type OrgRoleAttachment struct {
574+
Subject string `json:"subject"`
575+
Object string `json:"object"`
576+
Role OrgRoleAttachmentRole `json:"role"`
577+
}
578+
579+
type OrgRoleAttachmentRole struct {
580+
ID string `json:"id"`
581+
Actions []string `json:"actions"`
582+
}
583+
573584
type ModifyWorkspaceRequest struct {
574585
WorkspaceClass string `json:"workspaceClassId"`
575586
IsStoppable *bool `json:"isStoppable"`

pkg/store/organization.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ func GetDefaultOrNilOrg(orgs []entity.Organization) *entity.Organization {
214214
}
215215
}
216216

217+
func (s AuthHTTPStore) GetOrgRoleAttachments(orgID string) ([]entity.OrgRoleAttachment, error) {
218+
var result []entity.OrgRoleAttachment
219+
res, err := s.authHTTPClient.restyClient.R().
220+
SetHeader("Content-Type", "application/json").
221+
SetResult(&result).
222+
Get(fmt.Sprintf("api/organizations/%s/role_attachments", orgID))
223+
if err != nil {
224+
return nil, breverrors.WrapAndTrace(err)
225+
}
226+
if res.IsError() {
227+
return nil, NewHTTPResponseError(res)
228+
}
229+
230+
return result, nil
231+
}
232+
217233
type RedeemCouponCodeRequest struct {
218234
Code string `json:"Code"`
219235
}

pkg/store/user.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,22 @@ var usersIDPathPattern = fmt.Sprintf("%s/%s", usersPath, "%s")
129129

130130
// usersIDPath = fmt.Sprintf(usersIDPathPattern, fmt.Sprintf("{%s}", userIDParamStr))
131131

132+
func (s AuthHTTPStore) GetUserByID(userID string) (*entity.User, error) {
133+
var result entity.User
134+
res, err := s.authHTTPClient.restyClient.R().
135+
SetHeader("Content-Type", "application/json").
136+
SetResult(&result).
137+
Get(fmt.Sprintf(usersIDPathPattern, userID))
138+
if err != nil {
139+
return nil, breverrors.WrapAndTrace(err)
140+
}
141+
if res.IsError() {
142+
return nil, NewHTTPResponseError(res)
143+
}
144+
145+
return &result, nil
146+
}
147+
132148
func (s AuthHTTPStore) GetUsers(queryParams map[string]string) ([]entity.User, error) {
133149
var result []entity.User
134150
res, err := s.authHTTPClient.restyClient.R().

0 commit comments

Comments
 (0)