cmd.go
6840 bytes
1package cmd
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strings"
8 "text/template"
9 "unicode"
10
11 "charm.land/ssh"
12 "github.com/charmbracelet/soft-serve/pkg/access"
13 "github.com/charmbracelet/soft-serve/pkg/backend"
14 "github.com/charmbracelet/soft-serve/pkg/config"
15 "github.com/charmbracelet/soft-serve/pkg/proto"
16 "github.com/charmbracelet/soft-serve/pkg/sshutils"
17 "github.com/charmbracelet/soft-serve/pkg/utils"
18 "github.com/spf13/cobra"
19)
20
21var templateFuncs = template.FuncMap{
22 "trim": strings.TrimSpace,
23 "trimRightSpace": trimRightSpace,
24 "trimTrailingWhitespaces": trimRightSpace,
25 "rpad": rpad,
26 "gt": cobra.Gt,
27 "eq": cobra.Eq,
28}
29
30const (
31 // UsageTemplate is the template used for the help output.
32 UsageTemplate = `Usage:{{if .Runnable}}
33 {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
34 {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
35
36Aliases:
37 {{.NameAndAliases}}{{end}}{{if .HasExample}}
38
39Examples:
40{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
41
42Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
43 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
44
45{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
46 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
47
48Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
49 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
50
51Flags:
52{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
53
54Global Flags:
55{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
56
57Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
58 {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
59
60Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
61`
62)
63
64// UsageFunc is a function that can be used as a cobra.Command's
65// UsageFunc to render the help output.
66func UsageFunc(c *cobra.Command) error {
67 ctx := c.Context()
68 cfg := config.FromContext(ctx)
69 hostname := "localhost"
70 port := "23231"
71 url, err := url.Parse(cfg.SSH.PublicURL)
72 if err == nil {
73 hostname = url.Hostname()
74 port = url.Port()
75 }
76
77 sshCmd := "ssh"
78 if port != "" && port != "22" {
79 sshCmd += " -p " + port
80 }
81
82 sshCmd += " " + hostname
83 t := template.New("usage")
84 t.Funcs(templateFuncs)
85 template.Must(t.Parse(c.UsageTemplate()))
86 return t.Execute(c.OutOrStderr(), struct {
87 *cobra.Command
88 SSHCommand string
89 }{
90 Command: c,
91 SSHCommand: sshCmd,
92 })
93}
94
95func trimRightSpace(s string) string {
96 return strings.TrimRightFunc(s, unicode.IsSpace)
97}
98
99// rpad adds padding to the right of a string.
100func rpad(s string, padding int) string {
101 template := fmt.Sprintf("%%-%ds", padding)
102 return fmt.Sprintf(template, s)
103}
104
105// CommandName returns the name of the command from the args.
106func CommandName(args []string) string {
107 if len(args) == 0 {
108 return ""
109 }
110 return args[0]
111}
112
113func checkIfReadable(cmd *cobra.Command, args []string) error {
114 ctx := cmd.Context()
115 if repoAccessLevel(ctx, repoArg(args)) < access.ReadOnlyAccess {
116 return proto.ErrRepoNotFound
117 }
118 return nil
119}
120
121// IsPublicKeyAdmin returns true if the given public key is an admin key from
122// the initial_admin_keys config or environment field.
123func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
124 for _, k := range cfg.AdminKeys() {
125 if sshutils.KeysEqual(pk, k) {
126 return true
127 }
128 }
129 return false
130}
131
132// isServerAdmin reports whether the caller is a server administrator: either
133// their public key is one of the configured admin keys, or their account has
134// the admin flag set.
135//
136// This is the single source of truth for "is this caller a server admin".
137// Every authorization gate defers to it so the definition cannot drift.
138func isServerAdmin(ctx context.Context) bool {
139 cfg := config.FromContext(ctx)
140 pk := sshutils.PublicKeyFromContext(ctx)
141 if IsPublicKeyAdmin(cfg, pk) {
142 return true
143 }
144
145 user := proto.UserFromContext(ctx)
146 return user != nil && user.IsAdmin()
147}
148
149// repoArg returns the repository name from a repo-scoped command's arguments.
150// Repo-scoped commands always take the repository as their first argument.
151func repoArg(args []string) string {
152 if len(args) == 0 {
153 return ""
154 }
155 return utils.SanitizeRepo(args[0])
156}
157
158// repoAccessLevel returns the caller's access level for the named repository.
159// The repository name must already be sanitized, e.g. via repoArg.
160func repoAccessLevel(ctx context.Context, repo string) access.AccessLevel {
161 be := backend.FromContext(ctx)
162 return be.AccessLevelForUser(ctx, repo, proto.UserFromContext(ctx))
163}
164
165// checkIfServerAdmin is the authorization gate for global (non-repo-scoped)
166// commands such as `user` and `settings`. It allows server admins only.
167//
168// Unlike checkIfRepoAdmin, it never consults repository access levels, so it
169// cannot be bypassed by creating a repository whose name matches the command
170// argument. Attach it to the parent command so that every subcommand,
171// including ones added later, is gated by default.
172func checkIfServerAdmin(cmd *cobra.Command, _ []string) error {
173 if !isServerAdmin(cmd.Context()) {
174 return proto.ErrUnauthorized
175 }
176 return nil
177}
178
179// checkIfRepoAdmin is the authorization gate for repo-scoped commands that
180// require admin access to the repository named by the first argument.
181//
182// Only use this on commands whose first argument is a repository name. For
183// global commands, use checkIfServerAdmin instead.
184func checkIfRepoAdmin(cmd *cobra.Command, args []string) error {
185 ctx := cmd.Context()
186 if isServerAdmin(ctx) {
187 return nil
188 }
189
190 if proto.UserFromContext(ctx) == nil {
191 return proto.ErrUnauthorized
192 }
193
194 if repoAccessLevel(ctx, repoArg(args)) < access.AdminAccess {
195 return proto.ErrUnauthorized
196 }
197
198 return nil
199}
200
201// checkIfRepoCollab is the authorization gate for repo-scoped commands that
202// require write access to the repository named by the first argument.
203//
204// Only use this on commands whose first argument is a repository name.
205func checkIfRepoCollab(cmd *cobra.Command, args []string) error {
206 ctx := cmd.Context()
207 if repoAccessLevel(ctx, repoArg(args)) < access.ReadWriteAccess {
208 return proto.ErrUnauthorized
209 }
210 return nil
211}
212
213func checkIfReadableAndCollab(cmd *cobra.Command, args []string) error {
214 if err := checkIfReadable(cmd, args); err != nil {
215 return err
216 }
217 if err := checkIfRepoCollab(cmd, args); err != nil {
218 return err
219 }
220 return nil
221}