1package storage
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10
11 "tug/internal/platform/sshkey"
12)
13
14var (
15 ErrSSHKeyTaken = sshkey.ErrTaken
16 ErrSSHKeyOwned = sshkey.ErrOwned
17 ErrSSHKeyLimit = sshkey.ErrLimit
18 ErrSSHKeyNotFound = errors.New("storage: the account holds no such SSH key")
19 ErrSSHKeyDisabled = errors.New("storage: an SSH key cannot be added to a disabled account")
20 ErrSSHKeyPending = errors.New("storage: an SSH key cannot be added to an account waiting for its confirmation")
21)
22
23type SSHKey struct {
24 ID string
25 UserID string
26 Fingerprint string
27 Type string
28 Data []byte
29 Label string
30 AddedAt time.Time
31}
32
33func (k SSHKey) Authorized() string {
34 return sshkey.Authorized(k.Type, k.Data)
35}
36
37type AddSSHKeyInput struct {
38 UserID string
39 Key sshkey.Key
40 Label string
41 SessionID string
42}
43
44const sshKeyColumns = `id, user_id, fingerprint, key_type, key_data, label, added_at`
45
46func CheckSSHKeyLabel(value string) error {
47 if err := validateDisplayName(value); err != nil {
48 return fmt.Errorf("%w: %w", sshkey.ErrLabel, err)
49 }
50 return nil
51}
52
53func (s *Store) AddSSHKeyAudited(ctx context.Context, input AddSSHKeyInput, actor string) (SSHKey, error) {
54 const operation = "add ssh key"
55 if err := requireValue("user id", input.UserID); err != nil {
56 return SSHKey{}, invalid(operation, err)
57 }
58 if err := sshkey.Check(input.Key.Type, input.Key.Data); err != nil {
59 return SSHKey{}, invalid(operation, err)
60 }
61 key := sshkey.Key{Type: input.Key.Type, Data: input.Key.Data, Fingerprint: sshkey.Fingerprint(input.Key.Data)}
62 label := strings.TrimSpace(input.Label)
63 if err := CheckSSHKeyLabel(label); err != nil {
64 return SSHKey{}, invalid(operation, err)
65 }
66 if label == "" && CheckSSHKeyLabel(input.Key.Comment) == nil {
67 label = input.Key.Comment
68 }
69 id, err := s.newID()
70 if err != nil {
71 return SSHKey{}, fmt.Errorf("%s: %w", operation, err)
72 }
73 var added SSHKey
74 err = s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
75 username, err := sshKeyHolder(ctx, tx, operation, input.UserID)
76 if err != nil {
77 return err
78 }
79 if input.SessionID != "" {
80 if err := requireLiveSession(ctx, tx, operation, input.SessionID, input.UserID, at); err != nil {
81 return err
82 }
83 }
84 var owner string
85 err = tx.QueryRowContext(ctx, `SELECT user_id FROM ssh_keys WHERE fingerprint = ?`, key.Fingerprint).Scan(&owner)
86 switch {
87 case err == nil && owner == input.UserID:
88 return fmt.Errorf("%s: %w", operation, ErrSSHKeyOwned)
89 case err == nil:
90 return fmt.Errorf("%s: %w", operation, ErrSSHKeyTaken)
91 case !errors.Is(err, sql.ErrNoRows):
92 return classify(operation, err)
93 }
94 var held int
95 if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM ssh_keys WHERE user_id = ?`, input.UserID).Scan(&held); err != nil {
96 return classify(operation, err)
97 }
98 if held >= sshkey.MaxPerAccount {
99 return fmt.Errorf("%s: %w", operation, ErrSSHKeyLimit)
100 }
101 added = SSHKey{ID: id, UserID: input.UserID, Fingerprint: key.Fingerprint, Type: key.Type, Data: key.Data, Label: label, AddedAt: at}
102 if _, err := tx.ExecContext(ctx, `INSERT INTO ssh_keys (`+sshKeyColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?)`,
103 added.ID, added.UserID, added.Fingerprint, added.Type, added.Data, added.Label, timeToInt(at)); err != nil {
104 return classify(operation, err)
105 }
106 return writeAudit(ctx, tx, operation, actor, "sshkey.add", input.UserID, at, sshKeyFields(username, added))
107 })
108 if err != nil {
109 return SSHKey{}, err
110 }
111 return added, nil
112}
113
114func (s *Store) RemoveSSHKeyAudited(ctx context.Context, userID, keyID, sessionID, actor string) (SSHKey, error) {
115 const operation = "remove ssh key"
116 var removed SSHKey
117 err := s.audited(ctx, operation, actor, func(tx *sql.Tx, at time.Time) error {
118 username, _, err := userNameAndUID(ctx, tx, operation, userID)
119 if err != nil {
120 return err
121 }
122 if sessionID != "" {
123 if err := requireLiveSession(ctx, tx, operation, sessionID, userID, at); err != nil {
124 return err
125 }
126 }
127 row := tx.QueryRowContext(ctx, `DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING `+sshKeyColumns, keyID, userID)
128 removed, err = scanSSHKey(row)
129 switch {
130 case errors.Is(err, sql.ErrNoRows):
131 return fmt.Errorf("%s: %w", operation, ErrSSHKeyNotFound)
132 case err != nil:
133 return classify(operation, err)
134 }
135 return writeAudit(ctx, tx, operation, actor, "sshkey.remove", userID, at, sshKeyFields(username, removed))
136 })
137 if err != nil {
138 return SSHKey{}, err
139 }
140 return removed, nil
141}
142
143func (s *Store) SSHKeysOf(ctx context.Context, userID string) ([]SSHKey, error) {
144 return s.listSSHKeys(ctx, "list ssh keys of user", `SELECT `+sshKeyColumns+` FROM ssh_keys WHERE user_id = ? ORDER BY added_at, id`, userID)
145}
146
147func (s *Store) ListSSHKeys(ctx context.Context) ([]SSHKey, error) {
148 return s.listSSHKeys(ctx, "list ssh keys", `SELECT `+sshKeyColumns+` FROM ssh_keys ORDER BY user_id, added_at, id`)
149}
150
151func (s *Store) listSSHKeys(ctx context.Context, operation, query string, args ...any) ([]SSHKey, error) {
152 if err := s.checkOpen(operation); err != nil {
153 return nil, err
154 }
155 rows, err := s.sql.QueryContext(ctx, query, args...)
156 if err != nil {
157 return nil, classify(operation, err)
158 }
159 defer rows.Close()
160 var keys []SSHKey
161 for rows.Next() {
162 k, err := scanSSHKey(rows)
163 if err != nil {
164 return nil, classify(operation, err)
165 }
166 keys = append(keys, k)
167 }
168 if err := rows.Err(); err != nil {
169 return nil, classify(operation, err)
170 }
171 return keys, nil
172}
173
174func scanSSHKey(row interface{ Scan(...any) error }) (SSHKey, error) {
175 var k SSHKey
176 var added int64
177 if err := row.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Type, &k.Data, &k.Label, &added); err != nil {
178 return SSHKey{}, err
179 }
180 k.AddedAt = intToTime(added)
181 return k, nil
182}
183
184func sshKeyFields(username string, k SSHKey) map[string]any {
185 return map[string]any{"username": username, "fingerprint": k.Fingerprint, "key_type": k.Type}
186}
187
188func sshKeyHolder(ctx context.Context, q execer, operation, userID string) (string, error) {
189 var username string
190 var disabled, pending int
191 err := q.QueryRowContext(ctx, `SELECT username, disabled, EXISTS (SELECT 1 FROM pending_confirmations WHERE user_id = users.id) FROM users WHERE id = ?`, userID).Scan(&username, &disabled, &pending)
192 switch {
193 case errors.Is(err, sql.ErrNoRows):
194 return "", fmt.Errorf("%s: %w", operation, ErrNotFound)
195 case err != nil:
196 return "", classify(operation, err)
197 case pending != 0:
198 return "", fmt.Errorf("%s: %w", operation, ErrSSHKeyPending)
199 case disabled != 0:
200 return "", fmt.Errorf("%s: %w", operation, ErrSSHKeyDisabled)
201 }
202 return username, nil
203}