Files
anchorage/internal/cmd/minttoken.go
William Gill 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

259 lines
8.9 KiB
Go

package cmd
import (
"errors"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"anchorage/internal/pkg/config"
"anchorage/internal/pkg/ids"
"anchorage/internal/pkg/token"
)
// DefaultIPFSClientTTL is the ceiling for IPFS-client tokens: 1 year
// plus a 30-day rotation grace window. That matches the config-level
// auth.apiToken.maxTTL default so `mint-token` can pick this TTL
// without tripping the signer's max-TTL guard (the signer doesn't
// currently enforce a ceiling, but this keeps the numbers honest).
const DefaultIPFSClientTTL = 9480 * time.Hour
// newMintTokenCmd mints a standalone JWT off the configured signing
// key, printing the signed string to stdout. It does NOT talk to a
// running anchorage — it reads the key off disk directly, so it works
// before any OIDC user exists.
//
// Primary use case: handing an IPFS client a bearer token.
//
// anchorage admin mint-token --role sysadmin > /etc/anchorage/client.jwt
// ipfs pin remote service add anchor https://anchor.example.com/v1 $(cat /etc/anchorage/client.jwt)
// ipfs pin remote add --service=anchor bafybeig...
//
// A minted token bypasses the active-token / denylist caches because
// it was never registered through POST /v1/tokens — this is deliberate.
// Operators who need revocation should write the jti to the denylist
// via the /v1/tokens API or rotate the signing key.
func newMintTokenCmd(flags *globalFlags) *cobra.Command {
var (
role string
orgArg string
userArg string
ttl time.Duration
scopes []string
label string
issuer string
audience string
keyPath string
kidFlag string
verbose bool
)
c := &cobra.Command{
Use: "mint-token",
Short: "Mint a standalone JWT for CLI / IPFS-client usage",
Long: `Mints a signed JWT locally from the configured signing key and
prints it to stdout. Suitable for break-glass admin access, for
providing a long-lived bearer token to 'ipfs pin remote', or for
scripts / CI pipelines that need to call anchorage without going
through the interactive Authentik login.
The default role is 'sysadmin' — which enables /v1/admin/* endpoints
(drain, uncordon, maintenance). Downgrade to 'orgadmin' or 'member'
when handing a token to a specific org.
The default TTL is ` + DefaultIPFSClientTTL.String() + ` (1 year + 30-day rotation
grace) to match the apiToken.maxTTL ceiling. Shorten it with --ttl
for short-lived tokens:
anchorage admin mint-token --role member --ttl 24h --org org_...
Minted tokens DO NOT appear in 'list my tokens' (they aren't
registered via POST /v1/tokens) and cannot be revoked individually
except by denylisting the jti or rotating the signing key.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(config.LoadOptions{Path: flags.configPath, AllowMissing: true})
if err != nil {
return fmt.Errorf("load config: %w", err)
}
// Defaults: reach into the config's primary signingKey entry
// so operators who run mint-token alongside their configured
// server get correct key path + kid automatically.
primary, havePrimary := primarySigningKey(cfg)
if keyPath == "" && havePrimary {
keyPath = primary.Path
}
if kidFlag == "" && havePrimary {
kidFlag = primary.ID
}
if issuer == "" {
issuer = cfg.Auth.Authentik.Issuer
}
if audience == "" {
audience = cfg.Auth.Authentik.Audience
}
if ttl <= 0 {
ttl = DefaultIPFSClientTTL
}
if !validRole(role) {
return fmt.Errorf("bad --role %q: must be sysadmin, orgadmin, or member", role)
}
// Pick between a real key file and the built-in dev key.
var key []byte
if keyPath != "" {
k, err := loadSingleKey(keyPath)
if err != nil {
return err
}
key = k
} else {
key = []byte(token.DevOnlySigningKey)
if kidFlag == "" {
kidFlag = token.DevKeyID
}
}
if kidFlag == "" {
return fmt.Errorf("--kid is required (or configure auth.apiToken.signingKeys in anchorage.yaml)")
}
// nil TokenStore: standalone mint, no denylist check on verify
// and no api_tokens row written. Standalone by design.
signer, err := token.NewSigner([]token.SigningKey{
{ID: kidFlag, Key: key, Primary: true},
}, issuer, audience, nil)
if err != nil {
return fmt.Errorf("signer: %w", err)
}
orgID, err := resolveOrg(orgArg, role)
if err != nil {
return err
}
userID, err := resolveUser(userArg)
if err != nil {
return err
}
jwt, claims, err := signer.Mint(cmd.Context(), orgID, userID, role, scopes, ttl)
if err != nil {
return fmt.Errorf("mint: %w", err)
}
// A token with empty issuer / audience will fail the running
// anchorage's Parse() check (enforced via jwt.WithIssuer /
// jwt.WithAudience). Warn loudly so an operator doesn't
// silently mint a dud.
if issuer == "" || audience == "" {
fmt.Fprintln(cmd.ErrOrStderr(),
"warning: issuer or audience is empty — this token will NOT verify against a running anchorage "+
"that has auth.authentik.issuer / audience set in its config. Re-run with --issuer and "+
"--audience matching the server's config, or populate them via anchorage.yaml.")
}
// Primary output — just the token, so shell piping works:
// TOKEN=$(anchorage admin mint-token)
fmt.Fprintln(cmd.OutOrStdout(), jwt)
if verbose {
// Metadata to stderr so it doesn't contaminate piped output.
fmt.Fprintf(cmd.ErrOrStderr(),
"# jti %s\n"+
"# role %s\n"+
"# org %s\n"+
"# user %s\n"+
"# issuer %s\n"+
"# aud %s\n"+
"# expires %s\n"+
"# label %s\n",
claims.ID, claims.Role, claims.Org, claims.User,
claims.Issuer, strings.Join([]string(claims.Audience), ","),
claims.ExpiresAt.Time.Format(time.RFC3339), label)
}
return nil
},
}
c.Flags().StringVar(&role, "role", "sysadmin", "role: sysadmin | orgadmin | member")
c.Flags().StringVar(&orgArg, "org", "", "org TypeID (auto-generated if empty; not required for sysadmin)")
c.Flags().StringVar(&userArg, "user", "", "user TypeID (auto-generated if empty)")
c.Flags().DurationVar(&ttl, "ttl", DefaultIPFSClientTTL, "token lifetime (max config.auth.apiToken.maxTTL)")
c.Flags().StringSliceVar(&scopes, "scope", nil, "scope (repeatable): pin:read, pin:write, ...")
c.Flags().StringVar(&label, "label", "cli-mint", "free-form label (emitted in --verbose output)")
c.Flags().StringVar(&issuer, "issuer", "", "override JWT issuer (default: config auth.authentik.issuer)")
c.Flags().StringVar(&audience, "audience", "", "override JWT audience (default: config auth.authentik.audience)")
c.Flags().StringVar(&kidFlag, "kid", "", "key id to stamp into the JWT `kid` header (default: the primary entry in auth.apiToken.signingKeys; "+token.DevKeyID+" when no keys are configured)")
c.Flags().StringVar(&keyPath, "signing-key", "", "override signing key path (default: the primary entry in auth.apiToken.signingKeys; built-in dev key when none configured)")
c.Flags().BoolVarP(&verbose, "verbose", "v", false, "print jti + expiry + claims to stderr")
return c
}
// validRole mirrors the roles that actually mean something to the
// authz middleware. Keeping this gated at mint time avoids the
// confusing runtime path where a token with role="admin" (typo) is
// silently demoted to unauthenticated.
func validRole(r string) bool {
switch r {
case "sysadmin", "orgadmin", "member":
return true
}
return false
}
// primarySigningKey picks the entry flagged primary from the config.
// Returns (zero, false) when no keys are configured — mint-token falls
// back to the built-in dev key in that case.
func primarySigningKey(cfg *config.Config) (config.SigningKeyConfig, bool) {
for _, k := range cfg.Auth.APIToken.SigningKeys {
if k.Primary {
return k, true
}
}
return config.SigningKeyConfig{}, false
}
// loadSingleKey is the mint-token-only wrapper over token.LoadSigningKeys
// that expects exactly one file. Kept local so mint-token doesn't have
// to construct a full rotation spec for the single-key case.
func loadSingleKey(path string) ([]byte, error) {
keys, err := token.LoadSigningKeys([]token.KeyFileSpec{
{ID: "mint-one-shot", Path: path, Primary: true},
})
if err != nil {
return nil, err
}
return keys[0].Key, nil
}
func resolveOrg(s, role string) (ids.OrgID, error) {
if s == "" {
if role == "sysadmin" {
// Sysadmin tokens don't need a real org; admin endpoints
// use the role, not the org. Mint a fresh one so the JWT
// is well-formed.
id, err := ids.NewOrg()
if err != nil {
return ids.OrgID{}, fmt.Errorf("generate org id: %w", err)
}
return id, nil
}
return ids.OrgID{}, errors.New("--org is required for non-sysadmin roles")
}
return ids.ParseOrg(s)
}
func resolveUser(s string) (ids.UserID, error) {
if s == "" {
id, err := ids.NewUser()
if err != nil {
return ids.UserID{}, fmt.Errorf("generate user id: %w", err)
}
return id, nil
}
return ids.ParseUser(s)
}