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>
362 lines
9.9 KiB
Go
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
|
|
}
|
|
}
|