CI / test (push) Successful in 2m34s
Until now anything that could reach the client port could use every model in the catalog. A key now resolves to an identity, and that identity decides what it may do. Authentication wraps the whole mux rather than checking inside each handler, so adding a route cannot add an unauthenticated one by omission. Health stays open, because a load balancer has no key and a health check that needs one reports the gateway down. Nothing on the request path reads the database. A key is resolved once and answered from memory until the entry expires; rejections are cached too, so a client looping with a bad key cannot turn into a database read per request. The price is that a revocation takes up to cache_ttl to take effect, which is the trade stated in the configuration next to the knob. A store failure is not an authentication answer: it is never cached as one and returns 503, not 401 — sending someone hunting for a key problem that does not exist wastes the one thing an incident is short of. A revoked key is refused with the same message as an unknown one, because saying otherwise confirms the key was real. With auth on the band comes from the key and the band header is ignored. The header was trusted only while there was nothing to consult, and it let any client promote itself into the top priority band. Tenant fairness within a band is now per key rather than per session, so one caller cannot take a band by opening many sessions. Allowlists are decided per model, not per string: a model's canonical name and every alias name the same thing, so either form works in an allowlist and no alias can reach a model that was not allowed. The first version of that test passed without the resolution being there at all, which is why it now checks all four combinations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
8.3 KiB
Go
289 lines
8.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"fmt"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func newTenancyCmd() *cobra.Command {
|
|
var dbPath string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "keys",
|
|
Short: "Manage tenants and API keys",
|
|
Long: "Works directly on the store file rather than through the admin API, so keys\n" +
|
|
"can be issued before the server is running and without exposing an endpoint\n" +
|
|
"that mints credentials.",
|
|
}
|
|
cmd.PersistentFlags().StringVar(&dbPath, "store", "/var/lib/llmgw/llmgw.db", "path to the store")
|
|
|
|
open := func(ctx context.Context) (*store.SQLite, error) { return store.OpenSQLite(ctx, dbPath) }
|
|
|
|
cmd.AddCommand(
|
|
newKeyInitCmd(open),
|
|
newKeyCreateCmd(open),
|
|
newKeyListCmd(open),
|
|
newKeyRevokeCmd(open),
|
|
newLimitsCmd(open),
|
|
)
|
|
return cmd
|
|
}
|
|
|
|
type opener func(context.Context) (*store.SQLite, error)
|
|
|
|
func newKeyInitCmd(open opener) *cobra.Command {
|
|
var org, team, project string
|
|
cmd := &cobra.Command{
|
|
Use: "init",
|
|
Short: "Create the tenancy tree a key hangs off",
|
|
Long: "Idempotent: running it again renames rather than duplicating, so it can be\n" +
|
|
"kept in whatever provisions the machine.",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
s, err := open(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
ctx := cmd.Context()
|
|
if err := s.PutOrg(ctx, store.Org{ID: slug(org), Name: org}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.PutTeam(ctx, store.Team{ID: slug(team), OrgID: slug(org), Name: team}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.PutProject(ctx, store.Project{
|
|
ID: slug(project), TeamID: slug(team), Name: project,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s / %s / %s\n", slug(org), slug(team), slug(project))
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&org, "org", "default", "organization name")
|
|
cmd.Flags().StringVar(&team, "team", "default", "team name")
|
|
cmd.Flags().StringVar(&project, "project", "default", "project name")
|
|
return cmd
|
|
}
|
|
|
|
func newKeyCreateCmd(open opener) *cobra.Command {
|
|
var project, band, profile string
|
|
var expires time.Duration
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "create <name>",
|
|
Short: "Issue an API key",
|
|
Long: "The key is printed once and never again: only its hash is stored, so there\n" +
|
|
"is nothing to recover it from. That is the point — a leaked database hands\n" +
|
|
"over no working credential.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
secret, k, err := store.NewKey(randomID("key"), args[0], project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
k.CreatedAt = time.Now()
|
|
k.Band, k.RoutingProfile = band, profile
|
|
if expires > 0 {
|
|
k.ExpiresAt = k.CreatedAt.Add(expires)
|
|
}
|
|
if err := s.CreateKey(cmd.Context(), k); err != nil {
|
|
return fmt.Errorf("creating the key (does project %q exist? run `llmgw keys init`): %w",
|
|
project, err)
|
|
}
|
|
|
|
// The secret goes to stdout and everything else to stderr, so
|
|
// `keys create ... > key.txt` captures the key and nothing else.
|
|
// Cobra's Print writes to stderr, which would have made the one
|
|
// output that matters the one that cannot be piped.
|
|
fmt.Fprintln(cmd.OutOrStdout(), secret)
|
|
|
|
cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, k.ProjectID)
|
|
if !k.ExpiresAt.IsZero() {
|
|
cmd.PrintErrf("expires %s\n", k.ExpiresAt.Format(time.RFC3339))
|
|
}
|
|
cmd.PrintErrln("\nThis is the only time the key is shown. Store it now.")
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&project, "project", "default", "project the key belongs to")
|
|
cmd.Flags().StringVar(&band, "band", "", "admission band; empty inherits")
|
|
cmd.Flags().StringVar(&profile, "profile", "", "routing profile; empty inherits")
|
|
cmd.Flags().DurationVar(&expires, "expires-in", 0, "lifetime; 0 never expires")
|
|
return cmd
|
|
}
|
|
|
|
func newKeyListCmd(open opener) *cobra.Command {
|
|
var project string
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List a project's keys",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
s, err := open(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
keys, err := s.Keys(cmd.Context(), project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(keys) == 0 {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "no keys in project %q\n", project)
|
|
return nil
|
|
}
|
|
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tNAME\tKEY\tBAND\tSTATE\tLAST USED")
|
|
now := time.Now()
|
|
for _, k := range keys {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
k.ID, k.Name, k.Prefix, dash(k.Band), keyState(k, now), ago(k.LastUsedAt, now))
|
|
}
|
|
return w.Flush()
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&project, "project", "default", "project to list")
|
|
return cmd
|
|
}
|
|
|
|
func newKeyRevokeCmd(open opener) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "revoke <key-id>",
|
|
Short: "Stop a key working, without deleting it",
|
|
Long: "A soft delete. Usage rows reference the key, and an audit trail that loses\n" +
|
|
"its subject is not an audit trail.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
if err := s.RevokeKey(cmd.Context(), args[0], time.Now()); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
|
|
cmd.PrintErrln("A running server keeps accepting it until its cache expires (auth.cache_ttl).")
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newLimitsCmd(open opener) *cobra.Command {
|
|
var scope, id, period string
|
|
var rpm, tpm int
|
|
var budget int64
|
|
var models []string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "limits",
|
|
Short: "Set limits at one level of the tenancy tree",
|
|
Long: "Limits compose downward by taking the most restrictive value, so a key\n" +
|
|
"cannot raise what its organization set. Model allowlists intersect: a key\n" +
|
|
"cannot add a model its team withheld.",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
s, err := open(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
l := store.Limits{Scope: store.Scope(scope), ScopeID: id}
|
|
if cmd.Flags().Changed("rpm") {
|
|
l.RPM = &rpm
|
|
}
|
|
if cmd.Flags().Changed("tpm") {
|
|
l.TPM = &tpm
|
|
}
|
|
if cmd.Flags().Changed("budget") {
|
|
l.Budget = &budget
|
|
l.BudgetPeriod = store.Period(period)
|
|
}
|
|
if cmd.Flags().Changed("models") {
|
|
// An explicitly empty list is an allowlist that permits
|
|
// nothing, which is different from setting no list at all.
|
|
if models == nil {
|
|
models = []string{}
|
|
}
|
|
l.AllowedModels = models
|
|
}
|
|
if err := s.PutLimits(cmd.Context(), l); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "limits set on %s %s\n", scope, id)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&scope, "scope", "project", "org | team | project | key")
|
|
cmd.Flags().StringVar(&id, "id", "default", "id at that scope")
|
|
cmd.Flags().IntVar(&rpm, "rpm", 0, "requests per minute")
|
|
cmd.Flags().IntVar(&tpm, "tpm", 0, "tokens per minute")
|
|
cmd.Flags().Int64Var(&budget, "budget", 0, "budget per period, in micro-units")
|
|
cmd.Flags().StringVar(&period, "period", "month", "day | month")
|
|
cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none")
|
|
return cmd
|
|
}
|
|
|
|
func keyState(k store.Key, now time.Time) string {
|
|
switch {
|
|
case !k.RevokedAt.IsZero():
|
|
return "revoked"
|
|
case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt):
|
|
return "expired"
|
|
default:
|
|
return "active"
|
|
}
|
|
}
|
|
|
|
func ago(t, now time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
return now.Sub(t).Round(time.Second).String() + " ago"
|
|
}
|
|
|
|
// slug turns a human name into an id.
|
|
func slug(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == ' ' || r == '-' || r == '_':
|
|
b.WriteByte('-')
|
|
}
|
|
}
|
|
if b.Len() == 0 {
|
|
return "default"
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func randomID(prefix string) string {
|
|
var buf [10]byte
|
|
_, _ = rand.Read(buf[:])
|
|
return prefix + "-" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
|
|
}
|