279 lines
7.9 KiB
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)
|
|
}
|