internal/control/identity.go
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}