internal/control/identity.go

e2a32d5f8d59e4213571c602bd9009b6c8fa86ed
gitbay/internal/control/identity.go history · blame · raw

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