package cabana import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" ) func tokenRuntimeCommands(app *backpack.App) []bonfire.Command { return []bonfire.Command{ { Name: "admin:token:create", Description: "Create a personal API token for a backend administrator", Args: []bonfire.Arg{{ Name: "identifier", Description: "Login or email", Required: true, }}, Long: `Creates a personal API token for the administrator with this login or email and prints the plaintext token once, alone on a line, so a script can capture it; it is printed under -q too. Only a hash is stored. Each --scope limits the token to a scope listed by admin:token:scopes; without one the token gets the scopes the administrator's permissions allow. --json prints the token with its id, name, scopes and expiry.`, Flags: []bonfire.Flag{ {Name: "name", Description: "Token name"}, {Name: "scope", Description: "Scope name (repeatable)", Repeatable: true}, {Name: "expires", Description: "Expiry: 30d, 90d, 365d, never, or YYYY-MM-DD", Default: "90d"}, {Name: "json", Description: "Print the token and its details as JSON", Bare: true}, }, Examples: []bonfire.Example{ {Command: "admin:token:create jane --name agent --scope journal.posts", Description: "A token for an agent, limited to one scope"}, {Command: "admin:token:create jane --expires never --json", Description: "A token that never expires, as JSON"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenCreate(ctx, app, in, out) }, }, { Name: "admin:token:list", Description: "List personal API tokens for a backend administrator (or all)", Args: []bonfire.Arg{{ Name: "identifier", Description: "Login or email (optional)", }}, Long: `Lists active personal API tokens, of one administrator or of all, with the token hint (never the hash or the plaintext), scopes and dates. --revoked includes revoked tokens; --json prints them as a JSON array.`, Flags: []bonfire.Flag{ {Name: "revoked", Description: "Include revoked tokens", Bare: true}, {Name: "json", Description: "Print the tokens as JSON", Bare: true}, }, Examples: []bonfire.Example{ {Command: "admin:token:list", Description: "Every active token"}, {Command: "admin:token:list jane --revoked --json", Description: "Every token of one administrator, as JSON"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenList(ctx, app, in, out) }, }, { Name: "admin:token:revoke", Description: "Revoke a personal API token by id", Long: `Revokes one personal API token by the id admin:token:list shows. Requests with it are refused from then on. Revoking a token twice is not an error.`, Examples: []bonfire.Example{ {Command: "admin:token:revoke 12", Description: "Revoke token 12"}, }, Args: []bonfire.Arg{{ Name: "id", Description: "Token id", Required: true, }}, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenRevoke(ctx, app, in, out) }, }, { Name: "admin:token:scopes", Description: "List available personal API token scopes", Long: `Lists the scopes a personal API token can be limited to, from the framework and every activated plugin: name, label, whether it is read-only, and the permissions it needs. --json prints them as a JSON array.`, Flags: []bonfire.Flag{{Name: "json", Description: "Print the scopes as JSON", Bare: true}}, Examples: []bonfire.Example{ {Command: "admin:token:scopes", Description: "Show every scope"}, {Command: "admin:token:scopes --json", Description: "Machine-readable scope list"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return adminTokenScopes(ctx, app, in, out) }, }, } } func tokenCatalogForApp(app *backpack.App) (*Registry, error) { plugins := []party.Plugin{} if app != nil { if activated, ok := app.Lookup[party.Activated](); ok && activated != nil { plugins = []party.Plugin(activated) } } return compileTokenCatalog(plugins) } func adminTokenCreate(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { identifier := argValue(in, "identifier") if identifier == "" { return errors.New("cabana: an identifier is required") } name := strings.TrimSpace(flagValue(in, "name")) expires := strings.TrimSpace(flagValue(in, "expires")) scopes := in.Flags("scope") if len(scopes) == 0 { if s := flagValue(in, "scope"); s != "" { scopes = []string{s} } } catalog, err := tokenCatalogForApp(app) if err != nil { return err } return withAdminDB(ctx, app, func(gdb *gorm.DB) error { user, found, err := findBackendLogin(gdb.WithContext(ctx), identifier) if err != nil { return err } if !found { return fmt.Errorf("cabana: admin %q not found", identifier) } if !user.IsActivated || user.DeletedAt.Valid { return fmt.Errorf("cabana: admin %q is deactivated or deleted", identifier) } principal, err := (BackendUsers{DB: gdb, Registry: catalog}).FindByID(ctx, user.ID) if err != nil || principal == nil { return fmt.Errorf("cabana: could not load admin %q", identifier) } plain, row, details, err := issueAPIToken(gdb, catalog, principal, name, expires, scopes, time.Now().UTC(), true) if err != nil { return err } if details != nil { return fmt.Errorf("cabana: %s", firstDetail(details)) } if flagValue(in, "json") == "true" && row != nil { return printJSON(out, map[string]any{ "id": row.ID, "name": row.Name, "token": plain, "scopes": tokenScopes(row), "expires_at": row.ExpiresAt, }) } out.Println(plain) return nil }) } func adminTokenList(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { identifier := argValue(in, "identifier") showRevoked := flagValue(in, "revoked") == "true" return withAdminDB(ctx, app, func(gdb *gorm.DB) error { q := gdb.WithContext(ctx).Model(&BackendAPIToken{}).Order("created_at DESC").Order("id DESC") if identifier != "" { user, found, err := findBackendLogin(gdb.WithContext(ctx), identifier) if err != nil { return err } if !found { return fmt.Errorf("cabana: admin %q not found", identifier) } q = q.Where("backend_user_id = ?", user.ID) } if !showRevoked { q = q.Where("revoked_at IS NULL") } var rows []BackendAPIToken if err := q.Find(&rows).Error; err != nil { return err } headers := []string{"id", "login", "name", "scopes", "hint", "created", "last used", "expires", "revoked"} table := make([][]string, 0, len(rows)) list := make([]map[string]any, 0, len(rows)) for i := range rows { row := &rows[i] var login string _ = gdb.WithContext(ctx).Raw(`SELECT login FROM backend_users WHERE id = ?`, row.BackendUserID).Scan(&login).Error scopes := strings.Join(tokenScopes(row), ",") list = append(list, map[string]any{ "id": row.ID, "login": login, "name": row.Name, "scopes": tokenScopes(row), "hint": APITokenPrefix + "…" + row.TokenHint, "created_at": row.CreatedAt, "last_used_at": row.LastUsedAt, "expires_at": row.ExpiresAt, "revoked_at": row.RevokedAt, }) table = append(table, []string{ strconv.FormatUint(uint64(row.ID), 10), login, row.Name, scopes, APITokenPrefix + "…" + row.TokenHint, formatTime(row.CreatedAt), formatTimePtr(row.LastUsedAt), formatTimePtr(row.ExpiresAt), formatTimePtr(row.RevokedAt), }) } if flagValue(in, "json") == "true" { return printJSON(out, list) } out.Table(headers, table) return nil }) } func adminTokenRevoke(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { raw := argValue(in, "id") id, err := strconv.ParseUint(raw, 10, 64) if err != nil || id == 0 { return errors.New("cabana: a token id is required") } return withAdminDB(ctx, app, func(gdb *gorm.DB) error { var row BackendAPIToken err := gdb.WithContext(ctx).First(&row, id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("cabana: token %d not found", id) } if err != nil { return err } if row.RevokedAt != nil { out.Info(fmt.Sprintf("token %d was already revoked", id)) return nil } now := time.Now().UTC() if err := gdb.WithContext(ctx).Model(&row).Updates(map[string]any{ "revoked_at": now, "revoked_by": nil, }).Error; err != nil { return err } out.Success(fmt.Sprintf("revoked token %d", id)) return nil }) } func adminTokenScopes(ctx context.Context, app *backpack.App, in bonfire.Input, out bonfire.Output) error { catalog, err := tokenCatalogForApp(app) if err != nil { return err } tr, _ := app.Lookup[*phrasebook.Translator]() headers := []string{"name", "label", "read-only", "permissions"} rows := make([][]string, 0, len(catalog.tokenScopes.ordered)) list := make([]map[string]any, 0, len(catalog.tokenScopes.ordered)) for _, scope := range catalog.tokenScopes.ordered { label := scope.Label if strings.Contains(label, "::") && tr != nil && tr.Has(label) { if s := tr.Get(ctx, label, nil); s != "" { label = s } } readOnly := "yes" for m := range scope.Methods { if m != "GET" && m != "HEAD" { readOnly = "no" break } } if scope.Full && scope.Name == scopeAll { readOnly = "no" } perms := strings.Join(scope.Permissions, ",") if scope.Full { perms = "(full)" } rows = append(rows, []string{scope.Name, label, readOnly, perms}) permissions := scope.Permissions if permissions == nil { permissions = []string{} } list = append(list, map[string]any{ "name": scope.Name, "label": label, "read_only": readOnly == "yes", "full": scope.Full, "permissions": permissions, }) } if flagValue(in, "json") == "true" { return printJSON(out, list) } out.Table(headers, rows) return nil } // tokenScopes is the token's scope list; never nil. func tokenScopes(row *BackendAPIToken) []string { if row == nil || !row.Scopes.Valid || row.Scopes.Get() == nil { return []string{} } return row.Scopes.Get() } // printJSON writes v as indented JSON and a newline on out: data, printed // under -q too. func printJSON(out bonfire.Output, v any) error { b, err := json.MarshalIndent(v, "", " ") if err != nil { return err } out.Println(string(b)) return nil } func argValue(in bonfire.Input, name string) string { if in == nil { return "" } if v, ok := in.Argument(name); ok { return strings.TrimSpace(v) } if name == "identifier" && len(in.Args()) > 0 { return strings.TrimSpace(in.Args()[0]) } if name == "id" && len(in.Args()) > 0 { return strings.TrimSpace(in.Args()[0]) } return "" } func firstDetail(details map[string]any) string { for _, raw := range details { if keys, ok := raw.([]string); ok && len(keys) > 0 { key := keys[0] if name, ok := strings.CutPrefix(key, "api_tokens.scope_unavailable|"); ok { return fmt.Sprintf("scope %q is not available", name) } return key } } return "validation failed" } func formatTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) } func formatTimePtr(t *time.Time) string { if t == nil { return "" } return formatTime(*t) }