Files
keepassgo/ui_api.go
T
2026-03-31 20:23:59 -07:00

666 lines
21 KiB
Go

package main
import (
"fmt"
"strings"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apitokens"
)
func apiOperations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationManageVault,
}
}
func (u *ui) apiTokens() []apitokens.Token {
tokens, err := u.state.APITokens()
if err != nil {
return nil
}
query := strings.ToLower(strings.TrimSpace(u.search.Text()))
if query == "" {
if len(u.apiTokenClicks) < len(tokens) {
u.apiTokenClicks = make([]widget.Clickable, len(tokens))
}
return tokens
}
filtered := make([]apitokens.Token, 0, len(tokens))
for _, token := range tokens {
haystack := strings.ToLower(strings.Join([]string{
token.Name,
token.ClientName,
token.ID,
}, " "))
if strings.Contains(haystack, query) {
filtered = append(filtered, token)
}
}
if len(u.apiTokenClicks) < len(filtered) {
u.apiTokenClicks = make([]widget.Clickable, len(filtered))
}
return filtered
}
func (u *ui) selectedAPIToken() (apitokens.Token, bool) {
tokens, err := u.state.APITokens()
if err != nil {
return apitokens.Token{}, false
}
for _, token := range tokens {
if token.ID == strings.TrimSpace(u.state.SelectedEntryID) {
return token, true
}
}
return apitokens.Token{}, false
}
func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
if count <= 0 {
u.apiPolicyRemoves = nil
return nil
}
if len(u.apiPolicyRemoves) == count {
return u.apiPolicyRemoves
}
clicks := make([]widget.Clickable, count)
copy(clicks, u.apiPolicyRemoves)
u.apiPolicyRemoves = clicks
return clicks
}
func (u *ui) loadSelectedAPITokenIntoEditor() {
token, ok := u.selectedAPIToken()
if !ok {
u.apiTokenSecret = ""
u.apiTokenName.SetText("")
u.apiTokenClientName.SetText("")
u.apiTokenExpiresAt.SetText("")
u.apiTokenDisabled.Value = false
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
u.apiPolicyPath.SetText(strings.Join(u.displayPath(), " / "))
u.apiPolicyEntryID.SetText("")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.ensureAPIPolicyRemoveClickables(0)
return
}
u.apiTokenName.SetText(token.Name)
u.apiTokenClientName.SetText(token.ClientName)
if token.ExpiresAt != nil {
u.apiTokenExpiresAt.SetText(token.ExpiresAt.UTC().Format(time.RFC3339))
} else {
u.apiTokenExpiresAt.SetText("")
}
u.apiTokenDisabled.Value = token.Disabled
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
func (u *ui) issueAPITokenAction() error {
expiresAt, err := parseAPITokenExpiry(u.apiTokenExpiresAt.Text())
if err != nil {
return err
}
token, secret, err := u.state.IssueAPIToken(strings.TrimSpace(u.apiTokenName.Text()), strings.TrimSpace(u.apiTokenClientName.Text()), expiresAt, u.now())
if err != nil {
return err
}
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = secret
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) saveAPITokenAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
expiresAt, err := parseAPITokenExpiry(u.apiTokenExpiresAt.Text())
if err != nil {
return err
}
token.Name = strings.TrimSpace(u.apiTokenName.Text())
token.ClientName = strings.TrimSpace(u.apiTokenClientName.Text())
token.ExpiresAt = expiresAt
token.Disabled = u.apiTokenDisabled.Value
return u.state.UpsertAPIToken(token)
}
func (u *ui) rotateAPITokenAction() error {
token, secret, err := u.state.RotateAPIToken(strings.TrimSpace(u.state.SelectedEntryID), u.now())
if err != nil {
return err
}
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = secret
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) disableAPITokenAction() error {
if err := u.state.DisableAPIToken(strings.TrimSpace(u.state.SelectedEntryID)); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) revokeAPITokenAction() error {
if err := u.state.RevokeAPIToken(strings.TrimSpace(u.state.SelectedEntryID), u.now()); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) deleteAPITokenAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no API token selected")
}
if err := u.state.DeleteAPIToken(id); err != nil {
return err
}
u.state.SelectedEntryID = ""
u.loadSelectedAPITokenIntoEditor()
return nil
}
func parseAPITokenExpiry(text string) (*time.Time, error) {
value := strings.TrimSpace(text)
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, fmt.Errorf("expiration must use RFC3339, for example 2026-04-01T15:04:05Z")
}
return &parsed, nil
}
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
value := apitokens.Operation(strings.TrimSpace(text))
for _, operation := range apiOperations() {
if operation == value {
return value, nil
}
}
return "", fmt.Errorf("unknown API operation %q", text)
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
if err != nil {
return err
}
rule := apitokens.PolicyRule{
Operation: operation,
Effect: apitokens.EffectDeny,
}
if u.apiPolicyAllow.Value {
rule.Effect = apitokens.EffectAllow
}
u.apiPolicyGroupScope = u.apiPolicyGroupScopeW.Value
if u.apiPolicyGroupScope {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return fmt.Errorf("policy path is required for group scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
} else {
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
if entryID == "" {
return fmt.Errorf("entry id is required for entry scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
}
if !uiHasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("policy index %d out of range", index)
}
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiAuditEvents() []apiaudit.Event {
if u.auditLog == nil {
return nil
}
events := u.auditLog.Events()
query := strings.ToLower(strings.TrimSpace(u.search.Text()))
if query == "" {
if len(u.apiAuditClicks) < len(events) {
u.apiAuditClicks = make([]widget.Clickable, len(events))
}
return events
}
filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events {
haystack := strings.ToLower(strings.Join([]string{
string(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}, " "))
if strings.Contains(haystack, query) {
filtered = append(filtered, event)
}
}
if len(u.apiAuditClicks) < len(filtered) {
u.apiAuditClicks = make([]widget.Clickable, len(filtered))
}
return filtered
}
func formatAPIPolicyRule(rule apitokens.PolicyRule) string {
scope := strings.Join(rule.Resource.Path, " / ")
if rule.Resource.Kind == apitokens.ResourceEntry {
scope = "entry " + rule.Resource.EntryID
}
return strings.TrimSpace(strings.Join([]string{
strings.ToUpper(string(rule.Effect)),
string(rule.Operation),
scope,
}, " "))
}
func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
continue
}
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
continue
}
if strings.Join(rule.Resource.Path, "\x00") == strings.Join(target.Resource.Path, "\x00") {
return true
}
}
return false
}
func (u *ui) apiTokenRow(gtx layout.Context, click *widget.Clickable, idx int, token apitokens.Token) layout.Dimensions {
for click.Clicked(gtx) {
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = ""
u.loadSelectedAPITokenIntoEditor()
}
selected := strings.TrimSpace(u.state.SelectedEntryID) == token.ID
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), token.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), token.ClientName)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Non-expiring"
if token.ExpiresAt != nil {
text = "Expires " + token.ExpiresAt.Local().Format(time.RFC3339)
}
lbl := material.Label(u.theme, unit.Sp(12), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if selected {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
return layout.Background{}.Layout(gtx, fill(selectedColor), func(gtx layout.Context) layout.Dimensions {
paintBar := layout.Stack{}.Layout
_ = paintBar
return layout.Dimensions{Size: size}
})
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(selectedColor), row)
}),
)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, event apiaudit.Event) layout.Dimensions {
for click.Clicked(gtx) {
u.selectedAuditIndex = idx
}
selected := u.selectedAuditIndex == idx
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), string(event.Type))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.TrimSpace(event.ClientName))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if selected {
return layout.Background{}.Layout(gtx, fill(selectedColor), row)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
tokens := u.apiTokens()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(tokens) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No API tokens match the current filter.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i])
})
}),
)
}
func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Local gRPC audit history"
if strings.TrimSpace(u.grpcAddress) != "" {
text = "Local gRPC audit history at " + u.grpcAddress
}
lbl := material.Label(u.theme, unit.Sp(13), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(events) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No audit events yet.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return material.List(u.theme, &u.list).Layout(gtx, len(events), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiAuditRow(gtx, &u.apiAuditClicks[i], i, events[i])
})
}),
)
}
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken()
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
if ok {
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
}
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Token")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditor(u.theme, "Name", &u.apiTokenName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx)
},
}
if ok {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token ID", token.ID),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339)),
)
if token.RevokedAt != nil {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339)),
)
}
}
if strings.TrimSpace(u.apiTokenSecret) != "" {
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "ONE-TIME SECRET")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyAPITokenSecret, "Copy Secret")
}),
)
})
},
)
}
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveAPIToken, "Save Token")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.rotateAPIToken, "Rotate")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.disableAPIToken, "Disable")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.revokeAPIToken, "Revoke")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteAPIToken, "Delete")
}),
)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Policy Rules")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
)
if ok && len(token.Policies) > 0 {
for i, rule := range token.Policies {
index := i
ruleText := formatAPIPolicyRule(rule)
rows = append(rows,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), ruleText)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
}),
)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
)
}
} else {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
}
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
},
)
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func (u *ui) apiAuditDetailPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents()
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Audit")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
text := "No audit events yet."
if len(events) > 0 {
text = fmt.Sprintf("%d recent security events recorded.", len(events))
}
lbl := material.Label(u.theme, unit.Sp(14), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
}
if u.selectedAuditIndex >= 0 && u.selectedAuditIndex < len(events) {
event := events[u.selectedAuditIndex]
rows = append(rows,
layout.Spacer{Height: unit.Dp(12)}.Layout,
detailLine(u.theme, "Type", string(event.Type)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "When", event.At.Local().Format(time.RFC3339)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token", event.TokenName),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Client", event.ClientName),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Operation", string(event.Operation)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Resource", formatAuditResource(event.Resource)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Message", event.Message),
)
}
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func stringOps(ops []apitokens.Operation) []string {
out := make([]string, 0, len(ops))
for _, op := range ops {
out = append(out, string(op))
}
return out
}
func formatAuditResource(resource apitokens.Resource) string {
if resource.Kind == apitokens.ResourceEntry {
return "entry " + resource.EntryID
}
if len(resource.Path) == 0 {
return "/"
}
return strings.Join(resource.Path, " / ")
}