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 \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 ` 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"