gitmote

gitmote / internal / bootstrap / bootstrap.go  ·  history  ·  branches

4655 bytes · raw

// Package bootstrap takes an empty instance to a usable one: the first admin
// user and a token to authenticate as them — per docs/notes/bootstrap.md. The
// schema itself is created by meta.Open; this fills in the day-one rows that
// token auth otherwise makes a chicken-and-egg. Repos are created later in the
// UI, so bootstrap no longer needs one (it stays optional for scripts/tests).
package bootstrap

import (
	"context"
	"errors"
	"fmt"

	"github.com/atmin/gitmote/internal/auth"
	"github.com/atmin/gitmote/internal/meta"
)

// DefaultAdminHandle is the first admin's handle when none is given — so a plain
// `docker run` needs no extra input.
const DefaultAdminHandle = "admin"

// Options parameterize a bootstrap.
type Options struct {
	// AdminHandle is the first global admin's handle; empty means DefaultAdminHandle.
	AdminHandle string
	// RepoName is an optional initial repo, e.g. "gitmote"; empty creates none
	// (repos are made in the UI).
	RepoName string
	// DefaultBranch for the initial repo; empty means "main". Ignored without a repo.
	DefaultBranch string
	// TokenLabel labels the minted admin token; empty means "bootstrap".
	TokenLabel string
}

// Result reports what a bootstrap did. When AlreadyBootstrapped is true, the
// instance already had an admin and nothing was changed; RawToken is empty.
type Result struct {
	AlreadyBootstrapped bool
	Admin               *meta.User
	// Repo is the initial repo when one was requested, else nil.
	Repo *meta.Repo
	// RawToken is the admin's personal access token, shown exactly once. It is
	// never recoverable afterward.
	RawToken string
}

// Run bootstraps md. It refuses to clobber an existing admin: if any global
// admin already exists it returns a Result with AlreadyBootstrapped set and
// makes no changes, so re-running is safe. Otherwise it creates the admin, mints
// a token, and — only when opts.RepoName is set — the initial repo with a
// repo-admin ACL.
func Run(ctx context.Context, md *meta.Metadata, opts Options) (*Result, error) {
	handle := opts.AdminHandle
	if handle == "" {
		handle = DefaultAdminHandle
	}

	exists, err := md.AdminExists(ctx)
	if err != nil {
		return nil, err
	}
	if exists {
		return &Result{AlreadyBootstrapped: true}, nil
	}

	admin, err := md.CreateAdmin(ctx, handle)
	if err != nil {
		return nil, fmt.Errorf("bootstrap: create admin: %w", err)
	}

	raw, err := mintToken(ctx, md, admin.ID, opts.TokenLabel)
	if err != nil {
		return nil, err
	}

	res := &Result{Admin: admin, RawToken: raw}
	if opts.RepoName != "" {
		repo, err := md.CreateRepo(ctx, opts.RepoName, opts.DefaultBranch)
		if err != nil {
			return nil, fmt.Errorf("bootstrap: create repo: %w", err)
		}
		if err := md.SetACL(ctx, repo.ID, admin.ID, meta.PermAdmin); err != nil {
			return nil, fmt.Errorf("bootstrap: grant admin acl: %w", err)
		}
		res.Repo = repo
	}
	return res, nil
}

// Reissue mints a fresh token for the existing admin (identified by handle, or
// DefaultAdminHandle) and returns it once — the recovery path for a lost token.
// Whoever can run the container against the bucket already has total infra
// control, so an out-of-band re-mint is the correct authz boundary. It requires
// an existing admin (run bootstrap first) and the writer lease (run it while the
// server is idle/stopped). No token is ever stored at rest — only its hash.
func Reissue(ctx context.Context, md *meta.Metadata, opts Options) (*Result, error) {
	handle := opts.AdminHandle
	if handle == "" {
		handle = DefaultAdminHandle
	}

	user, err := md.GetUser(ctx, handle)
	if errors.Is(err, meta.ErrNotFound) {
		return nil, fmt.Errorf("bootstrap: no user %q to reissue for; run bootstrap first", handle)
	}
	if err != nil {
		return nil, err
	}
	if !user.IsAdmin {
		return nil, fmt.Errorf("bootstrap: user %q is not an admin", handle)
	}

	label := opts.TokenLabel
	if label == "" {
		label = "bootstrap (reissued)"
	}
	raw, err := mintToken(ctx, md, user.ID, label)
	if err != nil {
		return nil, err
	}
	return &Result{Admin: user, RawToken: raw}, nil
}

// mintToken mints a personal access token for userID and stores only its hash,
// returning the raw token (shown once). label defaults to "bootstrap".
func mintToken(ctx context.Context, md *meta.Metadata, userID int64, label string) (string, error) {
	if label == "" {
		label = "bootstrap"
	}
	raw, selector, verifier, err := auth.Mint()
	if err != nil {
		return "", fmt.Errorf("bootstrap: mint token: %w", err)
	}
	if _, err := md.CreateToken(ctx, userID, selector, verifier, label); err != nil {
		return "", fmt.Errorf("bootstrap: store token: %w", err)
	}
	return raw, nil
}