Files
mesh-catalog/modules/gitea/internal/forgeaccount/keys.go
T

156 lines
4.7 KiB
Go

package forgeaccount
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"gitea/internal/forgehttp"
)
// TitlePrefix starts the title of every key the mesh registers; a key without it is the operator's and is never
// touched (ADR 0399 §2, ADR 0400).
const TitlePrefix = "mesh:"
// KeyTitle is the title of a machine account's key: `mesh:<node>.<account>`.
func KeyTitle(node, account string) string { return TitlePrefix + node + "." + account }
// WantedKey is a public key the mesh wants registered, under its title.
type WantedKey struct {
Title string
// Key is the public key line: type, base64 and a comment.
Key string
}
// Key is a key the forge holds for an account.
type Key struct {
ID int64 `json:"id"`
Title string `json:"title"`
Key string `json:"key"`
}
// material is a public key line without its comment: its type and its base64, which is what makes it the same key.
func material(line string) string {
f := strings.Fields(line)
if len(f) < 2 {
return strings.TrimSpace(line)
}
return f[0] + " " + f[1]
}
// Keys lists the SSH keys the account holds.
func (m *Accounts) Keys(ctx context.Context, user string) ([]Key, error) {
var all []Key
for page := 1; page <= 20; page++ {
var got []Key
if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/users/%s/keys?limit=50&page=%d", user, page), &got); err != nil {
return nil, fmt.Errorf("the keys of %s could not be read: %w", user, err)
}
all = append(all, got...)
if len(got) < 50 {
break
}
}
return all, nil
}
// AddKey registers a key for the account, one it may write with.
func (m *Accounts) AddKey(ctx context.Context, user string, k WantedKey) error {
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users/"+user+"/keys",
map[string]any{"title": k.Title, "key": k.Key, "read_only": false}, nil)
if err != nil {
return fmt.Errorf("the key %s could not be added for %s: %w", k.Title, user, err)
}
return nil
}
// DeleteKey removes one key of the account, by its id.
func (m *Accounts) DeleteKey(ctx context.Context, user string, id int64) error {
err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/users/%s/keys/%d", user, id), nil, nil)
if err != nil && !errors.Is(err, forgehttp.ErrNotFound) {
return fmt.Errorf("the key %d of %s could not be deleted: %w", id, user, err)
}
return nil
}
// EnsureKeys registers each wanted key the account does not hold and removes the keys the mesh registered
// (title starting `mesh:`) that are no longer wanted. A key whose title does not start `mesh:` is the operator's and
// is never touched, and a wanted key the account already holds under another title is left as it is (ADR 0399 §2).
// A key is added before another title's is removed, so an account is not left without one.
func (m *Accounts) EnsureKeys(ctx context.Context, user string, wanted []WantedKey) error {
held, err := m.Keys(ctx, user)
if err != nil {
return err
}
have := map[string]bool{}
for _, k := range held {
have[material(k.Key)] = true
}
want := map[string]bool{}
for _, w := range wanted {
want[material(w.Key)] = true
}
gone := map[int64]bool{}
remove := func(k Key) error {
if gone[k.ID] {
return nil
}
gone[k.ID] = true
if err := m.DeleteKey(ctx, user, k.ID); err != nil {
return err
}
m.say("removed the key %s of %s: it is no longer wanted", k.Title, user)
return nil
}
for _, w := range wanted {
if have[material(w.Key)] {
continue
}
// The forge holds a title once: the mesh's key of the same title, now another key, goes first.
for _, k := range held {
if k.Title == w.Title && strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] {
if err := remove(k); err != nil {
return err
}
}
}
if err := m.AddKey(ctx, user, w); err != nil {
return err
}
m.say("registered the key %s for %s", w.Title, user)
}
for _, k := range held {
if strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] {
if err := remove(k); err != nil {
return err
}
}
}
return nil
}
// RemoveKeysTitled removes the account's keys whose title starts with prefix, and only a prefix the mesh gave
// (starting `mesh:`, such as `mesh:g14.`): what the mesh registered for one machine, never a key of the operator's
// (ADR 0399 §2).
func (m *Accounts) RemoveKeysTitled(ctx context.Context, user, prefix string) error {
if !strings.HasPrefix(prefix, TitlePrefix) {
return nil
}
held, err := m.Keys(ctx, user)
if err != nil {
return err
}
for _, k := range held {
if !strings.HasPrefix(k.Title, prefix) {
continue
}
if err := m.DeleteKey(ctx, user, k.ID); err != nil {
return err
}
m.say("removed the key %s of %s: its machine no longer binds the forge", k.Title, user)
}
return nil
}