Files
summercms/modules/cabana/token_commands.go

279 lines
7.9 KiB
Go

package cabana
import (
"context"
"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,
}},
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"},
},
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)",
}},
Flags: []bonfire.Flag{
{Name: "revoked", Description: "Include revoked tokens", Bare: true},
},
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",
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",
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, _, 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))
}
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))
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 := ""
if row.Scopes.Valid {
scopes = strings.Join(row.Scopes.Get(), ",")
}
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),
})
}
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))
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})
}
out.Table(headers, rows)
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)
}