vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Accounts with stable ids; the roster moves into the system repository
31df8baea9c9e8acf74b71bdda26d27907b30f2a · Keenan Nemetz · 18d ago · parent 36293228
Accounts with stable ids; the roster moves into the system repository
Names were the primary key everywhere — the keys file name, the ACL entry, the
approval file — so renaming someone broke their access, orphaned their approvals,
and left a retired name able to hand a newcomer the old holder's rights. This
splits identity into an account (stable id, anchored by its SSH keys) and a name
(mutable, never reissued).
data/system.git refs/heads/main
users/<ulid>/meta.json name, status, keys, former_names
Everything that grants authority now holds the id: ACL entries, approval files,
and the identity resolved at the SSH handshake (VOLE_ACTOR_ID). A rename is a
one-file change that disturbs nothing else, and both the old and new names stay
reserved forever.
The roster moved from a trusted-keys directory back into git, which reverses an
earlier simplification — deliberately, and in a smaller shape (one file per
account, one writer, no CAS plumbing for unused fields). Git buys what a directory
could not: `git log` answers who renamed whom and when a key appeared, and the
roster is now readable by anyone who can clone, since it is served read-only at
ssh://host/system.git. `vole request new` and `approve` use exactly that to turn a
name typed on the command line into the account id they store.
Two latent bugs surfaced, both caught by tests added here:
- gitx inherited git's repository-scoped environment. pre-receive runs with
GIT_OBJECT_DIRECTORY and GIT_QUARANTINE_PATH pointing at the pushed repository's
quarantine, so every git call a hook made for a *different* repository silently
read the pushed one: the hook saw an empty roster. gitx now scrubs those
variables and the hook restores them for the pushed repository only.
- The hook built the repository name with filepath.Base on receive-pack's path,
which carries a trailing separator, so refusals read "does not have write access
to .". Cleaned first.
Verified end to end on a live instance: two accounts file, approve and land a
change; `vole user rename wilbur robert` keeps the same key's write access and the
approval still reads as current, now credited to robert; the roster history shows
"user: rename wilbur to robert"; retiring an account rejects its key within five
seconds and refuses to reissue the name.
internal/identity/identity.go +400−198
| @@ -1,306 +1,509 @@ | |||
| 1 | // Package identity is the instance's user roster: one file of SSH public keys | ||
| 2 | // per user, in a trusted-keys directory. | ||
| 1 | // Package identity stores the user roster in git. | ||
| 3 | 2 | // | |
| 4 | // data/trusted_keys/keenan.keys # one authorized_keys line per device | ||
| 3 | // data/system.git | ||
| 4 | // refs/heads/main | ||
| 5 | // users/<ulid>/meta.json | ||
| 5 | 6 | // | |
| 6 | // This is the same shape sshd uses for AuthorizedKeysFile, so the tools people | ||
| 7 | // already reach for work here: ssh-keygen -lf, ansible, rsync, an editor. Nothing | ||
| 8 | // stored is secret, which is what lets the directory be copied or put under | ||
| 9 | // configuration management without leaking anything. | ||
| 7 | // An account has a stable id and a mutable name. The id is what ACL entries and | ||
| 8 | // approvals refer to, so renaming somebody touches no repository state, and a | ||
| 9 | // retired name is never handed to a second account — which is what stops a new | ||
| 10 | // user from inheriting an old one's access and approvals. | ||
| 10 | 11 | // | |
| 11 | // Adds are appends, so two processes cannot lose each other's writes. Removes are | ||
| 12 | // read-modify-rename and assume a single operator, which is the deployment this | ||
| 13 | // is built for. | ||
| 12 | // The keys anchor the account: the SSH handshake resolves a presented key to an | ||
| 13 | // account id, and every authorization decision downstream uses that id rather | ||
| 14 | // than a name somebody chose to type. | ||
| 15 | // | ||
| 16 | // Nothing stored here is secret, so the repository can be cloned, copied or put | ||
| 17 | // under configuration management without leaking anything. | ||
| 14 | 18 | package identity | |
| 15 | 19 | ||
| 16 | 20 | import ( | |
| 21 | "encoding/json" | ||
| 17 | 22 | "fmt" | |
| 18 | 23 | "os" | |
| 24 | "os/exec" | ||
| 19 | 25 | "path/filepath" | |
| 26 | "regexp" | ||
| 20 | 27 | "sort" | |
| 21 | 28 | "strings" | |
| 22 | "sync" | ||
| 23 | 29 | "sync/atomic" | |
| 24 | 30 | "time" | |
| 25 | 31 | ||
| 32 | "github.com/rodent-software/vole/internal/gitx" | ||
| 33 | "github.com/rodent-software/vole/internal/id" | ||
| 26 | 34 | "golang.org/x/crypto/ssh" | |
| 27 | 35 | ) | |
| 28 | 36 | ||
| 29 | 37 | const ( | |
| 30 | keysSuffix = ".keys" | ||
| 31 | header = "# vole: SSH public keys for %s, one per device (authorized_keys format).\n" | ||
| 38 | // Ref is the branch of the system repository carrying the roster. | ||
| 39 | Ref = "refs/heads/main" | ||
| 40 | // Dir is the roster subtree. | ||
| 41 | Dir = "users" | ||
| 42 | |||
| 43 | metaName = "/meta.json" | ||
| 32 | 44 | ) | |
| 33 | 45 | ||
| 34 | type User struct { | ||
| 35 | Name string | ||
| 36 | Keys []Key | ||
| 46 | var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`) | ||
| 47 | |||
| 48 | // ValidateName reports whether s may be an account name. | ||
| 49 | func ValidateName(s string) error { | ||
| 50 | if !nameRE.MatchString(s) { | ||
| 51 | return fmt.Errorf("name %q is not valid: 2-32 characters, lowercase letters, digits, - and _", s) | ||
| 52 | } | ||
| 53 | return nil | ||
| 37 | 54 | } | |
| 38 | 55 | ||
| 39 | 56 | type Key struct { | |
| 40 | Fingerprint string | ||
| 41 | Type string | ||
| 42 | Comment string | ||
| 43 | Line string | ||
| 57 | Fingerprint string `json:"fingerprint"` | ||
| 58 | Type string `json:"type"` | ||
| 59 | Line string `json:"line"` | ||
| 60 | Comment string `json:"comment,omitempty"` | ||
| 61 | } | ||
| 62 | |||
| 63 | type Account struct { | ||
| 64 | ID string | ||
| 65 | Name string | ||
| 66 | Status string // active | retired | ||
| 67 | Former []string | ||
| 68 | Keys []Key | ||
| 69 | Created time.Time | ||
| 70 | } | ||
| 71 | |||
| 72 | func (a *Account) Active() bool { return a.Status == "active" } | ||
| 73 | |||
| 74 | type meta struct { | ||
| 75 | Name string `json:"name"` | ||
| 76 | Status string `json:"status"` | ||
| 77 | Created string `json:"created"` | ||
| 78 | Former []string `json:"former_names,omitempty"` | ||
| 79 | Keys []Key `json:"keys"` | ||
| 44 | 80 | } | |
| 45 | 81 | ||
| 46 | 82 | // Snapshot is an immutable view of the roster, swapped atomically so the SSH | |
| 47 | // auth callback is a map lookup rather than a file read. | ||
| 83 | // auth callback stays a map lookup. | ||
| 48 | 84 | type Snapshot struct { | |
| 49 | Users map[string]*User | ||
| 50 | ByFingerprint map[string]string | ||
| 85 | ByID map[string]*Account | ||
| 86 | // ByKey maps a key fingerprint to an active account id. Retired accounts are | ||
| 87 | // absent, so revoking is immediate. | ||
| 88 | ByKey map[string]string | ||
| 89 | // ByName maps an active name to its account. | ||
| 90 | ByName map[string]*Account | ||
| 91 | // Reserved holds every name an account has ever held, including former names | ||
| 92 | // and retired accounts. A reserved name is never issued twice. | ||
| 93 | Reserved map[string]string | ||
| 94 | |||
| 95 | Count int // active accounts | ||
| 51 | 96 | } | |
| 52 | 97 | ||
| 53 | 98 | func (s *Snapshot) Lookup(fingerprint string) (string, bool) { | |
| 54 | 99 | if s == nil { | |
| 55 | 100 | return "", false | |
| 56 | 101 | } | |
| 57 | name, ok := s.ByFingerprint[fingerprint] | ||
| 58 | return name, ok | ||
| 59 | } | ||
| 60 | |||
| 61 | func (s *Snapshot) Names() []string { | ||
| 62 | if s == nil { | ||
| 63 | return nil | ||
| 64 | } | ||
| 65 | out := make([]string, 0, len(s.Users)) | ||
| 66 | for n := range s.Users { | ||
| 67 | out = append(out, n) | ||
| 68 | } | ||
| 69 | sort.Strings(out) | ||
| 70 | return out | ||
| 102 | a, ok := s.ByKey[fingerprint] | ||
| 103 | return a, ok | ||
| 71 | 104 | } | |
| 72 | 105 | ||
| 73 | 106 | type Store struct { | |
| 74 | Dir string | ||
| 75 | // AuditPath, when set, receives one line per key change: the audit trail a | ||
| 76 | // git-backed roster used to provide for free. | ||
| 107 | // AuditPath, when set, receives one line per roster change. | ||
| 77 | 108 | AuditPath string | |
| 109 | repo *gitx.Repo | ||
| 78 | 110 | snap atomic.Pointer[Snapshot] | |
| 79 | mu sync.Mutex | ||
| 80 | 111 | } | |
| 81 | 112 | ||
| 82 | // Init creates the trusted-keys directory if it is missing. | ||
| 83 | func Init(dir string) error { return os.MkdirAll(dir, 0o755) } | ||
| 113 | // Init creates the system repository if it is missing. | ||
| 114 | func Init(dir string) error { | ||
| 115 | if _, err := os.Stat(filepath.Join(dir, "HEAD")); err == nil { | ||
| 116 | return nil | ||
| 117 | } | ||
| 118 | if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { | ||
| 119 | return err | ||
| 120 | } | ||
| 121 | // -b main so that `git log` in the roster repository shows the roster rather | ||
| 122 | // than an unborn master branch. | ||
| 123 | if out, err := exec.Command("git", "init", "--bare", "-q", "-b", "main", dir).CombinedOutput(); err != nil { | ||
| 124 | return fmt.Errorf("identity: git init --bare %s: %w: %s", dir, err, out) | ||
| 125 | } | ||
| 126 | return nil | ||
| 127 | } | ||
| 84 | 128 | ||
| 85 | // Open loads the roster. An unreadable or unparsable file is a hard error: | ||
| 86 | // booting with a partial roster would silently change who can authenticate. | ||
| 129 | // Open loads the roster. An empty roster is valid: it means nobody can write yet. | ||
| 130 | // A roster that does not parse is a hard error, because booting with a partial | ||
| 131 | // roster would silently change who can authenticate. | ||
| 87 | 132 | func Open(dir string) (*Store, error) { | |
| 88 | st, err := os.Stat(dir) | ||
| 89 | if err != nil || !st.IsDir() { | ||
| 90 | return nil, fmt.Errorf("identity: %s is missing; run vole init", dir) | ||
| 133 | if _, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil { | ||
| 134 | if _, err := os.Stat(dir); err != nil { | ||
| 135 | return nil, fmt.Errorf("identity: %s is missing; run vole init", dir) | ||
| 136 | } | ||
| 91 | 137 | } | |
| 92 | s := &Store{Dir: dir} | ||
| 138 | s := &Store{repo: gitx.Open(dir)} | ||
| 93 | 139 | if err := s.Reload(); err != nil { | |
| 94 | 140 | return nil, err | |
| 95 | 141 | } | |
| 96 | 142 | return s, nil | |
| 97 | 143 | } | |
| 98 | 144 | ||
| 145 | func (s *Store) Snapshot() *Snapshot { return s.snap.Load() } | ||
| 146 | |||
| 147 | // Dir is the system repository's path. | ||
| 148 | func (s *Store) Dir() string { return s.repo.Dir } | ||
| 149 | |||
| 150 | // Reload re-reads the roster, keeping the previous snapshot if the new one does | ||
| 151 | // not parse: a half-finished edit must not lock everybody out. | ||
| 99 | 152 | func (s *Store) Reload() error { | |
| 100 | entries, err := os.ReadDir(s.Dir) | ||
| 153 | commit, err := s.repo.RevParse(Ref) | ||
| 101 | 154 | if err != nil { | |
| 102 | 155 | return err | |
| 103 | 156 | } | |
| 104 | snap := &Snapshot{Users: map[string]*User{}, ByFingerprint: map[string]string{}} | ||
| 105 | for _, e := range entries { | ||
| 106 | if e.IsDir() || !strings.HasSuffix(e.Name(), keysSuffix) { | ||
| 107 | continue | ||
| 157 | files := map[string][]byte{} | ||
| 158 | if commit != "" { | ||
| 159 | files, err = s.repo.FlatTree(commit) | ||
| 160 | if err != nil { | ||
| 161 | return err | ||
| 162 | } | ||
| 163 | } | ||
| 164 | snap, err := parse(files) | ||
| 165 | if err != nil { | ||
| 166 | return err | ||
| 167 | } | ||
| 168 | s.snap.Store(snap) | ||
| 169 | return nil | ||
| 170 | } | ||
| 171 | |||
| 172 | func parse(files map[string][]byte) (*Snapshot, error) { | ||
| 173 | snap := &Snapshot{ | ||
| 174 | ByID: map[string]*Account{}, | ||
| 175 | ByKey: map[string]string{}, | ||
| 176 | ByName: map[string]*Account{}, | ||
| 177 | Reserved: map[string]string{}, | ||
| 178 | } | ||
| 179 | paths := make([]string, 0, len(files)) | ||
| 180 | for p := range files { | ||
| 181 | if strings.HasPrefix(p, Dir+"/") && strings.HasSuffix(p, metaName) { | ||
| 182 | paths = append(paths, p) | ||
| 183 | } | ||
| 184 | } | ||
| 185 | sort.Strings(paths) | ||
| 186 | for _, path := range paths { | ||
| 187 | accountID := strings.TrimSuffix(strings.TrimPrefix(path, Dir+"/"), metaName) | ||
| 188 | if strings.Contains(accountID, "/") || !id.Valid(accountID) { | ||
| 189 | return nil, fmt.Errorf("identity: %s: %q is not a valid account id", path, accountID) | ||
| 108 | 190 | } | |
| 109 | name := strings.TrimSuffix(e.Name(), keysSuffix) | ||
| 110 | if strings.HasPrefix(e.Name(), ".") { | ||
| 111 | continue | ||
| 191 | var m meta | ||
| 192 | dec := json.NewDecoder(strings.NewReader(string(files[path]))) | ||
| 193 | dec.DisallowUnknownFields() | ||
| 194 | if err := dec.Decode(&m); err != nil { | ||
| 195 | return nil, fmt.Errorf("identity: %s: %w", path, err) | ||
| 112 | 196 | } | |
| 113 | if err := ValidateName(name); err != nil { | ||
| 114 | return fmt.Errorf("identity: file %q: %w", e.Name(), err) | ||
| 197 | if err := ValidateName(m.Name); err != nil { | ||
| 198 | return nil, fmt.Errorf("identity: %s: %w", path, err) | ||
| 115 | 199 | } | |
| 116 | data, err := os.ReadFile(filepath.Join(s.Dir, e.Name())) | ||
| 200 | switch m.Status { | ||
| 201 | case "": | ||
| 202 | m.Status = "active" | ||
| 203 | case "active", "retired": | ||
| 204 | default: | ||
| 205 | return nil, fmt.Errorf("identity: %s: status must be active or retired", path) | ||
| 206 | } | ||
| 207 | created, err := time.Parse(time.RFC3339, m.Created) | ||
| 117 | 208 | if err != nil { | |
| 118 | return err | ||
| 209 | return nil, fmt.Errorf("identity: %s: created must be RFC3339: %w", path, err) | ||
| 119 | 210 | } | |
| 120 | u := &User{Name: name} | ||
| 121 | for i, line := range strings.Split(string(data), "\n") { | ||
| 122 | line = strings.TrimSpace(line) | ||
| 123 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 124 | continue | ||
| 211 | a := &Account{ID: accountID, Name: m.Name, Status: m.Status, Former: m.Former, Keys: m.Keys, Created: created.UTC()} | ||
| 212 | if a.Active() { | ||
| 213 | snap.ByName[a.Name] = a | ||
| 214 | snap.Count++ | ||
| 215 | } | ||
| 216 | for _, n := range append([]string{a.Name}, a.Former...) { | ||
| 217 | if other, taken := snap.Reserved[n]; taken && other != a.ID { | ||
| 218 | return nil, fmt.Errorf("identity: the name %q is claimed by both %s and %s", n, other, a.ID) | ||
| 125 | 219 | } | |
| 126 | pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 127 | if err != nil { | ||
| 128 | return fmt.Errorf("identity: %s line %d: %w", e.Name(), i+1, err) | ||
| 220 | snap.Reserved[n] = a.ID | ||
| 221 | } | ||
| 222 | for _, k := range a.Keys { | ||
| 223 | if other, ok := snap.ByKey[k.Fingerprint]; ok && other != a.ID { | ||
| 224 | return nil, fmt.Errorf("identity: key %s appears under both %s and %s", k.Fingerprint, other, a.ID) | ||
| 129 | 225 | } | |
| 130 | fp := ssh.FingerprintSHA256(pub) | ||
| 131 | if owner, ok := snap.ByFingerprint[fp]; ok { | ||
| 132 | return fmt.Errorf("identity: key %s appears under both %s and %s", fp, owner, name) | ||
| 226 | if a.Active() { | ||
| 227 | snap.ByKey[k.Fingerprint] = a.ID | ||
| 133 | 228 | } | |
| 134 | u.Keys = append(u.Keys, Key{ | ||
| 135 | Fingerprint: fp, | ||
| 136 | Type: pub.Type(), | ||
| 137 | Comment: comment, | ||
| 138 | Line: line, | ||
| 139 | }) | ||
| 140 | snap.ByFingerprint[fp] = name | ||
| 141 | 229 | } | |
| 142 | sort.Slice(u.Keys, func(i, j int) bool { return u.Keys[i].Fingerprint < u.Keys[j].Fingerprint }) | ||
| 143 | snap.Users[name] = u | ||
| 230 | sort.Slice(a.Keys, func(i, j int) bool { return a.Keys[i].Fingerprint < a.Keys[j].Fingerprint }) | ||
| 231 | snap.ByID[a.ID] = a | ||
| 144 | 232 | } | |
| 145 | s.snap.Store(snap) | ||
| 146 | return nil | ||
| 233 | return snap, nil | ||
| 147 | 234 | } | |
| 148 | 235 | ||
| 149 | func (s *Store) Snapshot() *Snapshot { return s.snap.Load() } | ||
| 236 | // Lookup resolves a key fingerprint to an active account id and name. | ||
| 237 | func (s *Store) Lookup(fingerprint string) (accountID, name string, ok bool) { | ||
| 238 | a, ok := s.Snapshot().ByID[s.Snapshot().ByKey[fingerprint]] | ||
| 239 | if !ok { | ||
| 240 | return "", "", false | ||
| 241 | } | ||
| 242 | return a.ID, a.Name, true | ||
| 243 | } | ||
| 150 | 244 | ||
| 151 | func (s *Store) Lookup(fingerprint string) (string, bool) { | ||
| 152 | return s.Snapshot().Lookup(fingerprint) | ||
| 245 | // ResolveID maps a name to an active account id. | ||
| 246 | func (s *Store) ResolveID(name string) (string, bool) { | ||
| 247 | a, ok := s.Snapshot().ByName[name] | ||
| 248 | if !ok { | ||
| 249 | return "", false | ||
| 250 | } | ||
| 251 | return a.ID, true | ||
| 153 | 252 | } | |
| 154 | 253 | ||
| 155 | func ValidateName(name string) error { | ||
| 156 | if len(name) < 2 || len(name) > 32 { | ||
| 157 | return fmt.Errorf("username must be 2-32 characters") | ||
| 254 | // Name returns the display name for an account id, or the id itself when the | ||
| 255 | // account is unknown. | ||
| 256 | func (s *Store) Name(accountID string) string { | ||
| 257 | if a, ok := s.Snapshot().ByID[accountID]; ok { | ||
| 258 | return a.Name | ||
| 158 | 259 | } | |
| 159 | for _, r := range name { | ||
| 160 | if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { | ||
| 161 | continue | ||
| 260 | return accountID | ||
| 261 | } | ||
| 262 | |||
| 263 | // Find resolves an id, an active name, a former name or a retired name. | ||
| 264 | func (s *Store) Find(idOrName string) (*Account, error) { | ||
| 265 | snap := s.Snapshot() | ||
| 266 | if a, ok := snap.ByID[idOrName]; ok { | ||
| 267 | return a, nil | ||
| 268 | } | ||
| 269 | if a, ok := snap.ByName[idOrName]; ok { | ||
| 270 | return a, nil | ||
| 271 | } | ||
| 272 | if owner, ok := snap.Reserved[idOrName]; ok { | ||
| 273 | if a, ok := snap.ByID[owner]; ok { | ||
| 274 | return a, nil | ||
| 162 | 275 | } | |
| 163 | return fmt.Errorf("username may only contain lowercase letters, digits, dash and underscore") | ||
| 164 | 276 | } | |
| 165 | return nil | ||
| 277 | return nil, fmt.Errorf("identity: no account %q", idOrName) | ||
| 278 | } | ||
| 279 | |||
| 280 | // All returns every account, active first, then by name. | ||
| 281 | func (s *Store) All() []*Account { | ||
| 282 | snap := s.Snapshot() | ||
| 283 | out := make([]*Account, 0, len(snap.ByID)) | ||
| 284 | for _, a := range snap.ByID { | ||
| 285 | out = append(out, a) | ||
| 286 | } | ||
| 287 | sort.Slice(out, func(i, j int) bool { | ||
| 288 | if out[i].Active() != out[j].Active() { | ||
| 289 | return out[i].Active() | ||
| 290 | } | ||
| 291 | return out[i].Name < out[j].Name | ||
| 292 | }) | ||
| 293 | return out | ||
| 166 | 294 | } | |
| 167 | 295 | ||
| 168 | func (s *Store) path(name string) string { return filepath.Join(s.Dir, name+keysSuffix) } | ||
| 296 | // ---------- mutations ---------- | ||
| 297 | // | ||
| 298 | // Every change is one commit on the system repository, so `git log users'` | ||
| 299 | // answers who changed what and when without a separate audit trail. | ||
| 169 | 300 | ||
| 170 | // Create adds an empty key file: a user who exists but cannot authenticate yet. | ||
| 171 | func (s *Store) Create(name string) error { | ||
| 172 | if err := ValidateName(name); err != nil { | ||
| 301 | func (s *Store) update(message, actor string, mutate func(files map[string][]byte, snap *Snapshot) error) error { | ||
| 302 | _, err := gitx.CommitFiles(s.repo, Ref, func(files map[string][]byte) error { | ||
| 303 | snap, err := parse(files) | ||
| 304 | if err != nil { | ||
| 305 | return err | ||
| 306 | } | ||
| 307 | return mutate(files, snap) | ||
| 308 | }, message, actor) | ||
| 309 | if err != nil { | ||
| 173 | 310 | return err | |
| 174 | 311 | } | |
| 175 | if _, ok := s.Snapshot().Users[name]; ok { | ||
| 176 | return fmt.Errorf("identity: user %q already exists", name) | ||
| 312 | return s.Reload() | ||
| 313 | } | ||
| 314 | |||
| 315 | // Create adds an account with a fresh id and no keys. | ||
| 316 | func (s *Store) Create(name, actor string) (*Account, error) { | ||
| 317 | if err := ValidateName(name); err != nil { | ||
| 318 | return nil, err | ||
| 177 | 319 | } | |
| 178 | f, err := os.OpenFile(s.path(name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) | ||
| 320 | var created *Account | ||
| 321 | err := s.update("user: create "+name, actor, func(files map[string][]byte, snap *Snapshot) error { | ||
| 322 | if owner, taken := snap.Reserved[name]; taken { | ||
| 323 | return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused; a retired or former name is never handed out twice", name, owner) | ||
| 324 | } | ||
| 325 | now := time.Now().UTC() | ||
| 326 | accountID, err := id.New(now) | ||
| 327 | if err != nil { | ||
| 328 | return err | ||
| 329 | } | ||
| 330 | a := &Account{ID: accountID, Name: name, Status: "active", Created: now} | ||
| 331 | raw, err := marshal(a) | ||
| 332 | if err != nil { | ||
| 333 | return err | ||
| 334 | } | ||
| 335 | files[Dir+"/"+accountID+metaName] = raw | ||
| 336 | created = a | ||
| 337 | return nil | ||
| 338 | }) | ||
| 179 | 339 | if err != nil { | |
| 180 | if os.IsExist(err) { | ||
| 181 | return fmt.Errorf("identity: user %q already exists", name) | ||
| 340 | return nil, err | ||
| 341 | } | ||
| 342 | s.audit("identity.create", created.ID, created.Name) | ||
| 343 | return created, nil | ||
| 344 | } | ||
| 345 | |||
| 346 | // Rename changes the name on an account. The old name stays reserved forever, so | ||
| 347 | // nobody can inherit it later. | ||
| 348 | func (s *Store) Rename(idOrName, newName, actor string) (*Account, error) { | ||
| 349 | if err := ValidateName(newName); err != nil { | ||
| 350 | return nil, err | ||
| 351 | } | ||
| 352 | var renamed *Account | ||
| 353 | err := s.update("user: rename "+idOrName+" to "+newName, actor, func(files map[string][]byte, snap *Snapshot) error { | ||
| 354 | a, err := find(snap, idOrName) | ||
| 355 | if err != nil { | ||
| 356 | return err | ||
| 182 | 357 | } | |
| 183 | return err | ||
| 358 | if a.Name == newName { | ||
| 359 | return fmt.Errorf("identity: %s is already called %q", a.ID, newName) | ||
| 360 | } | ||
| 361 | if owner, taken := snap.Reserved[newName]; taken && owner != a.ID { | ||
| 362 | return fmt.Errorf("identity: the name %q belongs to %s and cannot be reused", newName, owner) | ||
| 363 | } | ||
| 364 | a.Former = append(append([]string{}, a.Former...), a.Name) | ||
| 365 | a.Name = newName | ||
| 366 | raw, err := marshal(a) | ||
| 367 | if err != nil { | ||
| 368 | return err | ||
| 369 | } | ||
| 370 | files[Dir+"/"+a.ID+metaName] = raw | ||
| 371 | renamed = a | ||
| 372 | return nil | ||
| 373 | }) | ||
| 374 | if err != nil { | ||
| 375 | return nil, err | ||
| 184 | 376 | } | |
| 185 | defer f.Close() | ||
| 186 | if _, err := fmt.Fprintf(f, header, name); err != nil { | ||
| 187 | return err | ||
| 377 | s.audit("identity.rename", renamed.ID, renamed.Name) | ||
| 378 | return renamed, nil | ||
| 379 | } | ||
| 380 | |||
| 381 | // Retire disables an account. Its keys stop authenticating at the next reload and | ||
| 382 | // its name is never reissued. | ||
| 383 | func (s *Store) Retire(idOrName, actor string) (*Account, error) { | ||
| 384 | var retired *Account | ||
| 385 | err := s.update("user: retire "+idOrName, actor, func(files map[string][]byte, snap *Snapshot) error { | ||
| 386 | a, err := find(snap, idOrName) | ||
| 387 | if err != nil { | ||
| 388 | return err | ||
| 389 | } | ||
| 390 | a.Status = "retired" | ||
| 391 | raw, err := marshal(a) | ||
| 392 | if err != nil { | ||
| 393 | return err | ||
| 394 | } | ||
| 395 | files[Dir+"/"+a.ID+metaName] = raw | ||
| 396 | retired = a | ||
| 397 | return nil | ||
| 398 | }) | ||
| 399 | if err != nil { | ||
| 400 | return nil, err | ||
| 188 | 401 | } | |
| 189 | s.audit("identity.create", name, "") | ||
| 190 | return s.Reload() | ||
| 402 | s.audit("identity.retire", retired.ID, retired.Name) | ||
| 403 | return retired, nil | ||
| 191 | 404 | } | |
| 192 | 405 | ||
| 193 | // AddKey registers an SSH public key for a user and returns its fingerprint. | ||
| 194 | // The write is an append, so it cannot lose a concurrent add. | ||
| 195 | func (s *Store) AddKey(name, authorizedKey, actor string) (string, error) { | ||
| 406 | // AddKey attaches an authorized_keys line to an account and returns its | ||
| 407 | // fingerprint. | ||
| 408 | func (s *Store) AddKey(idOrName, authorizedKey, actor string) (string, error) { | ||
| 196 | 409 | line := strings.TrimSpace(authorizedKey) | |
| 197 | 410 | pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | |
| 198 | 411 | if err != nil { | |
| 199 | 412 | return "", fmt.Errorf("identity: not an SSH public key: %w", err) | |
| 200 | 413 | } | |
| 201 | 414 | fp := ssh.FingerprintSHA256(pub) | |
| 202 | if owner, ok := s.Lookup(fp); ok { | ||
| 203 | return "", fmt.Errorf("identity: that key is already registered to %s", owner) | ||
| 204 | } | ||
| 205 | if _, ok := s.Snapshot().Users[name]; !ok { | ||
| 206 | return "", fmt.Errorf("identity: no such user %q", name) | ||
| 207 | } | ||
| 208 | |||
| 209 | f, err := os.OpenFile(s.path(name), os.O_APPEND|os.O_WRONLY, 0o644) | ||
| 415 | err = s.update("user: add key "+fp, actor, func(files map[string][]byte, snap *Snapshot) error { | ||
| 416 | a, err := find(snap, idOrName) | ||
| 417 | if err != nil { | ||
| 418 | return err | ||
| 419 | } | ||
| 420 | if owner, taken := snap.ByKey[fp]; taken && owner != a.ID { | ||
| 421 | return fmt.Errorf("identity: that key is already registered to %s", owner) | ||
| 422 | } | ||
| 423 | for _, k := range a.Keys { | ||
| 424 | if k.Fingerprint == fp { | ||
| 425 | return fmt.Errorf("identity: %s already has that key", a.Name) | ||
| 426 | } | ||
| 427 | } | ||
| 428 | a.Keys = append(a.Keys, Key{Fingerprint: fp, Type: pub.Type(), Line: line, Comment: comment}) | ||
| 429 | raw, err := marshal(a) | ||
| 430 | if err != nil { | ||
| 431 | return err | ||
| 432 | } | ||
| 433 | files[Dir+"/"+a.ID+metaName] = raw | ||
| 434 | return nil | ||
| 435 | }) | ||
| 210 | 436 | if err != nil { | |
| 211 | 437 | return "", err | |
| 212 | 438 | } | |
| 213 | if _, err := fmt.Fprintf(f, "%s\n", line); err != nil { | ||
| 214 | f.Close() | ||
| 215 | return "", err | ||
| 216 | } | ||
| 217 | if err := f.Close(); err != nil { | ||
| 218 | return "", err | ||
| 219 | } | ||
| 220 | s.audit("identity.add-key", name, fp+" "+comment) | ||
| 221 | if err := s.Reload(); err != nil { | ||
| 222 | return "", err | ||
| 223 | } | ||
| 439 | s.audit("identity.add-key", idOrName, fp+" "+comment) | ||
| 224 | 440 | return fp, nil | |
| 225 | 441 | } | |
| 226 | 442 | ||
| 227 | // RemoveKey revokes a key by rewriting the file without it. Read-modify-rename, | ||
| 228 | // so it assumes a single operator process. | ||
| 229 | func (s *Store) RemoveKey(name, fingerprint, actor string) error { | ||
| 230 | s.mu.Lock() | ||
| 231 | defer s.mu.Unlock() | ||
| 232 | |||
| 233 | u, ok := s.Snapshot().Users[name] | ||
| 234 | if !ok { | ||
| 235 | return fmt.Errorf("identity: no such user %q", name) | ||
| 236 | } | ||
| 237 | found := false | ||
| 238 | for _, k := range u.Keys { | ||
| 239 | if k.Fingerprint == fingerprint { | ||
| 240 | found = true | ||
| 241 | break | ||
| 443 | // RemoveKey revokes one key. An exact fingerprint or a unique prefix is accepted. | ||
| 444 | func (s *Store) RemoveKey(idOrName, fingerprint, actor string) error { | ||
| 445 | err := s.update("user: remove key "+fingerprint, actor, func(files map[string][]byte, snap *Snapshot) error { | ||
| 446 | a, err := find(snap, idOrName) | ||
| 447 | if err != nil { | ||
| 448 | return err | ||
| 242 | 449 | } | |
| 243 | } | ||
| 244 | if !found { | ||
| 245 | return fmt.Errorf("identity: %s has no key %s", name, fingerprint) | ||
| 246 | } | ||
| 247 | |||
| 248 | p := s.path(name) | ||
| 249 | data, err := os.ReadFile(p) | ||
| 250 | if err != nil { | ||
| 251 | return err | ||
| 252 | } | ||
| 253 | var kept []string | ||
| 254 | for _, line := range strings.Split(string(data), "\n") { | ||
| 255 | trimmed := strings.TrimSpace(line) | ||
| 256 | switch { | ||
| 257 | case trimmed == "": | ||
| 258 | continue | ||
| 259 | case strings.HasPrefix(trimmed, "#"): | ||
| 260 | kept = append(kept, line) | ||
| 261 | default: | ||
| 262 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(trimmed)) | ||
| 263 | if err != nil { | ||
| 264 | return fmt.Errorf("identity: %s: %w", p, err) | ||
| 265 | } | ||
| 266 | if ssh.FingerprintSHA256(pub) != fingerprint { | ||
| 267 | kept = append(kept, line) | ||
| 450 | var kept []Key | ||
| 451 | found := 0 | ||
| 452 | for _, k := range a.Keys { | ||
| 453 | if k.Fingerprint == fingerprint || strings.HasPrefix(k.Fingerprint, fingerprint) { | ||
| 454 | found++ | ||
| 455 | continue | ||
| 268 | 456 | } | |
| 457 | kept = append(kept, k) | ||
| 269 | 458 | } | |
| 270 | } | ||
| 271 | if err := writeAtomic(p, strings.Join(kept, "\n")+"\n"); err != nil { | ||
| 459 | switch { | ||
| 460 | case found == 0: | ||
| 461 | return fmt.Errorf("identity: %s has no key %s", a.Name, fingerprint) | ||
| 462 | case found > 1: | ||
| 463 | return fmt.Errorf("identity: %q matches %d keys of %s; give more of the fingerprint", fingerprint, found, a.Name) | ||
| 464 | } | ||
| 465 | a.Keys = kept | ||
| 466 | raw, err := marshal(a) | ||
| 467 | if err != nil { | ||
| 468 | return err | ||
| 469 | } | ||
| 470 | files[Dir+"/"+a.ID+metaName] = raw | ||
| 471 | return nil | ||
| 472 | }) | ||
| 473 | if err != nil { | ||
| 272 | 474 | return err | |
| 273 | 475 | } | |
| 274 | s.audit("identity.remove-key", name, fingerprint) | ||
| 275 | return s.Reload() | ||
| 476 | s.audit("identity.remove-key", idOrName, fingerprint) | ||
| 477 | return nil | ||
| 276 | 478 | } | |
| 277 | 479 | ||
| 278 | // writeAtomic replaces path via a temp file in the same directory, so a reader | ||
| 279 | // never observes a half-written roster. | ||
| 280 | func writeAtomic(path, content string) error { | ||
| 281 | dir := filepath.Dir(path) | ||
| 282 | f, err := os.CreateTemp(dir, ".vole-keys-*") | ||
| 283 | if err != nil { | ||
| 284 | return err | ||
| 480 | func find(snap *Snapshot, idOrName string) (*Account, error) { | ||
| 481 | if a, ok := snap.ByID[idOrName]; ok { | ||
| 482 | return a, nil | ||
| 285 | 483 | } | |
| 286 | tmp := f.Name() | ||
| 287 | defer os.Remove(tmp) | ||
| 288 | if _, err := f.WriteString(content); err != nil { | ||
| 289 | f.Close() | ||
| 290 | return err | ||
| 291 | } | ||
| 292 | if err := f.Close(); err != nil { | ||
| 293 | return err | ||
| 484 | if a, ok := snap.ByName[idOrName]; ok { | ||
| 485 | return a, nil | ||
| 294 | 486 | } | |
| 295 | if err := os.Chmod(tmp, 0o644); err != nil { | ||
| 296 | return err | ||
| 487 | return nil, fmt.Errorf("identity: no active account %q", idOrName) | ||
| 488 | } | ||
| 489 | |||
| 490 | func marshal(a *Account) ([]byte, error) { | ||
| 491 | b, err := json.MarshalIndent(meta{ | ||
| 492 | Name: a.Name, | ||
| 493 | Status: a.Status, | ||
| 494 | Created: a.Created.UTC().Format(time.RFC3339), | ||
| 495 | Former: a.Former, | ||
| 496 | Keys: a.Keys, | ||
| 497 | }, "", " ") | ||
| 498 | if err != nil { | ||
| 499 | return nil, err | ||
| 297 | 500 | } | |
| 298 | return os.Rename(tmp, path) | ||
| 501 | return append(b, '\n'), nil | ||
| 299 | 502 | } | |
| 300 | 503 | ||
| 301 | // audit appends one line describing a roster change. Best effort: failing to | ||
| 302 | // write the log must not fail an otherwise valid key change. | ||
| 303 | func (s *Store) audit(action, user, detail string) { | ||
| 504 | // audit appends one line describing a roster change. The git history is the real | ||
| 505 | // record; this is for operators grepping one file. | ||
| 506 | func (s *Store) audit(action, account, detail string) { | ||
| 304 | 507 | if s.AuditPath == "" { | |
| 305 | 508 | return | |
| 306 | 509 | } | |
| @@ -308,7 +511,6 @@ func (s *Store) audit(action, user, detail string) { | |||
| 308 | 511 | if err != nil { | |
| 309 | 512 | return | |
| 310 | 513 | } | |
| 311 | fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", | ||
| 312 | time.Now().UTC().Format(time.RFC3339), action, user, detail) | ||
| 514 | fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, account, detail) | ||
| 313 | 515 | f.Close() | |
| 314 | 516 | } | |