package main import ( "context" "errors" "fmt" "os" "strconv" "text/tabwriter" "time" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) const tokenUsage = "usage: specsrht token create | specsrht token list | specsrht token revoke " // runToken is the agent-token administration command: // // specsrht token create // specsrht token list // specsrht token revoke // // Agent tokens have no other entry point, and without one a freshly deployed // instance cannot be written to at all: both agent write surfaces — the REST // PUT and mcpsrv's spec_propose — refuse an anonymous caller, and the human // path (native receive-pack) is approval rather than proposal. The alternative // to this command is an operator hand-writing an INSERT with a sha256 hash, // which is exactly the shape of mistake that ends with an unusable credential // and no way to tell why. // // It goes through service/ rather than db/ directly, and constructs the owner // principal to do so. The owner-only rule on minting is the one that makes // revocation mean anything — an agent that could mint would survive having its // credential revoked — so it is spelled once, in service/, and this command is // held to it exactly as the /tokens page is. A process on this host could of // course write the row itself; the point is not to confine it but to keep one // implementation of what issuing a token *is*. func runToken(args []string) error { if len(args) == 0 { return errors.New(tokenUsage) } conf := config.LoadConfig() cfg, err := validateConfig(conf) if err != nil { return err } pool, err := openDatabase(cfg.ConnectionString) if err != nil { return err } defer pool.Close() svc, err := service.New(cfg, pool) if err != nil { return err } owner := authn.Principal{Kind: authn.KindOwner, Owner: cfg.Instance.OwnerName} ctx := context.Background() switch args[0] { case "create": if len(args) != 2 { return errors.New("usage: specsrht token create ") } name := args[1] // The plaintext is returned by the mint and printed once. Nothing writes // it to the log, because a token in a log file is a token in a backup. token, row, err := svc.IssueAgentToken(ctx, owner, name) if err != nil { return err } fmt.Printf("created agent token %q (id %d)\n\n %s\n\n"+ "This is the only time the token is shown — only its hash is stored.\n"+ "Agents present it as: Authorization: Bearer \n", row.Name, row.ID, token) return nil case "list": tokens, err := svc.ListAgentTokens(ctx, owner) if err != nil { return err } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tNAME\tCREATED\tSTATE") for _, t := range tokens { fmt.Fprintln(w, formatTokenRow(t)) } return w.Flush() case "revoke": if len(args) != 2 { return errors.New("usage: specsrht token revoke ") } id, err := parseTokenID(args[1]) if err != nil { return err } if err := svc.RevokeAgentToken(ctx, owner, id); err != nil { return err } fmt.Printf("revoked agent token %d\n", id) return nil default: return fmt.Errorf("unknown subcommand %q: want create, list or revoke", args[0]) } } // formatTokenRow renders one token as a tab-separated line for `token list`. // The hash is deliberately not shown: it identifies nothing an operator acts // on, and printing a column of it would only invite treating it as the // credential. func formatTokenRow(t service.AgentToken) string { state := "active" if !t.Active() { state = "revoked " + t.Revoked.Format(time.RFC3339) } return fmt.Sprintf("%d\t%s\t%s\t%s", t.ID, t.Name, t.Created.Format(time.RFC3339), state) } // parseTokenID reads the id argument of `token revoke`. It rejects anything // that is not a positive integer here rather than letting a typo become an // UPDATE that matches no row and reports "not found", which reads like the // token is already gone. func parseTokenID(s string) (int, error) { id, err := strconv.Atoi(s) if err != nil || id <= 0 { return 0, fmt.Errorf("token id %q is not a positive integer; `specsrht token list` shows the ids", s) } return id, nil }