internal/control/identity.go

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

298 lines · 9078 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strings"
  8	"time"
  9	"unicode"
 10
 11	"golang.org/x/crypto/ssh"
 12
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17func init() {
 18	register(Command{
 19		Path:     []string{"whoami"},
 20		Summary:  "show the authenticated account",
 21		Usage:    "whoami",
 22		Examples: []string{"whoami"},
 23		ReadOnly: true,
 24		Run:      runWhoami,
 25	})
 26	register(Command{
 27		Path:     []string{"keys", "list"},
 28		Summary:  "list registered SSH keys",
 29		Usage:    "keys list",
 30		Examples: []string{"keys list"},
 31		ReadOnly: true,
 32		Run:      runKeysList,
 33	})
 34	register(Command{
 35		Path:    []string{"keys", "add"},
 36		Summary: "register an SSH public key (authorized_keys format)",
 37		Usage:   "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub",
 38		Flags: []Flag{
 39			{"--scope", "full|git|runner", "what the key may do", "full"},
 40			{"--label", "<text>", "a name for the key", ""},
 41			{"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"},
 42		},
 43		Examples:        []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
 44		ReadsStdin:      true,
 45		MintsCredential: true, NeedsRecentSignIn: true,
 46		Run: runKeysAdd,
 47	})
 48	register(Command{
 49		Path:     []string{"keys", "label"},
 50		Summary:  "name a key; an empty label clears it",
 51		Usage:    "keys label <fingerprint> [<text>]",
 52		Examples: []string{`keys label SHA256:abcd1234 "work laptop"`},
 53		Run:      runKeysLabel,
 54	})
 55	register(Command{
 56		Path:     []string{"keys", "remove"},
 57		Summary:  "remove an SSH key by fingerprint",
 58		Usage:    "keys remove <fingerprint>",
 59		Examples: []string{"keys remove SHA256:abcd1234"},
 60		Run:      runKeysRemove,
 61	})
 62}
 63
 64func runWhoami(c *Ctx, args []string) int {
 65	if len(args) != 0 {
 66		return c.usage()
 67	}
 68	type out struct {
 69		Username string `json:"username"`
 70		Admin    bool   `json:"admin"`
 71		KeyScope string `json:"key_scope"`
 72	}
 73	d := out{Username: c.User.Username, Admin: c.User.IsAdmin, KeyScope: c.Scope}
 74	return c.emitView(d, func(w io.Writer) {
 75		fmt.Fprintln(w, d.Username)
 76	}, func() screen {
 77		s := screen{fields: []field{{"User", []cell{cText(d.Username)}}}}
 78		if d.Admin {
 79			s.fields = append(s.fields, field{"Role", []cell{cState("admin")}})
 80		}
 81		if c.Cfg.Server.SiteURL != "" {
 82			s.fields = append(s.fields, field{"Instance", []cell{cText(c.Cfg.Server.SiteURL)}})
 83		}
 84		// The key's label, or the start of its fingerprint: keys list
 85		// has the whole of it.
 86		via := c.Source
 87		if k, err := c.Store.SSHKeyByFingerprint(c.Source); err == nil && k.Label != "" {
 88			via = k.Label
 89		} else if len(via) > 20 {
 90			via = via[:19] + "…"
 91		}
 92		if via != "" {
 93			s.fields = append(s.fields, field{"Key", []cell{cText(via)}})
 94		}
 95		if d.KeyScope != "" {
 96			s.fields = append(s.fields, field{"Scope", []cell{cState(d.KeyScope)}})
 97		}
 98		s.actions = []action{
 99			{"Account", []string{"keys", "list"}},
100			{"Account", []string{"token", "list"}},
101			{"Account", []string{"email", "list"}},
102		}
103		return s
104	})
105}
106
107func runKeysList(c *Ctx, args []string) int {
108	if len(args) != 0 {
109		return c.usage()
110	}
111	keys, err := c.Store.ListSSHKeys(c.User.ID)
112	if err != nil {
113		return c.fail(protocol.ExitFailure, "listing keys: %v", err)
114	}
115	type out struct {
116		Fingerprint string     `json:"fingerprint"`
117		Algo        string     `json:"algo"`
118		Scope       string     `json:"scope"`
119		Label       string     `json:"label"`
120		CreatedBy   string     `json:"created_by,omitempty"`
121		LastUsedAt  string     `json:"last_used_at,omitempty"`
122		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
123	}
124	var ds []out
125	for _, k := range keys {
126		ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt})
127	}
128	now := time.Now()
129	return c.emitView(ds, func(w io.Writer) {
130		tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
131		for _, d := range ds {
132			tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
133				cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now)))
134		}
135		tb.flush()
136	}, func() screen {
137		rows := make([]row, len(ds))
138		for i, d := range ds {
139			lead, used := cGlyph(""), "used "+c.usedText(d.LastUsedAt)
140			if d.Fingerprint == c.Source {
141				lead, used = cYou(), "this session"
142			}
143			rows[i] = rowOf(cFlexRef(d.Fingerprint), lead, cState(d.Scope), cText(d.Label), cMeta(d.Algo, used, c.expiresText(d.ExpiresAt, now)))
144		}
145		return listScreen("SSH keys", rows,
146			action{"Keys", []string{"keys", "label", "<fingerprint>", "<text>"}},
147			action{"Keys", []string{"keys", "remove", "<fingerprint>"}},
148		)
149	})
150}
151
152// maxKeyLabel bounds a key's name. Labels are display text, one line.
153const maxKeyLabel = 64
154
155// keyLabel normalises a label: surrounding space trimmed, control
156// characters refused, length capped. An empty result is a valid "no
157// label".
158func keyLabel(s string) (string, error) {
159	s = strings.TrimSpace(s)
160	if len(s) > maxKeyLabel {
161		return "", fmt.Errorf("label is longer than %d bytes", maxKeyLabel)
162	}
163	for _, r := range s {
164		if unicode.IsControl(r) {
165			return "", errors.New("label must be a single line of printable text")
166		}
167	}
168	return s, nil
169}
170
171// usedText is a key's last use as a USED cell shows it.
172func (c *Ctx) usedText(ts string) string {
173	switch {
174	case ts == "":
175		return "never"
176	case c.Term.Cols == 0:
177		return stamp(ts)
178	}
179	return relAge(ts, termNow())
180}
181
182// expiresText is a credential's expiry as an EXPIRES cell shows it:
183// RFC3339 in plain output, relative at a terminal.
184func (c *Ctx) expiresText(t *time.Time, now time.Time) string {
185	if t == nil {
186		return "never"
187	}
188	s := stamp(t.UTC().Format(time.RFC3339Nano))
189	if c.Term.Cols > 0 {
190		s = relAge(s, now)
191	}
192	if !t.After(now) {
193		return "expired " + s
194	}
195	return s
196}
197
198func runKeysAdd(c *Ctx, args []string) int {
199	f, err := c.parseArgs(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
200	if err != nil {
201		return c.fail(protocol.ExitUsage, "%v", err)
202	}
203	scope := "full"
204	if f.Has("--scope") {
205		scope = f.Value("--scope")
206	}
207	if scope != "full" && scope != "git" && scope != "runner" {
208		// deploy:* scopes are granted via repo settings, not self-service.
209		return c.fail(protocol.ExitUsage, "scope must be full, git or runner")
210	}
211	expires, code := c.ttlFlag(f)
212	if code >= 0 {
213		return code
214	}
215	raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
216	if err != nil {
217		return c.fail(protocol.ExitFailure, "reading key: %v", err)
218	}
219	pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
220	if err != nil {
221		return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
222	}
223	// The key's own comment is the label unless --label says otherwise.
224	label := comment
225	if f.Has("--label") {
226		label = f.Value("--label")
227	}
228	if label, err = keyLabel(label); err != nil {
229		return c.fail(protocol.ExitUsage, "%v", err)
230	}
231	fp := ssh.FingerprintSHA256(pub)
232	if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
233		if errors.Is(err, store.ErrDuplicateKey) {
234			return c.failErr(err)
235		}
236		return c.fail(protocol.ExitFailure, "adding key: %v", err)
237	}
238	type out struct {
239		Fingerprint string     `json:"fingerprint"`
240		Scope       string     `json:"scope"`
241		Label       string     `json:"label"`
242		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
243	}
244	d := out{fp, scope, label, expires}
245	return c.emit(d, func(w io.Writer) {
246		line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope)
247		if d.Label != "" {
248			line += " " + d.Label
249		}
250		if d.ExpiresAt != nil {
251			line += ", expires " + c.expiresText(d.ExpiresAt, time.Now())
252		}
253		fmt.Fprintln(w, line)
254	})
255}
256
257func runKeysLabel(c *Ctx, args []string) int {
258	if len(args) < 1 || len(args) > 2 {
259		return c.usage()
260	}
261	label := ""
262	if len(args) == 2 {
263		label = args[1]
264	}
265	label, err := keyLabel(label)
266	if err != nil {
267		return c.fail(protocol.ExitUsage, "%v", err)
268	}
269	if err := c.Store.SetSSHKeyLabel(c.User.ID, args[0], label); err != nil {
270		if errors.Is(err, store.ErrNotFound) {
271			return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
272		}
273		return c.fail(protocol.ExitFailure, "labelling key: %v", err)
274	}
275	d := map[string]string{"fingerprint": args[0], "label": label}
276	return c.emit(d, func(w io.Writer) {
277		if label == "" {
278			fmt.Fprintf(w, "cleared label on %s\n", args[0])
279			return
280		}
281		fmt.Fprintf(w, "%s is now %q\n", args[0], label)
282	})
283}
284
285func runKeysRemove(c *Ctx, args []string) int {
286	if len(args) != 1 {
287		return c.usage()
288	}
289	if err := c.Store.RemoveSSHKey(c.User.ID, args[0]); err != nil {
290		if errors.Is(err, store.ErrNotFound) {
291			return c.fail(protocol.ExitNotFound, "no key with fingerprint %s on your account", args[0])
292		}
293		return c.fail(protocol.ExitFailure, "removing key: %v", err)
294	}
295	return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
296		fmt.Fprintf(w, "removed %s\n", args[0])
297	})
298}