Files
llmgw/internal/cli/org.go
T
Ilker ManapandClaude Opus 5.5 098886e0e4 Let a person belong to many places, or to none
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>
2026-09-28 19:03:46 +02:00

362 lines
9.9 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newOrgCmd() *cobra.Command {
var sf storeFlags
cmd := &cobra.Command{
Use: "org",
Short: "Organizations, teams and projects: see, add, rename, move, remove",
Long: "The tenancy tree an account lives in. A person is in a project, a project\n" +
"in a team, a team in an organization, and limits can be set on any of them.\n\n" +
"Anything that takes one of them accepts its id, its path\n" +
"(\"Acme/platform/gateway\") or its name when that name is unique.",
}
sf.bind(cmd)
open := func(cmd *cobra.Command) (*store.DB, error) { return sf.open(cmd) }
cmd.AddCommand(newOrgTreeCmd(open), newOrgAddCmd(open), newOrgRenameCmd(open),
newOrgMoveCmd(open), newOrgRemoveCmd(open))
return cmd
}
type dbOpener func(*cobra.Command) (*store.DB, error)
func newOrgTreeCmd(open dbOpener) *cobra.Command {
var people bool
cmd := &cobra.Command{
Use: "tree",
Short: "Show the whole tree, with how many accounts and keys each project has",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
tree, err := s.Tree(cmd.Context())
if err != nil {
return err
}
var members map[string][]string
var alone []store.User
if people {
users, err := s.Users(cmd.Context(), "")
if err != nil {
return err
}
all, err := s.AllMemberships(cmd.Context())
if err != nil {
return err
}
email := map[string]string{}
for _, u := range users {
email[u.ID] = u.Email + " " + string(u.State)
if u.IsAdmin {
email[u.ID] += " admin"
}
}
members = map[string][]string{}
in := map[string]bool{}
for _, m := range all {
members[string(m.Scope)+":"+m.ScopeID] = append(members[string(m.Scope)+":"+m.ScopeID], email[m.UserID])
in[m.UserID] = true
}
for _, u := range users {
if !in[u.ID] {
alone = append(alone, u)
}
}
}
printTree(cmd.OutOrStdout(), tree, members)
if people && len(alone) > 0 {
fmt.Fprintf(cmd.OutOrStdout(), "\non their own\n")
for _, u := range alone {
fmt.Fprintf(cmd.OutOrStdout(), " %s %s %s\n", u.Email, u.State, u.ID)
}
}
return nil
},
}
cmd.Flags().BoolVar(&people, "people", false, "list the accounts in each project")
return cmd
}
func printTree(w io.Writer, tree []store.TreeOrg, members map[string][]string) {
if len(tree) == 0 {
fmt.Fprintln(w, "no organizations yet; `llmgw org add org <name>` starts one")
return
}
list := func(indent, key string) {
for _, m := range members[key] {
fmt.Fprintf(w, "%s%s\n", indent, m)
}
}
for _, o := range tree {
fmt.Fprintf(w, "%s (%s)%s\n", o.Name, o.ID, membersNote(o.Members))
list(" · ", "org:"+o.ID)
if len(o.Teams) == 0 {
fmt.Fprintln(w, " no teams")
}
for _, t := range o.Teams {
fmt.Fprintf(w, " %s (%s)%s\n", t.Name, t.ID, membersNote(t.Members))
list(" · ", "team:"+t.ID)
if len(t.Projects) == 0 {
fmt.Fprintln(w, " no projects")
}
for _, p := range t.Projects {
fmt.Fprintf(w, " %s (%s) %s, %s\n", p.Name, p.ID,
countOf(p.Users, "member"), keysOf(p))
list(" · ", "project:"+p.ID)
}
}
}
}
func membersNote(n int) string {
if n == 0 {
return ""
}
return " " + countOf(n, "member")
}
func keysOf(p store.TreeProject) string {
s := countOf(p.ActiveKeys, "key")
if revoked := p.Keys - p.ActiveKeys; revoked > 0 {
s += fmt.Sprintf(" (+%d revoked)", revoked)
}
return s
}
func countOf(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return fmt.Sprintf("%d %ss", n, noun)
}
func levelArg(arg string) (string, error) {
switch arg {
case "org", "organization":
return "org", nil
case "team", "project":
return arg, nil
}
return "", fmt.Errorf("%q is not org, team or project", arg)
}
func newOrgAddCmd(open dbOpener) *cobra.Command {
var in string
cmd := &cobra.Command{
Use: "add <org|team|project> <name>",
Short: "Add an organization, a team to one, or a project to a team",
Example: " llmgw org add org Acme\n" +
" llmgw org add team platform --in Acme\n" +
" llmgw org add project gateway --in Acme/platform",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
level, err := levelArg(args[0])
if err != nil {
return err
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
ctx := cmd.Context()
var id, where string
switch level {
case "org":
id, err = s.CreateOrg(ctx, args[1])
case "team":
var o store.Org
if o, err = resolveOrg(ctx, s, in); err == nil {
id, err = s.CreateTeam(ctx, o.ID, args[1])
where = " in " + o.Name
}
case "project":
var t store.TeamPath
if t, err = resolveTeam(ctx, s, in); err == nil {
id, err = s.CreateProject(ctx, t.ID, args[1])
where = " in " + t.OrgName + " / " + t.Name
}
}
if errors.Is(err, store.ErrDuplicateName) {
return fmt.Errorf("there is already a %s named %q there", level, args[1])
}
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s %s%s (%s)\n", level, args[1], where, id)
return nil
},
}
cmd.Flags().StringVar(&in, "in", "", "the organization a team goes in, or the team a project goes in")
return cmd
}
func newOrgRenameCmd(open dbOpener) *cobra.Command {
return &cobra.Command{
Use: "rename <org|team|project> <which> <new name>",
Short: "Rename an organization, team or project; ids and everything under it stay",
Example: " llmgw org rename project Acme/platform/gateway api-gateway",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
level, err := levelArg(args[0])
if err != nil {
return err
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
id, err := resolveLevel(cmd.Context(), s, level, args[1])
if err != nil {
return err
}
switch level {
case "org":
err = s.RenameOrg(cmd.Context(), id, args[2])
case "team":
err = s.RenameTeam(cmd.Context(), id, args[2])
case "project":
err = s.RenameProject(cmd.Context(), id, args[2])
}
if errors.Is(err, store.ErrDuplicateName) {
return fmt.Errorf("there is already a %s named %q beside it", level, args[2])
}
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s %s is now %q\n", level, id, args[2])
return nil
},
}
}
func newOrgMoveCmd(open dbOpener) *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "move <team|project> <which> --to <parent>",
Short: "Put a team in another organization, or a project in another team",
Long: "Everything under it moves too: projects, accounts and keys. From then on it\n" +
"inherits the new parent's limits. Usage already recorded is not rewritten.",
Example: " llmgw org move project Acme/platform/gateway --to Scania/ml\n" +
" llmgw org move team platform --to Scania",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
ctx := cmd.Context()
switch args[0] {
case "team":
t, err := resolveTeam(ctx, s, args[1])
if err != nil {
return err
}
o, err := resolveOrg(ctx, s, to)
if err != nil {
return err
}
if err := s.MoveTeam(ctx, t.ID, o.ID); err != nil {
return dupAs(err, "team", t.Name)
}
fmt.Fprintf(cmd.OutOrStdout(), "team %s is now in %s\n", t.Name, o.Name)
case "project":
p, err := resolveProject(ctx, s, args[1])
if err != nil {
return err
}
t, err := resolveTeam(ctx, s, to)
if err != nil {
return err
}
if err := s.MoveProject(ctx, p.ID, t.ID); err != nil {
return dupAs(err, "project", p.Path)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s is now in %s / %s\n", p.Path, t.OrgName, t.Name)
default:
return fmt.Errorf("only a team or a project moves; to move an account, `llmgw users move`")
}
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "the organization or team it goes to")
_ = cmd.MarkFlagRequired("to")
return cmd
}
func dupAs(err error, level, name string) error {
if errors.Is(err, store.ErrDuplicateName) {
return fmt.Errorf("the destination already has a %s named like %s", level, name)
}
return err
}
func newOrgRemoveCmd(open dbOpener) *cobra.Command {
return &cobra.Command{
Use: "remove <org|team|project> <which>",
Short: "Remove an organization, team or project that nothing is in",
Long: "Only something empty is removed: an organization with no teams, a team with\n" +
"no projects, a project with no accounts and no keys. A project that ever had\n" +
"a key keeps it, revoked, because usage history refers to it; rename such a\n" +
"project instead. Its limits go with it.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
level, err := levelArg(args[0])
if err != nil {
return err
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
id, err := resolveLevel(cmd.Context(), s, level, args[1])
if err != nil {
return err
}
switch level {
case "org":
err = s.DeleteOrg(cmd.Context(), id)
case "team":
err = s.DeleteTeam(cmd.Context(), id)
case "project":
err = s.DeleteProject(cmd.Context(), id)
}
if err != nil {
return fmt.Errorf("%s %s was not removed: %w", level, args[1], err)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s %s removed\n", level, id)
return nil
},
}
}
func resolveLevel(ctx context.Context, s *store.DB, level, arg string) (string, error) {
switch level {
case "org":
o, err := resolveOrg(ctx, s, arg)
return o.ID, err
case "team":
t, err := resolveTeam(ctx, s, arg)
return t.ID, err
default:
p, err := resolveProject(ctx, s, arg)
return p.ID, err
}
}