internal/control/identity.go
298 lines · 9078 bytes
10 symbols in this file
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}