// Package admin implements the management operations exposed by the TUI
// (and usable headlessly): users, repos, ACLs and the trust store, per
// DESIGN.md §2 ("Gestion des repos/utilisateurs/ACL via TUI").
package admin
import (
"context"
"fmt"
"log"
"net/url"
"os"
"regexp"
"sort"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
gossh "golang.org/x/crypto/ssh"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/acl"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/ca"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/federation"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)
// Ops is the set of management operations the TUI drives, satisfied both by
// *Admin (direct, in-process access to the store) and by an RPC client
// talking to a running gitfed-server over its admin socket (see
// internal/adminrpc). This lets gitfed-tui manage a live instance without
// fighting the store's single-writer file lock.
type Ops interface {
ListUsers() ([]store.User, error)
GetUser(username string) (store.User, error)
CreateUser(username, pubKeyAuthorized string) error
AddUserKey(username, pubKeyAuthorized string) error
RemoveUserKey(username, pubKeyAuthorized string) error
DeleteUser(username string) error
SetUserAdmin(username string, isAdmin bool) error
SetUserBio(username, bio string) error
SetPassword(username, newPassword string) error
VerifyPassword(username, password string) (isAdmin bool, ok bool, err error)
CreateSession(principal, username string, isAdmin bool) (token string, err error)
GetSession(token string) (store.Session, error)
DeleteSession(token string) error
CheckAccess(repoName, principal string, want store.Role) (store.Role, bool, error)
ListRepos() ([]store.Repo, error)
GetRepo(name string) (store.Repo, error)
CreateRepo(name, ownerUsername string) error
ImportRepo(name, sourceURL, ownerUsername string) error
DeleteRepo(name string) error
SetRepoPublic(name string, public bool) error
SetRepoTopics(name string, topics []string) error
SetRepoDescription(name, description string) error
GetRepoReadme(name string) (content string, found bool, err error)
ListRepoReadmeLanguages(name string) (langs []string, err error)
GetRepoReadmeLang(name, lang string) (content string, found bool, err error)
GetRepoLicense(name string) (content, filename string, found bool, err error)
ListRepoTags(name string) ([]string, error)
GetRepoArchive(name, ref, format string) (data []byte, err error)
ListRepoTree(name, path string) (entries []gitexec.TreeEntry, found bool, err error)
ListRepoTreeAtRef(name, ref, path string) (entries []gitexec.TreeEntry, found bool, err error)
GetRepoFile(name, path string) (content string, found bool, err error)
GetRepoFileAtRef(name, ref, path string) (content string, found bool, err error)
GetRepoBranch(name string) (branch string, found bool, err error)
ListCommits(name string, limit int) (commits []gitexec.Commit, found bool, err error)
ListCommitsPage(name string, limit, offset int) (commits []gitexec.Commit, found bool, err error)
CountCommits(name string) (count int, found bool, err error)
CountContributors(name string) (count int, found bool, err error)
DominantLanguage(name string) (language string, found bool, err error)
RepoDiskUsage(name string) (bytes int64, err error)
ShowCommit(name, hash string) (detail gitexec.CommitDetail, found bool, err error)
CommitDiff(name, hash string) (diff string, truncated bool, err error)
ListBranches(name string) (branches []string, err error)
CreateMergeRequest(repoName, title, description, author, sourceBranch, targetBranch string) (store.MergeRequest, error)
ListMergeRequests(repoName string) ([]store.MergeRequest, error)
GetMergeRequest(repoName string, number int) (store.MergeRequest, error)
MergeRequestDiff(repoName string, number int) (files []gitexec.DiffFile, diff string, truncated bool, found bool, err error)
CheckMergeRequestMergeable(repoName string, number int) (gitexec.MergeResult, error)
MergeMergeRequest(repoName string, number int, actor string) (store.MergeRequest, gitexec.MergeResult, error)
CloseMergeRequest(repoName string, number int) error
ListMRComments(repoName string, number int) ([]store.MRComment, error)
AddMRComment(repoName string, number int, author, body string) (store.MRComment, error)
GetACL(repoName string) (store.ACL, error)
GrantCollaborator(repoName, principal, actor string, role store.Role) error
RevokeCollaborator(repoName, principal string) error
ListTrustedCAs() ([]store.TrustedCA, error)
CountPendingTrust() (int, error)
ApproveDomain(domain string) error
PinRepo(principal, domain, repo, label string) error
UnpinRepo(principal, domain, repo string) error
ListPinnedRepos(principal string) ([]store.PinnedRepo, error)
ListNotifications(principal string) ([]store.Notification, error)
CountPendingNotifications(principal string) (int, error)
AcceptNotification(principal, id string) error
DismissNotification(principal, id string) error
ListAudit(limit int) ([]store.AuditEvent, error)
}
type Admin struct {
Store *store.Store
Resolver *federation.Resolver
Domain string
ReposDir string
CA *ca.CA // signs outbound federated notifications; see GrantCollaborator
}
var _ Ops = (*Admin)(nil)
func New(st *store.Store, resolver *federation.Resolver, domain, reposDir string, localCA *ca.CA) *Admin {
return &Admin{Store: st, Resolver: resolver, Domain: domain, ReposDir: reposDir, CA: localCA}
}
// CreateUser registers a new local user with an initial SSH public key
// (authorized_keys format). It clears any lingering revocation for the
// principal or that key, so re-creating a previously-deleted user (or
// re-adding a removed key) restores certificate access.
func (a *Admin) CreateUser(username, pubKeyAuthorized string) error {
key, err := canonicalAuthorizedKey(pubKeyAuthorized)
if err != nil {
return err
}
if err := a.Store.CreateUser(store.User{Username: username, PubKeys: []string{key}}); err != nil {
return err
}
_ = a.Store.UnrevokePrincipal(fmt.Sprintf("%s@%s", username, a.Domain))
if fp, err := keyFingerprint(key); err == nil {
_ = a.Store.UnrevokeKey(fp)
}
return nil
}
func (a *Admin) AddUserKey(username, pubKeyAuthorized string) error {
key, err := canonicalAuthorizedKey(pubKeyAuthorized)
if err != nil {
return err
}
if err := a.Store.AddUserKey(username, key); err != nil {
return err
}
if fp, err := keyFingerprint(key); err == nil {
_ = a.Store.UnrevokeKey(fp)
}
return nil
}
// RemoveUserKey drops a key from a user and revokes any outstanding
// certificate issued for it, so a removed key can't keep authenticating via a
// still-valid cert until its TTL lapses.
func (a *Admin) RemoveUserKey(username, pubKeyAuthorized string) error {
key, err := canonicalAuthorizedKey(pubKeyAuthorized)
if err != nil {
return err
}
if err := a.Store.RemoveUserKey(username, key); err != nil {
return err
}
if fp, err := keyFingerprint(key); err == nil {
_ = a.Store.RevokeKey(fp)
}
return nil
}
// keyFingerprint returns the SHA-256 fingerprint of an authorized_keys line,
// matching gossh.FingerprintSHA256(cert.Key) used during cert auth.
func keyFingerprint(pubKeyAuthorized string) (string, error) {
pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(pubKeyAuthorized))
if err != nil {
return "", err
}
return gossh.FingerprintSHA256(pub), nil
}
// canonicalAuthorizedKey re-marshals a pasted authorized_keys line (which,
// coming straight from a .pub file, normally carries a "user@host" comment)
// into the bare "algo base64" form with no comment. That's the exact form
// ssh.MarshalAuthorizedKey produces from the key offered on the wire during
// auth (comments aren't part of the SSH protocol's pubkey exchange) — so
// storing anything else means FindUserByKey's exact-string match against
// the login attempt would silently never succeed.
func canonicalAuthorizedKey(pubKeyAuthorized string) (string, error) {
pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(strings.TrimSpace(pubKeyAuthorized)))
if err != nil {
return "", fmt.Errorf("admin: invalid public key: %w", err)
}
return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub))), nil
}
// DeleteUser removes a local user, and strips them as a collaborator from
// every repo's ACL — otherwise a stale entry naming a nonexistent principal
// lingers forever. Repos they own are left alone; those must be reassigned
// or deleted first, since a repo without a valid owner would silently lose
// its "owner always has admin" guarantee (§6).
func (a *Admin) DeleteUser(username string) error {
principal := fmt.Sprintf("%s@%s", username, a.Domain)
repos, err := a.Store.ListRepos()
if err != nil {
return err
}
for _, r := range repos {
if r.Owner == principal {
return fmt.Errorf("admin: %s owns repo %q; delete or reassign it before deleting the user", principal, r.Name)
}
}
if err := a.Store.DeleteUser(username); err != nil {
return err
}
if err := a.Store.DeletePasswordHash(username); err != nil && err != store.ErrNotFound {
return err
}
// Block any certificate this user still holds (valid until its TTL) from
// authenticating now that the account is gone.
if err := a.Store.RevokePrincipal(principal); err != nil {
return err
}
for _, r := range repos {
if err := a.Store.RemoveCollaborator(r.Name, principal); err != nil && err != store.ErrNotFound {
return err
}
}
return nil
}
func (a *Admin) ListUsers() ([]store.User, error) {
return a.Store.ListUsers()
}
func (a *Admin) GetUser(username string) (store.User, error) {
return a.Store.GetUser(username)
}
func (a *Admin) SetUserAdmin(username string, isAdmin bool) error {
return a.Store.SetUserAdmin(username, isAdmin)
}
// bioMaxLen matches mrTextMaxLen (cmd/gitfed-web) — same free-text-field
// budget used throughout the app.
const bioMaxLen = 4000
func (a *Admin) SetUserBio(username, bio string) error {
if len(bio) > bioMaxLen {
return fmt.Errorf("admin: bio must be at most %d characters", bioMaxLen)
}
return a.Store.SetUserBio(username, bio)
}
func (a *Admin) ListRepos() ([]store.Repo, error) {
return a.Store.ListRepos()
}
func (a *Admin) GetRepo(name string) (store.Repo, error) {
return a.Store.GetRepo(name)
}
func (a *Admin) GetACL(repoName string) (store.ACL, error) {
return a.Store.GetACL(repoName)
}
func (a *Admin) ListTrustedCAs() ([]store.TrustedCA, error) {
return a.Store.ListTrustedCAs()
}
func (a *Admin) CountPendingTrust() (int, error) {
return a.Store.CountPendingTrust()
}
func (a *Admin) ListAudit(limit int) ([]store.AuditEvent, error) {
return a.Store.ListAudit(limit)
}
// CreateRepo creates a bare repo on disk and registers it, owned by
// "<owner>@<localDomain>".
func (a *Admin) CreateRepo(name, ownerUsername string) error {
path, err := gitexec.ResolvePath(a.ReposDir, name)
if err != nil {
return err
}
if err := gitexec.InitBareRepo(path); err != nil {
return err
}
owner := fmt.Sprintf("%s@%s", ownerUsername, a.Domain)
return a.Store.CreateRepo(store.Repo{Name: name, Owner: owner, Path: path})
}
// ImportCloneTimeout bounds how long a one-shot import's git clone may run,
// so a slow or hostile source can't tie up the server indefinitely. Exported
// so adminrpc's Client can give the RPC call itself a matching (slightly
// longer) deadline — the default per-call RPC timeout is tuned for the
// local, near-instant operations everything else in this package does.
const ImportCloneTimeout = 5 * time.Minute
// ImportRepo one-shot-clones sourceURL into a new bare repo, registered like
// any other repo and owned by "<ownerUsername>@<localDomain>" — see
// ROADMAP.md §3 ("import ponctuel depuis un dépôt externe").
func (a *Admin) ImportRepo(name, sourceURL, ownerUsername string) error {
if err := validateImportURL(sourceURL); err != nil {
return err
}
path, err := gitexec.ResolvePath(a.ReposDir, name)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), ImportCloneTimeout)
defer cancel()
if err := gitexec.CloneMirror(ctx, path, sourceURL); err != nil {
return err
}
owner := fmt.Sprintf("%s@%s", ownerUsername, a.Domain)
if err := a.Store.CreateRepo(store.Repo{Name: name, Owner: owner, Path: path}); err != nil {
_ = os.RemoveAll(path) // don't leave an orphaned clone on disk if the store write fails
return err
}
return nil
}
// validateImportURL restricts one-shot repo import to plain HTTPS URLs with
// no embedded credentials, resolving to a public address — the same
// SSRF/credential-leak concerns as federation discovery (see
// federation.CheckPublicHost), plus a hard scheme allowlist that rules out
// git's other transport helpers (ext::, fd::, file://, bare local paths),
// which can run arbitrary commands or read arbitrary server files if ever
// reached with a user-controlled URL.
func validateImportURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("admin: invalid source URL: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("admin: source URL must start with https://")
}
if u.User != nil {
return fmt.Errorf("admin: source URL must not carry credentials")
}
if u.Hostname() == "" {
return fmt.Errorf("admin: source URL must have a host")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := federation.CheckPublicHost(ctx, u.Hostname()); err != nil {
return fmt.Errorf("admin: source URL host: %w", err)
}
return nil
}
func (a *Admin) DeleteRepo(name string) error {
if err := a.Store.DeleteRepo(name); err != nil {
return err
}
return a.Store.DeleteACL(name)
}
func (a *Admin) SetRepoPublic(name string, public bool) error {
return a.Store.SetRepoPublic(name, public)
}
func (a *Admin) SetRepoTopics(name string, topics []string) error {
return a.Store.SetRepoTopics(name, topics)
}
func (a *Admin) SetRepoDescription(name, description string) error {
return a.Store.SetRepoDescription(name, description)
}
// readmeCandidates and licenseCandidates are tried in order against the
// tree at HEAD; the first match wins.
var readmeCandidates = []string{"README.md", "Readme.md", "README.markdown", "README", "README.txt"}
var licenseCandidates = []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "LICENSE.rst", "COPYING"}
func (a *Admin) GetRepoReadme(name string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", false, err
}
for _, candidate := range readmeCandidates {
content, found, err := gitexec.ReadFileAtHEAD(repo.Path, candidate)
if err != nil {
return "", false, err
}
if found {
return content, true, nil
}
}
return "", false, nil
}
// readmeLangPattern matches README.<lang>.md sibling files at the repo root
// (e.g. README.fr.md), for the repo page's language switcher.
var readmeLangPattern = regexp.MustCompile(`(?i)^readme\.([a-z]{2})\.md$`)
// ListRepoReadmeLanguages returns the language codes of README.<lang>.md
// sibling files found next to the repo's main README at HEAD, sorted.
func (a *Admin) ListRepoReadmeLanguages(name string) ([]string, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, err
}
entries, found, err := gitexec.ListTree(repo.Path, "")
if err != nil || !found {
return nil, err
}
var langs []string
for _, e := range entries {
if e.Type != "blob" {
continue
}
if m := readmeLangPattern.FindStringSubmatch(e.Name); m != nil {
langs = append(langs, strings.ToLower(m[1]))
}
}
sort.Strings(langs)
return langs, nil
}
// GetRepoReadmeLang returns README.<lang>.md's content at HEAD. The caller
// must have already validated lang against ListRepoReadmeLanguages — same
// contract as ListRepoTreeAtRef.
func (a *Admin) GetRepoReadmeLang(name, lang string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", false, err
}
return gitexec.ReadFileAtHEAD(repo.Path, "README."+lang+".md")
}
func (a *Admin) GetRepoLicense(name string) (string, string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", "", false, err
}
for _, candidate := range licenseCandidates {
content, found, err := gitexec.ReadFileAtHEAD(repo.Path, candidate)
if err != nil {
return "", "", false, err
}
if found {
return content, candidate, true, nil
}
}
return "", "", false, nil
}
func (a *Admin) ListRepoTags(name string) ([]string, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, err
}
return gitexec.ListTags(repo.Path)
}
// GetRepoArchive returns a tar.gz/zip snapshot of ref (a branch or tag).
// The caller must have already validated ref against the repo's real
// branches/tags (see gitexec.Archive's contract).
func (a *Admin) GetRepoArchive(name, ref, format string) ([]byte, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, err
}
return gitexec.Archive(repo.Path, ref, format)
}
func (a *Admin) ListRepoTree(name, path string) ([]gitexec.TreeEntry, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, false, err
}
return gitexec.ListTree(repo.Path, path)
}
// ListRepoTreeAtRef is ListRepoTree against an explicit branch (from the
// repo page's branch dropdown) instead of the repo's default branch. The
// caller must have already validated ref against ListBranches — this
// doesn't re-check it (see gitexec.ListTreeAtRef).
func (a *Admin) ListRepoTreeAtRef(name, ref, path string) ([]gitexec.TreeEntry, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, false, err
}
return gitexec.ListTreeAtRef(repo.Path, ref, path)
}
// GetRepoFile returns the content of an exact path at HEAD — unlike
// GetRepoReadme/GetRepoLicense, it doesn't try alternate filenames.
func (a *Admin) GetRepoFile(name, path string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", false, err
}
return gitexec.ReadFileAtHEAD(repo.Path, path)
}
// GetRepoFileAtRef is GetRepoFile against an explicit branch instead of the
// repo's default branch — same caller-must-validate-ref contract as
// ListRepoTreeAtRef.
func (a *Admin) GetRepoFileAtRef(name, ref, path string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", false, err
}
return gitexec.ReadFileAtRef(repo.Path, ref, path)
}
func (a *Admin) GetRepoBranch(name string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", false, err
}
branch, ok := gitexec.DefaultBranchName(repo.Path)
return branch, ok, nil
}
func (a *Admin) ListCommits(name string, limit int) ([]gitexec.Commit, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, false, err
}
return gitexec.ListCommits(repo.Path, limit)
}
func (a *Admin) ListCommitsPage(name string, limit, offset int) ([]gitexec.Commit, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, false, err
}
return gitexec.ListCommitsPage(repo.Path, limit, offset)
}
func (a *Admin) CountCommits(name string) (int, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return 0, false, err
}
return gitexec.CountCommits(repo.Path)
}
func (a *Admin) CountContributors(name string) (int, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return 0, false, err
}
return gitexec.CountContributors(repo.Path)
}
func (a *Admin) DominantLanguage(name string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", false, err
}
return gitexec.DominantLanguage(repo.Path)
}
// RepoDiskUsage returns name's total on-disk size in bytes, for the admin
// repos list — see gitexec.DiskUsage for what's counted.
func (a *Admin) RepoDiskUsage(name string) (int64, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return 0, err
}
return gitexec.DiskUsage(repo.Path)
}
func (a *Admin) ShowCommit(name, hash string) (gitexec.CommitDetail, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return gitexec.CommitDetail{}, false, err
}
return gitexec.ShowCommit(repo.Path, hash)
}
func (a *Admin) CommitDiff(name, hash string) (string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return "", false, err
}
return gitexec.CommitDiff(repo.Path, hash)
}
func (a *Admin) ListBranches(name string) ([]string, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
return nil, err
}
return gitexec.ListBranches(repo.Path)
}
func (a *Admin) CreateMergeRequest(repoName, title, description, author, sourceBranch, targetBranch string) (store.MergeRequest, error) {
mr, err := a.Store.CreateMergeRequest(store.MergeRequest{
Repo: repoName, Title: title, Description: description, Author: author,
SourceBranch: sourceBranch, TargetBranch: targetBranch,
})
if err != nil {
return mr, err
}
// Best-effort: let the repo owner know a new MR is waiting on them.
// Never fails MR creation itself over a notification.
if repo, err := a.Store.GetRepo(repoName); err == nil {
a.notifyMR(repo.Owner, repoName, author, store.NotificationMROpened, mr.Number, title)
}
return mr, nil
}
// notifyMR records an mr_opened/mr_comment notification for recipient, a
// repo's local activity rather than a federated grant (see
// store.NotificationKind). It's a silent no-op if recipient is the same
// principal as actor (don't notify people about their own actions) or
// isn't a local principal — MR notifications aren't federated out to a
// collaborator's home instance, unlike grants (see notifyGrant).
func (a *Admin) notifyMR(recipient, repoName, actor string, kind store.NotificationKind, mrNumber int, title string) {
if recipient == "" || recipient == actor {
return
}
if _, domain, ok := splitPrincipal(recipient); !ok || domain != a.Domain {
return
}
_ = a.Store.CreateNotification(store.Notification{
Principal: recipient,
FromDomain: a.Domain,
Repo: repoName,
Actor: actor,
Kind: kind,
MRNumber: mrNumber,
Title: title,
})
}
func (a *Admin) ListMergeRequests(repoName string) ([]store.MergeRequest, error) {
return a.Store.ListMergeRequests(repoName)
}
func (a *Admin) GetMergeRequest(repoName string, number int) (store.MergeRequest, error) {
return a.Store.GetMergeRequest(repoName, number)
}
// MergeRequestDiff computes mr's live diff against its two branches' current
// tips — never a stored snapshot, so a new push to the source branch is
// reflected the next time this is called with no extra bookkeeping.
func (a *Admin) MergeRequestDiff(repoName string, number int) ([]gitexec.DiffFile, string, bool, bool, error) {
mr, err := a.Store.GetMergeRequest(repoName, number)
if err != nil {
return nil, "", false, false, err
}
repo, err := a.Store.GetRepo(repoName)
if err != nil {
return nil, "", false, false, err
}
return gitexec.BranchDiff(repo.Path, mr.TargetBranch, mr.SourceBranch)
}
func (a *Admin) CheckMergeRequestMergeable(repoName string, number int) (gitexec.MergeResult, error) {
mr, err := a.Store.GetMergeRequest(repoName, number)
if err != nil {
return gitexec.MergeResult{}, err
}
repo, err := a.Store.GetRepo(repoName)
if err != nil {
return gitexec.MergeResult{}, err
}
return gitexec.CheckMergeable(repo.Path, mr.TargetBranch, mr.SourceBranch)
}
// MergeMergeRequest merges mr's source into its target with a real merge
// commit attributed to actor, and records the outcome on the stored
// MergeRequest. actor must already have been checked for write access by
// the caller — this only re-checks that the MR is still open. A conflicted
// merge is reported back (result.Clean == false) without changing the MR's
// status, so the requester can resolve locally and try again.
func (a *Admin) MergeMergeRequest(repoName string, number int, actor string) (store.MergeRequest, gitexec.MergeResult, error) {
mr, err := a.Store.GetMergeRequest(repoName, number)
if err != nil {
return store.MergeRequest{}, gitexec.MergeResult{}, err
}
if mr.Status != store.MROpen {
return mr, gitexec.MergeResult{}, fmt.Errorf("admin: merge request %s#%d is not open", repoName, number)
}
repo, err := a.Store.GetRepo(repoName)
if err != nil {
return mr, gitexec.MergeResult{}, err
}
authorName := actor
if username, _, ok := splitPrincipal(actor); ok {
authorName = username
}
msg := fmt.Sprintf("Merge branch '%s' into %s\n\n%s\n\n#%d", mr.SourceBranch, mr.TargetBranch, mr.Title, mr.Number)
commit, result, err := gitexec.MergeBranches(repo.Path, mr.TargetBranch, mr.SourceBranch, msg, authorName, actor)
if err != nil || !result.Clean {
return mr, result, err
}
mr.Status = store.MRMerged
mr.MergedBy = actor
mr.MergeCommit = commit
if err := a.Store.UpdateMergeRequest(mr); err != nil {
return mr, result, err
}
return mr, result, nil
}
func (a *Admin) CloseMergeRequest(repoName string, number int) error {
mr, err := a.Store.GetMergeRequest(repoName, number)
if err != nil {
return err
}
if mr.Status != store.MROpen {
// Already merged or already closed — a no-op, not an error, so a
// stale page (or a double-click) can't overwrite a "merged" record
// with "closed" and erase the fact that it actually landed.
return nil
}
mr.Status = store.MRClosed
return a.Store.UpdateMergeRequest(mr)
}
func (a *Admin) ListMRComments(repoName string, number int) ([]store.MRComment, error) {
return a.Store.ListMRComments(repoName, number)
}
func (a *Admin) AddMRComment(repoName string, number int, author, body string) (store.MRComment, error) {
comment, err := a.Store.AddMRComment(store.MRComment{Repo: repoName, Number: number, Author: author, Body: body})
if err != nil {
return comment, err
}
// Best-effort: let the MR's author know someone commented. Never fails
// the comment itself over a notification.
if mr, err := a.Store.GetMergeRequest(repoName, number); err == nil {
a.notifyMR(mr.Author, repoName, author, store.NotificationMRComment, number, mr.Title)
}
return comment, nil
}
// GrantCollaborator adds/updates a collaborator's role on a repo. If the
// principal belongs to a remote domain, it first resolves trust for that
// domain (§5.2/§6); for the whitelist policy this leaves the domain pending
// until an admin approves it, but the collaborator entry is still recorded.
// actor is who's granting it (a logged-in session's principal, or a
// synthetic value for TUI-driven grants) — used only to tell a remote
// collaborator's own instance who granted them access, see notifyGrant.
func (a *Admin) GrantCollaborator(repoName, principal, actor string, role store.Role) error {
_, domain, ok := splitPrincipal(principal)
if !ok {
return fmt.Errorf("admin: invalid principal %q, expected user@domain", principal)
}
if domain != a.Domain {
if _, err := a.Resolver.EnsureTrust(domain); err != nil {
return fmt.Errorf("admin: resolve trust for %q: %w", domain, err)
}
}
if err := acl.Grant(a.Store, repoName, principal, role); err != nil {
return err
}
if domain != a.Domain && a.CA != nil {
go a.notifyGrant(repoName, principal, domain, actor, role)
}
return nil
}
// notifyGrant tells principal's home instance about a grant it just
// received. Best-effort and asynchronous by design: the notification is
// purely advisory (see internal/federation/notify.go) — the grant itself
// already happened and is real regardless of whether this ever arrives,
// so nothing should block on it or surface its failure to the granter.
func (a *Admin) notifyGrant(repoName, principal, domain, actor string, role store.Role) {
err := federation.SendNotify(a.CA, a.Domain, domain, federation.NotifyPayload{
Principal: principal,
Repo: repoName,
Role: string(role),
Actor: actor,
}, a.Resolver.Insecure())
if err != nil {
log.Printf("admin: notify %s about grant on %s: %v", principal, repoName, err)
}
}
func (a *Admin) RevokeCollaborator(repoName, principal string) error {
return acl.Revoke(a.Store, repoName, principal)
}
func (a *Admin) ApproveDomain(domain string) error {
return a.Resolver.Approve(domain)
}
// PinRepo, UnpinRepo and ListPinnedRepos back a purely local bookmark list
// (store.PinnedRepo) — see its doc comment. There's no validation here that
// domain/repo actually exists or is reachable: pinning is just a note to
// self, and a bad one just produces a link that 404s.
func (a *Admin) PinRepo(principal, domain, repo, label string) error {
return a.Store.PinRepo(principal, domain, repo, label)
}
func (a *Admin) UnpinRepo(principal, domain, repo string) error {
return a.Store.UnpinRepo(principal, domain, repo)
}
func (a *Admin) ListPinnedRepos(principal string) ([]store.PinnedRepo, error) {
return a.Store.ListPinnedRepos(principal)
}
func (a *Admin) ListNotifications(principal string) ([]store.Notification, error) {
return a.Store.ListNotifications(principal)
}
func (a *Admin) CountPendingNotifications(principal string) (int, error) {
return a.Store.CountPendingNotifications(principal)
}
// AcceptNotification marks a pending notification accepted and, for a
// grant notification only, pins the repo it was about as a convenience —
// the whole point of accepting one of those is "yes, remember this for
// me." It grants no access: whether the underlying collaborator grant is
// still valid is checked independently, by the granting instance, whenever
// the repo is actually visited. For an mr_opened/mr_comment notification,
// "accept" just means "I've seen this" — there's nothing to pin, the MR
// itself is the thing to go look at.
func (a *Admin) AcceptNotification(principal, id string) error {
notifs, err := a.Store.ListNotifications(principal)
if err != nil {
return err
}
for _, n := range notifs {
if n.ID == id {
if n.EffectiveKind() == store.NotificationGrant {
if err := a.Store.PinRepo(principal, n.FromDomain, n.Repo, ""); err != nil {
return err
}
}
break
}
}
return a.Store.SetNotificationStatus(principal, id, store.NotificationAccepted)
}
func (a *Admin) DismissNotification(principal, id string) error {
return a.Store.SetNotificationStatus(principal, id, store.NotificationDismissed)
}
const (
minPasswordLength = 8
// bcryptCost is above the library default (10) — appropriate for 2026
// hardware and still well under a noticeable login delay.
bcryptCost = 12
)
// dummyHash is a valid bcrypt hash (of an unguessable value) at bcryptCost.
// VerifyPassword compares against it when a user or their credential doesn't
// exist, so a login attempt against a non-existent account takes the same
// time as one against a real account — closing the timing side channel that
// otherwise reveals which usernames exist.
var dummyHash = mustDummyHash()
func mustDummyHash() []byte {
h, err := bcrypt.GenerateFromPassword([]byte("gitfed-nonexistent-account-sentinel"), bcryptCost)
if err != nil {
panic(err)
}
return h
}
// SetPassword hashes and stores newPassword for username, replacing any
// existing one. Used both for an admin resetting someone's password and for
// a user changing their own (the web layer is responsible for requiring the
// current password in the latter case — this call itself doesn't check).
func (a *Admin) SetPassword(username, newPassword string) error {
if len(newPassword) < minPasswordLength {
return fmt.Errorf("admin: password must be at least %d characters", minPasswordLength)
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return fmt.Errorf("admin: hash password: %w", err)
}
return a.Store.SetPasswordHash(username, string(hash))
}
// VerifyPassword reports whether password matches username's stored hash.
// ok is false (with a nil error) for a wrong password or a user with no
// password set yet — both are normal login-form outcomes, not failures.
// It always performs one bcrypt comparison, even when the account or hash is
// missing, to keep the response time independent of whether the user exists.
func (a *Admin) VerifyPassword(username, password string) (isAdmin bool, ok bool, err error) {
user, err := a.Store.GetUser(username)
if err != nil && err != store.ErrNotFound {
return false, false, err
}
userExists := err == nil
hash, err := a.Store.GetPasswordHash(username)
if err != nil && err != store.ErrNotFound {
return false, false, err
}
compareAgainst := []byte(hash)
if !userExists || hash == "" {
compareAgainst = dummyHash
}
match := bcrypt.CompareHashAndPassword(compareAgainst, []byte(password)) == nil
if !userExists || hash == "" || !match {
return false, false, nil
}
return user.IsAdmin, true, nil
}
const sessionTTL = 7 * 24 * time.Hour
func (a *Admin) CreateSession(principal, username string, isAdmin bool) (string, error) {
return a.Store.CreateSession(principal, username, isAdmin, sessionTTL)
}
func (a *Admin) GetSession(token string) (store.Session, error) {
return a.Store.GetSession(token)
}
func (a *Admin) DeleteSession(token string) error {
return a.Store.DeleteSession(token)
}
// CheckAccess is the self-service-safe way to ask "can principal do want on
// repoName" without handing out full Ops access — the web layer uses this
// to authorize actions instead of trusting client-supplied ownership.
func (a *Admin) CheckAccess(repoName, principal string, want store.Role) (store.Role, bool, error) {
return acl.Check(a.Store, repoName, principal, want)
}
func splitPrincipal(principal string) (username, domain string, ok bool) {
i := strings.LastIndex(principal, "@")
if i <= 0 || i == len(principal)-1 {
return "", "", false
}
return principal[:i], principal[i+1:], true
}