Raw
  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}