Parent directory

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}