A person was in exactly one project, chosen at approval, and could not use the gateway before that. People belong to several teams and to none, so: - memberships: a person is a member of any number of organizations, teams and projects, or of none; a member of a team or organization may use every project in it. users join/leave, and the people page adds and takes out from either side. Approval no longer needs a project. - personal keys: a key may have an owner and no project. /profile asks what a new key spends against; keys create --for does the same. auth.personal sets default limits for personal use or turns it off. - limits for people: --scope user holds a person across every key, project and the chat page; --per-member on an org, team or project gives each member their own share there. Resolution folds the tree, each per-member limit for the key's owner, and the owner's own, each in its own pool; the quotas page shows and sets both. - the chat page asks what to spend against and checks it per message. - usage records who spent it, so a person's budget survives a restart, personal use and chat included; restore no longer drops rows with no project. Leaving a project revokes the keys made there unless another membership still reaches it. A personal key keeps no threads or notes, which are per project, and is refused rather than dropped into the anonymous pool. Migration 0010 turns each person's project into a membership, adds usage.user_id and backfills it, and makes api_keys.project_id nullable. SQLite cannot drop NOT NULL, so the table is rebuilt; that needs foreign keys off, which only works outside a transaction, so migrations marked for it run on one connection with foreign_key_check before commit. Tested on a populated SQLite store with claims, and on a copy of the cluster's PostgreSQL store: 5 people became members, 17 keys and 3 claims intact, 145 of 577 usage rows and all 69 chat rows attributed. The PostgreSQL store tests, including new membership cases, pass on 16. users move is gone; join and leave replace it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
430 lines
13 KiB
Go
430 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func newTenancyCmd() *cobra.Command {
|
|
var sf storeFlags
|
|
|
|
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.",
|
|
}
|
|
sf.bind(cmd)
|
|
|
|
// Takes a context rather than the command, because some of these run
|
|
// before the command tree is entered.
|
|
open := func(ctx context.Context) (*store.DB, error) { return sf.openCtx(ctx) }
|
|
|
|
cmd.AddCommand(
|
|
newKeyInitCmd(open),
|
|
newKeyCreateCmd(open),
|
|
newKeyListCmd(open),
|
|
newKeyRevokeCmd(open),
|
|
newLimitsCmd(open),
|
|
newThreadsCmd(open),
|
|
newMemoryCmd(open),
|
|
newMoveStoreCmd(),
|
|
)
|
|
return cmd
|
|
}
|
|
|
|
type opener func(context.Context) (*store.DB, 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, baseURL, forUser string
|
|
var expires, claimTTL time.Duration
|
|
var claim bool
|
|
|
|
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.\n\n" +
|
|
"With --claim it prints a one-time link instead. Send the link rather than\n" +
|
|
"the key: a key pasted into a chat window stays in that chat window forever,\n" +
|
|
"while a link works once and then expires. The key is sealed with the link's\n" +
|
|
"own token, which is not stored, so a pending claim is not a usable\n" +
|
|
"credential sitting in the database either.",
|
|
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()
|
|
|
|
// Three kinds: an operator's key in a project, a person's key in a
|
|
// project they are a member of, and a person's personal key.
|
|
var owner store.User
|
|
if forUser != "" {
|
|
if owner, err = resolveUser(cmd.Context(), s, forUser); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var p store.ProjectPath
|
|
if project != "" || forUser == "" {
|
|
if p, err = resolveProject(cmd.Context(), s, project); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.ID == "" {
|
|
p.Path = "personal, " + owner.Email
|
|
}
|
|
secret, k, err := store.NewKey(randomID("key"), args[0], p.ID)
|
|
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 owner.ID != "" {
|
|
err = s.CreateUserKey(cmd.Context(), owner.ID, k)
|
|
} else {
|
|
err = s.CreateKey(cmd.Context(), k)
|
|
}
|
|
if errors.Is(err, store.ErrNotMember) {
|
|
return fmt.Errorf("%s is not a member of %s; `llmgw users join` first", owner.Email, p.Path)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("creating the key in %s: %w", p.Path, err)
|
|
}
|
|
|
|
// The one output that matters goes to stdout and everything else to
|
|
// stderr, so `keys create ... > out.txt` captures it and nothing
|
|
// else. Cobra's Print writes to stderr, which would have made the
|
|
// useful half the half that cannot be piped.
|
|
out := cmd.OutOrStdout()
|
|
|
|
if claim {
|
|
if baseURL == "" {
|
|
return errors.New("--claim needs --base-url, the address users reach this gateway at")
|
|
}
|
|
token, err := store.NewClaimToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.CreateClaim(cmd.Context(), k.ID, token, secret, claimTTL); err != nil {
|
|
return err
|
|
}
|
|
link := strings.TrimRight(baseURL, "/") + "/claim/" + token
|
|
fmt.Fprintln(out, link)
|
|
cmd.PrintErrf("\nid %s\nproject %s\nexpires %s\n",
|
|
k.ID, p.Path, time.Now().Add(orDefault(claimTTL, store.ClaimTTL)).Format(time.RFC3339))
|
|
cmd.PrintErrln("\nSend the link. It works once, and the key is shown only to whoever opens it.")
|
|
return nil
|
|
}
|
|
|
|
fmt.Fprintln(out, secret)
|
|
cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, p.Path)
|
|
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", "",
|
|
"project the key belongs to: id, path (Acme/platform/gateway) or unique name")
|
|
cmd.Flags().StringVar(&forUser, "for", "",
|
|
"a person the key belongs to (id or email); without --project it is their personal key")
|
|
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")
|
|
cmd.Flags().BoolVar(&claim, "claim", false, "print a one-time link instead of the key")
|
|
cmd.Flags().StringVar(&baseURL, "base-url", "", "public address of the gateway, for --claim")
|
|
cmd.Flags().DurationVar(&claimTTL, "claim-ttl", store.ClaimTTL, "how long the link stays usable")
|
|
return cmd
|
|
}
|
|
|
|
func orDefault(d, fallback time.Duration) time.Duration {
|
|
if d <= 0 {
|
|
return fallback
|
|
}
|
|
return d
|
|
}
|
|
|
|
func newKeyListCmd(open opener) *cobra.Command {
|
|
var project string
|
|
var all bool
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List a project's keys, or with --all every key and whose it is",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
s, err := open(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
|
|
if all {
|
|
rows, err := s.AllKeys(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tNAME\tKEY\tPROJECT\tOWNER\tSTATE\tLAST USED")
|
|
now := time.Now()
|
|
for _, r := range rows {
|
|
where := r.ProjectPath
|
|
if where == "" {
|
|
where = "personal"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.Name, r.Prefix,
|
|
where, dash(r.OwnerEmail), keyState(r.Key, now), ago(r.LastUsedAt, now))
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
p, err := resolveProject(cmd.Context(), s, project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keys, err := s.Keys(cmd.Context(), p.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(keys) == 0 {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "no keys in %s\n", p.Path)
|
|
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", "", "project to list: id, path or unique name")
|
|
cmd.Flags().BoolVar(&all, "all", false, "every key in every project, with its owner")
|
|
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 perMember bool
|
|
var rpm, tpm int
|
|
var budget float64
|
|
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()
|
|
|
|
scopeID := id
|
|
switch store.Scope(scope) {
|
|
case store.ScopeOrg:
|
|
o, err := resolveOrg(cmd.Context(), s, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scopeID = o.ID
|
|
case store.ScopeTeam:
|
|
t, err := resolveTeam(cmd.Context(), s, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scopeID = t.ID
|
|
case store.ScopeProject:
|
|
p, err := resolveProject(cmd.Context(), s, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scopeID = p.ID
|
|
case store.ScopeUser:
|
|
u, err := resolveUser(cmd.Context(), s, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scopeID = u.ID
|
|
case store.ScopeKey:
|
|
if id == "" {
|
|
return errors.New("--scope key needs --id, the key's id")
|
|
}
|
|
default:
|
|
return fmt.Errorf("--scope is org, team, project, user or key, not %q", scope)
|
|
}
|
|
level := store.Scope(scope)
|
|
if perMember {
|
|
// Written on the scope, applied to each member separately.
|
|
if level = store.MemberScope(level); level == "" {
|
|
return errors.New("--per-member is for --scope org, team or project")
|
|
}
|
|
}
|
|
l := store.Limits{Scope: level, ScopeID: scopeID}
|
|
if cmd.Flags().Changed("rpm") {
|
|
l.RPM = &rpm
|
|
}
|
|
if cmd.Flags().Changed("tpm") {
|
|
l.TPM = &tpm
|
|
}
|
|
if cmd.Flags().Changed("budget") {
|
|
// Whole units, as the quotas page takes them; stored as
|
|
// micro-units so repeated addition does not drift.
|
|
micros := int64(math.Round(budget * 1e6))
|
|
l.Budget = µs
|
|
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", level, scopeID)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&scope, "scope", "project", "org | team | project | user | key")
|
|
cmd.Flags().BoolVar(&perMember, "per-member", false,
|
|
"with org, team or project: a limit for each member there, not one shared by all")
|
|
cmd.Flags().StringVar(&id, "id", "", "which one: id, path or unique name (a key's id for --scope key)")
|
|
cmd.Flags().IntVar(&rpm, "rpm", 0, "requests per minute")
|
|
cmd.Flags().IntVar(&tpm, "tpm", 0, "tokens per minute")
|
|
cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them")
|
|
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[:]))
|
|
}
|