internal/control/identity.go

v1.36.0
gitbay/internal/control/identity.go history · blame · raw

214 lines · 6205 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strings"
  8	"unicode"
  9
 10	"golang.org/x/crypto/ssh"
 11
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16func init() {
 17	register(Command{
 18		Path:     []string{"whoami"},
 19		Summary:  "show the authenticated account",
 20		Usage:    "whoami",
 21		Examples: []string{"whoami"},
 22		ReadOnly: true,
 23		Run:      runWhoami,
 24	})
 25	register(Command{
 26		Path:     []string{"keys", "list"},
 27		Summary:  "list registered SSH keys",
 28		Usage:    "keys list",
 29		Examples: []string{"keys list"},
 30		ReadOnly: true,
 31		Run:      runKeysList,
 32	})
 33	register(Command{
 34		Path:    []string{"keys", "add"},
 35		Summary: "register an SSH public key (authorized_keys format)",
 36		Usage:   "keys add [--scope full|git|runner] [--label <text>] < key.pub",
 37		Flags: []Flag{
 38			{"--scope", "full|git|runner", "what the key may do", "full"},
 39			{"--label", "<text>", "a name for the key", ""},
 40		},
 41		Examples:   []string{"keys add --label laptop < key.pub"},
 42		ReadsStdin: true,
 43		Run:        runKeysAdd,
 44	})
 45	register(Command{
 46		Path:     []string{"keys", "label"},
 47		Summary:  "name a key; an empty label clears it",
 48		Usage:    "keys label <fingerprint> [<text>]",
 49		Examples: []string{`keys label SHA256:abcd1234 "work laptop"`},
 50		Run:      runKeysLabel,
 51	})
 52	register(Command{
 53		Path:     []string{"keys", "remove"},
 54		Summary:  "remove an SSH key by fingerprint",
 55		Usage:    "keys remove <fingerprint>",
 56		Examples: []string{"keys remove SHA256:abcd1234"},
 57		Run:      runKeysRemove,
 58	})
 59}
 60
 61func runWhoami(c *Ctx, args []string) int {
 62	if len(args) != 0 {
 63		return c.usage()
 64	}
 65	type out struct {
 66		Username string `json:"username"`
 67		Admin    bool   `json:"admin"`
 68		KeyScope string `json:"key_scope"`
 69	}
 70	d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope}
 71	return c.emit(d, func(w io.Writer) {
 72		fmt.Fprintln(w, d.Username)
 73	})
 74}
 75
 76func runKeysList(c *Ctx, args []string) int {
 77	if len(args) != 0 {
 78		return c.usage()
 79	}
 80	keys, err := c.Store.ListSSHKeys(c.User.ID)
 81	if err != nil {
 82		return c.fail(protocol.ExitFailure, "listing keys: %v", err)
 83	}
 84	type out struct {
 85		Fingerprint string `json:"fingerprint"`
 86		Algo        string `json:"algo"`
 87		Scope       string `json:"scope"`
 88		Label       string `json:"label"`
 89	}
 90	var ds []out
 91	for _, k := range keys {
 92		ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label})
 93	}
 94	return c.emit(ds, func(w io.Writer) {
 95		tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL")
 96		for _, d := range ds {
 97			tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label))
 98		}
 99		tb.flush()
100	})
101}
102
103// maxKeyLabel bounds a key's name. Labels are display text, one line.
104const maxKeyLabel = 64
105
106// keyLabel normalises a label: surrounding space trimmed, control
107// characters refused, length capped. An empty result is a valid "no
108// label".
109func keyLabel(s string) (string, error) {
110	s = strings.TrimSpace(s)
111	if len(s) > maxKeyLabel {
112		return "", fmt.Errorf("label is longer than %d bytes", maxKeyLabel)
113	}
114	for _, r := range s {
115		if unicode.IsControl(r) {
116			return "", errors.New("label must be a single line of printable text")
117		}
118	}
119	return s, nil
120}
121
122func runKeysAdd(c *Ctx, args []string) int {
123	f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label"}, MaxPos: 0, Usage: "keys add [--scope full|git|runner] [--label <text>] < key.pub"})
124	if err != nil {
125		return c.fail(protocol.ExitUsage, "%v", err)
126	}
127	scope := "full"
128	if f.Has("--scope") {
129		scope = f.Value("--scope")
130	}
131	if scope != "full" && scope != "git" && scope != "runner" {
132		// deploy:* scopes are granted via repo settings, not self-service.
133		return c.fail(protocol.ExitUsage, "scope must be full, git or runner")
134	}
135	raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
136	if err != nil {
137		return c.fail(protocol.ExitFailure, "reading key: %v", err)
138	}
139	pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
140	if err != nil {
141		return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
142	}
143	// The key's own comment is the label unless --label says otherwise.
144	label := comment
145	if f.Has("--label") {
146		label = f.Value("--label")
147	}
148	if label, err = keyLabel(label); err != nil {
149		return c.fail(protocol.ExitUsage, "%v", err)
150	}
151	fp := ssh.FingerprintSHA256(pub)
152	if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label); err != nil {
153		if errors.Is(err, store.ErrDuplicateKey) {
154			return c.failErr(err)
155		}
156		return c.fail(protocol.ExitFailure, "adding key: %v", err)
157	}
158	type out struct {
159		Fingerprint string `json:"fingerprint"`
160		Scope       string `json:"scope"`
161		Label       string `json:"label"`
162	}
163	d := out{fp, scope, label}
164	return c.emit(d, func(w io.Writer) {
165		if d.Label != "" {
166			fmt.Fprintf(w, "added %s (%s) %s\n", d.Fingerprint, d.Scope, d.Label)
167			return
168		}
169		fmt.Fprintf(w, "added %s (%s)\n", d.Fingerprint, d.Scope)
170	})
171}
172
173func runKeysLabel(c *Ctx, args []string) int {
174	if len(args) < 1 || len(args) > 2 {
175		return c.usage()
176	}
177	label := ""
178	if len(args) == 2 {
179		label = args[1]
180	}
181	label, err := keyLabel(label)
182	if err != nil {
183		return c.fail(protocol.ExitUsage, "%v", err)
184	}
185	if err := c.Store.SetSSHKeyLabel(c.User.ID, args[0], label); err != nil {
186		if errors.Is(err, store.ErrNotFound) {
187			return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
188		}
189		return c.fail(protocol.ExitFailure, "labelling key: %v", err)
190	}
191	d := map[string]string{"fingerprint": args[0], "label": label}
192	return c.emit(d, func(w io.Writer) {
193		if label == "" {
194			fmt.Fprintf(w, "cleared label on %s\n", args[0])
195			return
196		}
197		fmt.Fprintf(w, "%s is now %q\n", args[0], label)
198	})
199}
200
201func runKeysRemove(c *Ctx, args []string) int {
202	if len(args) != 1 {
203		return c.usage()
204	}
205	if err := c.Store.RemoveSSHKey(c.User.ID, args[0]); err != nil {
206		if errors.Is(err, store.ErrNotFound) {
207			return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
208		}
209		return c.fail(protocol.ExitFailure, "removing key: %v", err)
210	}
211	return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
212		fmt.Fprintf(w, "removed %s\n", args[0])
213	})
214}