dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall: Gitea's own token-creation and admin-user endpoints reject token auth outright -- POST /users/{u}/tokens returns 'auth required', POST /admin/users returns 'token does not have at least one of required scope(s), required=[write:admin]' -- for ANY existing token, by design. An existing token cannot mint another token or create an account; the only way through either endpoint is real basic auth as an actual site admin. That is exactly what a sandboxed agent session cannot safely hold. internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable against a fake transport (5 tests, no network). Given real admin username/password, it creates the target account (or resets its password if it already exists -- deliberately unconditional, gated behind a required ConfirmServiceAccount flag since that reset is only safe for a dedicated bot account), mints it a scoped token by authenticating AS that account (Gitea requires this specifically -- an admin cannot mint on another account's behalf), grants it read-only collaborator access on named repos, and stores the result via a SecretStore -- never returning the token or either password to the caller. internal/localtools: the MCP tool wrapper (mint_service_account_token) and the real op-CLI-backed SecretStore (token passed via stdin, never argv, per the estate's own secret-handling rule). Deliberately its OWN package, not internal/tools -- this capability is far too dangerous to risk it ever being wired into RegisterAll and reachable from the shared HTTP gitea-mcp server, even behind OIDC. internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line, standard MCP stdio framing) for the existing registry.Tool interface -- the only transport gitea-mcp had before this was HTTP (server.go), which a sandboxed session can reach but a real admin password never should be piped through. cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code session (.mcp.json stdio server) or run by hand, via 'op run --env-file ~/.op-env' so admin credentials arrive just-in-time and never touch a sandboxed session. 11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the shared HTTP server) changed.
259 lines
9.7 KiB
Go
259 lines
9.7 KiB
Go
package adminbootstrap_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// This exists to close dispatch#67 fix #2's blocker: minting a scoped Gitea
|
|
// token needs basic auth (POST /users/{u}/tokens and POST /admin/users both
|
|
// reject token auth outright, confirmed live against git.d-ma.be — an
|
|
// existing token cannot mint another token, by design). A sandboxed agent
|
|
// session has no interactive password, so this package is meant to run
|
|
// locally, where a real admin password is available just-in-time (1Password,
|
|
// `op run`), and to do the whole bootstrap in one auditable call rather than
|
|
// leaving it as ad hoc curl.
|
|
//
|
|
// Design choice, made explicit rather than silently assumed: this tool
|
|
// ALWAYS (re)sets the target account's password via admin basic auth before
|
|
// minting, whether the account is new or already existed. That is safe and
|
|
// expected for a dedicated service/bot account (its password is a mechanism
|
|
// to reach the token endpoint, never a human login credential) and would be
|
|
// dangerous pointed at a real person's account by mistake -- which is why
|
|
// Request.ConfirmServiceAccount exists and Run refuses to proceed without it.
|
|
|
|
type call struct {
|
|
method, path string
|
|
body map[string]any
|
|
authUser string // basic-auth username used for this call, "" if token/none
|
|
authPass string
|
|
}
|
|
|
|
type fakeTransport struct {
|
|
t *testing.T
|
|
responses map[string]fakeResponse // key: "METHOD path"
|
|
calls []call
|
|
}
|
|
|
|
type fakeResponse struct {
|
|
status int
|
|
body string
|
|
}
|
|
|
|
func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
var body map[string]any
|
|
if req.Body != nil {
|
|
raw, _ := io.ReadAll(req.Body)
|
|
if len(raw) > 0 {
|
|
require.NoError(f.t, json.Unmarshal(raw, &body))
|
|
}
|
|
}
|
|
user, pass, _ := req.BasicAuth()
|
|
f.calls = append(f.calls, call{method: req.Method, path: req.URL.Path, body: body, authUser: user, authPass: pass})
|
|
|
|
key := req.Method + " " + req.URL.Path
|
|
r, ok := f.responses[key]
|
|
if !ok {
|
|
f.t.Fatalf("unscripted request: %s (have: %v)", key, keys(f.responses))
|
|
}
|
|
return &http.Response{
|
|
StatusCode: r.status,
|
|
Body: io.NopCloser(strings.NewReader(r.body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
|
|
func keys(m map[string]fakeResponse) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fakeSecretStore records what would have been written to 1Password, without
|
|
// ever touching it -- and, critically, asserts the token value itself never
|
|
// leaks into anything but this one call.
|
|
type fakeSecretStore struct {
|
|
stored map[string]string
|
|
}
|
|
|
|
func (f *fakeSecretStore) Store(_ context.Context, name, value string) (string, error) {
|
|
if f.stored == nil {
|
|
f.stored = map[string]string{}
|
|
}
|
|
f.stored[name] = value
|
|
return "op://AgentSecrets/" + name + "/token", nil
|
|
}
|
|
|
|
func newBootstrapper(t *testing.T, responses map[string]fakeResponse) (adminbootstrap.Bootstrapper, *fakeTransport, *fakeSecretStore) {
|
|
ft := &fakeTransport{t: t, responses: responses}
|
|
fs := &fakeSecretStore{}
|
|
return adminbootstrap.Bootstrapper{
|
|
BaseURL: "http://fake.gitea",
|
|
AdminUsername: "mathias",
|
|
AdminPassword: "admin-pass-not-a-real-secret",
|
|
HTTP: &http.Client{Transport: ft},
|
|
Secrets: fs,
|
|
}, ft, fs
|
|
}
|
|
|
|
func TestRun_CreatesNewAccountMintsTokenAndStoresIt(t *testing.T) {
|
|
b, ft, fs := newBootstrapper(t, map[string]fakeResponse{
|
|
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
|
"POST /api/v1/admin/users": {status: 201, body: `{"login":"dispatch-bot"}`},
|
|
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_abc123"}`},
|
|
})
|
|
|
|
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
|
Username: "dispatch-bot",
|
|
Email: "dispatch@d-ma.be",
|
|
Scopes: []string{"read:repository", "read:issue"},
|
|
TokenName: "dispatch-ci-readonly",
|
|
SecretName: "gitea-dispatch-bot-ci-readonly",
|
|
ConfirmServiceAccount: true,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Created)
|
|
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result.SecretRef)
|
|
assert.Equal(t, "tok_abc123", fs.stored["gitea-dispatch-bot-ci-readonly"])
|
|
|
|
// The account-creation call must use the ADMIN's basic auth, never the
|
|
// fresh account's own (unknown at that point) password.
|
|
create := findCall(t, ft.calls, "POST", "/api/v1/admin/users")
|
|
assert.Equal(t, "mathias", create.authUser)
|
|
assert.Equal(t, "dispatch-bot", create.body["username"])
|
|
assert.Equal(t, "dispatch@d-ma.be", create.body["email"])
|
|
assert.Equal(t, false, create.body["must_change_password"])
|
|
|
|
// The token-mint call must use basic auth AS THE NEW ACCOUNT -- Gitea
|
|
// requires this specifically (an admin token cannot mint on someone
|
|
// else's behalf) -- with the password this package generated and set,
|
|
// never the admin's own password.
|
|
mint := findCall(t, ft.calls, "POST", "/api/v1/users/dispatch-bot/tokens")
|
|
assert.Equal(t, "dispatch-bot", mint.authUser)
|
|
assert.NotEqual(t, "admin-pass-not-a-real-secret", mint.authPass)
|
|
assert.NotEmpty(t, mint.authPass)
|
|
assert.Equal(t, "dispatch-ci-readonly", mint.body["name"])
|
|
assert.ElementsMatch(t, []any{"read:repository", "read:issue"}, mint.body["scopes"])
|
|
}
|
|
|
|
func TestRun_ExistingAccountGetsPasswordResetNotRecreated(t *testing.T) {
|
|
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
|
|
"GET /api/v1/users/dispatch-bot": {status: 200, body: `{"login":"dispatch-bot"}`},
|
|
"PATCH /api/v1/admin/users/dispatch-bot": {status: 200, body: `{}`},
|
|
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_new"}`},
|
|
})
|
|
|
|
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
|
Username: "dispatch-bot",
|
|
Email: "dispatch@d-ma.be",
|
|
Scopes: []string{"read:repository"},
|
|
TokenName: "dispatch-ci-readonly",
|
|
SecretName: "gitea-dispatch-bot-ci-readonly",
|
|
ConfirmServiceAccount: true,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.False(t, result.Created, "must report the account as pre-existing, not newly created")
|
|
|
|
// Must PATCH (reset password), never POST (create) for an existing account.
|
|
assert.Empty(t, filterCalls(ft.calls, "POST", "/api/v1/admin/users"))
|
|
patch := findCall(t, ft.calls, "PATCH", "/api/v1/admin/users/dispatch-bot")
|
|
assert.Equal(t, "mathias", patch.authUser, "the reset must be done as the real admin")
|
|
assert.NotEmpty(t, patch.body["password"])
|
|
}
|
|
|
|
func TestRun_RefusesWithoutExplicitServiceAccountConfirmation(t *testing.T) {
|
|
// Guard against pointing this at a real person's account by accident --
|
|
// this tool resets whatever password the target account has.
|
|
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{})
|
|
|
|
_, err := b.Run(context.Background(), adminbootstrap.Request{
|
|
Username: "mathias",
|
|
Email: "mthbqv@gmail.com",
|
|
Scopes: []string{"read:repository"},
|
|
TokenName: "whatever",
|
|
SecretName: "whatever",
|
|
// ConfirmServiceAccount left false/unset
|
|
})
|
|
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "ConfirmServiceAccount")
|
|
assert.Empty(t, ft.calls, "must not make any request at all without confirmation")
|
|
}
|
|
|
|
func TestRun_GrantsReadOnlyCollaboratorAccessViaAdminAuth(t *testing.T) {
|
|
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
|
|
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
|
"POST /api/v1/admin/users": {status: 201, body: `{}`},
|
|
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok"}`},
|
|
"PUT /api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot": {status: 204, body: ``},
|
|
})
|
|
|
|
result, err := b.Run(context.Background(), adminbootstrap.Request{
|
|
Username: "dispatch-bot",
|
|
Email: "dispatch@d-ma.be",
|
|
Scopes: []string{"read:repository"},
|
|
TokenName: "dispatch-ci-readonly",
|
|
SecretName: "gitea-dispatch-bot-ci-readonly",
|
|
GrantReadRepos: []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}},
|
|
ConfirmServiceAccount: true,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"mathias/cobalt-dingo"}, result.GrantedRepos)
|
|
grant := findCall(t, ft.calls, "PUT", "/api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot")
|
|
assert.Equal(t, "mathias", grant.authUser, "granting collaborator access is an admin action, not the new account's own")
|
|
assert.Equal(t, "read", grant.body["permission"])
|
|
}
|
|
|
|
func TestRun_FailsClosedOnUpstreamError(t *testing.T) {
|
|
b, _, fs := newBootstrapper(t, map[string]fakeResponse{
|
|
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
|
|
"POST /api/v1/admin/users": {status: 500, body: `{"message":"internal error"}`},
|
|
})
|
|
|
|
_, err := b.Run(context.Background(), adminbootstrap.Request{
|
|
Username: "dispatch-bot",
|
|
Email: "dispatch@d-ma.be",
|
|
Scopes: []string{"read:repository"},
|
|
TokenName: "x",
|
|
SecretName: "x",
|
|
ConfirmServiceAccount: true,
|
|
})
|
|
|
|
require.Error(t, err)
|
|
assert.Empty(t, fs.stored, "nothing must be written to the secret store if the flow didn't complete")
|
|
}
|
|
|
|
func findCall(t *testing.T, calls []call, method, path string) call {
|
|
t.Helper()
|
|
for _, c := range calls {
|
|
if c.method == method && c.path == path {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("no call found: %s %s (have: %+v)", method, path, calls)
|
|
return call{}
|
|
}
|
|
|
|
func filterCalls(calls []call, method, path string) []call {
|
|
var out []call
|
|
for _, c := range calls {
|
|
if c.method == method && c.path == path {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|