- migrate, migrate:rollback and deferred:purge gain --pretend and --force; they and queue:clear confirm only in production on an interactive terminal, so deploys over SSH proceed as before - result lines: Applied N migrations, Rolled back <id> of <plugin>, Cleared N jobs from <queue> - --json for migrate:status, key:generate, route:list, admin:token:create, admin:token:list, admin:token:scopes and websockets:health; -q keeps the key and the token - hints for an empty DSN, an unreachable database, an invalid app.key, a missing uploads bucket and an application binary run below its root - flare and websockets:health return bonfire.Reported after printing - long help and examples for every application command, greeter:hello and blog:publish; cmd/summer TestCommandsDocumented fails on any gap
367 lines
12 KiB
Go
367 lines
12 KiB
Go
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)
|
|
}
|