133 lines
7.7 KiB
Go
133 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"textmachine/platform/internal/auth"
|
|
"textmachine/platform/internal/config"
|
|
"textmachine/platform/internal/login"
|
|
"textmachine/platform/internal/pgstore"
|
|
)
|
|
|
|
// token.go: the door for a client that is not a browser.
|
|
//
|
|
// # What was wrong and what this fixes
|
|
//
|
|
// The contract has declared a `bearerToken` scheme since its first version and the server has always
|
|
// accepted one — the principal is established in middleware from either presentation
|
|
// (internal/auth/middleware.go, `Present`) — but NOTHING issued one, and the canon said so about
|
|
// itself: «Nothing issues such a token today». So a CLI, a desktop client, an integration or a smoke
|
|
// test of a production host could not get in at all, and the only door that worked was
|
|
// `/auth/dev-login`, which a production configuration REFUSES to start with (config.Load). That is
|
|
// how a cycle came to be proved live through a mechanism production does not have (unified backlog
|
|
// row 270).
|
|
//
|
|
// # Why an admin command and not an endpoint
|
|
//
|
|
// The fork was: mint a token from an HTTP call, or mint it from the operator's own tool. This is the
|
|
// second, and the argument is that it is the SMALLEST thing that closes the hole:
|
|
//
|
|
// - It adds no route, so the ratified surface does not move and no client has to be taught
|
|
// anything: the token works on every `/v0` route the moment it exists, through the scheme the
|
|
// canon already declares.
|
|
// - It grants no authority that did not exist. This tool already writes the credit ledger and
|
|
// revokes sessions; an operator who can run it can already do anything to any account. A token
|
|
// endpoint, by contrast, would be new authority reachable over the network.
|
|
// - It matches how the beta actually operates. There is no self-service anything here: an account
|
|
// is credited BY HAND (`grant`, PD-104, owner 16.08), a book is added by hand, quotas do not
|
|
// exist. A token handed out by hand is the same shape as the rest of the beta.
|
|
// - The browser-facing half — a user pressing "create an API token" — needs a frontend to press
|
|
// it, and the frontend is frozen. Building the endpoint now would land a route nothing calls,
|
|
// into a canon that would have to declare it, for a user who cannot reach it.
|
|
//
|
|
// # What it deliberately does NOT do
|
|
//
|
|
// It does not invent a second kind of credential. What it mints is an ORDINARY session: the same
|
|
// 256-bit opaque token, the same digest-only storage, the same two clocks, the same immediate
|
|
// revocation (`revoke --user`), the same row in the login journal. A "long-lived API key" would be a
|
|
// second session model with a second expiry policy and a second revocation path — and the reason to
|
|
// avoid that is not tidiness: it is that the first thing anyone forgets to build for a second
|
|
// credential model is the revocation.
|
|
//
|
|
// ⚠ THE PLAINTEXT IS PRINTED ONCE AND IS NEVER RECOVERABLE. The database holds only sha256 of it
|
|
// (auth.Digest), which is the property that makes a stolen dump useless; the cost of that property is
|
|
// that a lost token is re-issued rather than looked up.
|
|
|
|
// issueToken is `tmplatformctl token issue`.
|
|
func issueToken(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
|
|
fs := flag.NewFlagSet("token issue", flag.ContinueOnError)
|
|
user := fs.String("user", "", "account id the token signs in as")
|
|
client := fs.String("client", "cli", "what this token is for, as the login journal will show it")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *user == "" {
|
|
return errors.New("token issue needs --user")
|
|
}
|
|
// The SAME clocks the browser's session gets, read from the SAME configuration the daemon reads.
|
|
// Not a knob of this command, and that is the point: a token minted here with a private expiry
|
|
// policy would be a session the deployment's own settings do not describe.
|
|
idle, maxAge, err := config.SessionTTLs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The journal renders this straight into a column that until now held one of four fixed classes,
|
|
// so it is bounded here rather than trusted.
|
|
if len(*client) > 32 || strings.IndexFunc(*client, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' }) >= 0 {
|
|
return errors.New("token issue: --client must be up to 32 letters, digits, '-' or '_' — it is a word in the account's own audit journal")
|
|
}
|
|
now := time.Now().UTC()
|
|
token := auth.NewToken()
|
|
if err := store.CreateSession(ctx, auth.Digest(token), *user, now, idle, maxAge); err != nil {
|
|
// A user id that does not exist arrives here as the foreign key's refusal, which is the
|
|
// answer we want: a session for an account that is not there would authenticate as nobody.
|
|
return err
|
|
}
|
|
// ⚠ THE TOKEN IS PRINTED FIRST, BEFORE ANYTHING ELSE CAN REACH THIS STREAM, and the order is the
|
|
// contract: the first line of stdout IS the credential, which is how a script captures it. An
|
|
// earlier version wrote the journal-failure warning before it, so on exactly the path where
|
|
// something had already gone wrong the caller would have captured that warning as their token.
|
|
//
|
|
// It goes to this stream and NOWHERE else: not to the log, not to the journal, not to an error
|
|
// (ENGINEERING_STANDARDS §2, "плейнтекст-токен не персистится, не логируется").
|
|
_, _ = fmt.Fprintf(out, "%s\n", token)
|
|
_, _ = fmt.Fprintf(out, "# account %s · idle %s · absolute %s (until %s)\n",
|
|
*user, idle, maxAge, now.Add(maxAge).Format(time.RFC3339))
|
|
// ⚠ The clocks above are the ones THIS PROCESS's environment resolves, and they are printed rather
|
|
// than assumed for that reason: run the command with the deployment's own environment (the same
|
|
// `EnvironmentFile=` the unit uses), or the token gets the defaults while the deployment runs a
|
|
// different policy. Printing them makes the mismatch visible instead of silent.
|
|
_, _ = fmt.Fprintf(out, "# send it as: Authorization: Bearer <token>\n")
|
|
_, _ = fmt.Fprintf(out, "# it is shown ONCE — only its digest is stored; revoke with: tmplatformctl revoke --user %s\n", *user)
|
|
// The journal is what answers "was that me" — the same question it answers for a browser sign-in,
|
|
// and an operator-issued session that did not appear there would be the one kind of session a
|
|
// user could not see. `tmplatformctl logins --user <id>` shows it.
|
|
if err := store.RecordLogin(ctx, login.LoginEvent{
|
|
UserID: *user, Provider: JournalProviderOperator, Outcome: "success",
|
|
Reason: "a bearer token was issued by the operator", Client: *client, At: now,
|
|
}); err != nil {
|
|
// Not fatal: the session exists and refusing now would leave a live credential the caller was
|
|
// never told. Loud, because a session outside the journal is a gap in the account's own record.
|
|
_, _ = fmt.Fprintf(out, "⚠ the login journal could not be written for this token: %v\n", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// JournalProviderOperator is what the login journal calls a session the OPERATOR issued, as opposed
|
|
// to one an identity provider did.
|
|
//
|
|
// ⚠ It is NOT reserved the way `dev` is (config.Load refuses an issuer configured under that name),
|
|
// and the difference is honest rather than an oversight: `dev` had to be reserved because it names an
|
|
// IDENTITY namespace — the key is (provider, subject), so an issuer filing subjects under it would
|
|
// resolve to the development account. Nothing is filed under this one: it appears only in the
|
|
// journal's `provider` column, which is a label and not a key. A deployment that configured an
|
|
// issuer literally named `operator` would make two kinds of row look alike in `tmplatformctl logins`,
|
|
// and that is the whole of the damage.
|
|
const JournalProviderOperator = "operator"
|