From e757be66d950af466fc24bad4f93d019b6335c78 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sat, 11 Apr 2026 00:03:30 -0700 Subject: [PATCH] Complete API token authz UI flows --- TODO.md | 189 ------------------------------------ internal/api/host_test.go | 12 ++- internal/appui/api_views.go | 138 +++++++++++++++++++++++--- internal/appui/app.go | 44 ++++++--- internal/appui/frame.go | 46 +++++---- internal/appui/main_test.go | 160 +++++++++++++++++++++++++++++- 6 files changed, 354 insertions(+), 235 deletions(-) diff --git a/TODO.md b/TODO.md index a509f8c..c64b47e 100644 --- a/TODO.md +++ b/TODO.md @@ -132,195 +132,6 @@ These are important, but they should likely move behind a dedicated settings gea - Phone and desktop layouts both present a clear information hierarchy. - The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations. -## API Token And gRPC Authorization Parallel Segments - -These segments define the work for programmatic access control over gRPC. -They are designed to be independently landable wherever file overlap permits. -The feature is not complete until all segment exit criteria and the global exit criteria are satisfied. - -### API Segment A: Token Domain Model - -Scope: -- Represent API tokens as first-class vault-backed records. -- Mark token entries explicitly as API credentials rather than generic passwords. -- Store token metadata: - token id, - hashed secret or verifier, - display name, - client name, - created at, - expires at, - disabled state. -- Keep the persisted representation compatible with KDBX entry fields. - -Exit criteria: -- A domain type exists for API tokens and round-trips through the persisted vault model. -- Generic entry listing can distinguish API token entries from ordinary secrets. -- Tests cover create, load, save, and parse behavior for API token entries. -- `go test ./...` passes. - -### API Segment B: Token Issuance And Rotation - -Scope: -- Generate new API tokens for external tools. -- Return the cleartext token only at creation or explicit rotation time. -- Rotate an existing token while preserving its identity and policy linkage. -- Revoke or disable a token without deleting policy history. - -Exit criteria: -- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer. -- Cleartext token material is only exposed on creation or rotation paths. -- Tests cover generation, rotation, and disable/revoke semantics. -- `go test ./...` passes. - -### API Segment C: Token Expiration - -Scope: -- Allow tokens to have optional expiration timestamps. -- Treat expired tokens as unauthenticated. -- Surface expiration in UI and gRPC management views. -- Support non-expiring tokens explicitly. - -Exit criteria: -- Expired tokens are rejected by the gRPC authentication path. -- Token expiration can be created, edited, and removed through the service layer. -- Tests cover valid, expired, and non-expiring token behavior. -- `go test ./...` passes. - -### API Segment D: Authorization Policy Model - -Scope: -- Define an authorization model for token-scoped access. -- Support allow and deny rules over: - folders/groups, - specific entries, - entry fields where needed, - and operation types. -- Keep specific deny rules higher priority than broad allow rules. -- Model “not yet decided” separately from “denied”. - -Exit criteria: -- A policy evaluator exists for token, resource, and operation tuples. -- Explicit deny overrides allow. -- Unspecified access is distinguishable from denied access. -- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior. -- `go test ./...` passes. - -### API Segment E: gRPC Authentication And Authorization Enforcement - -Scope: -- Replace the current single static bearer-token interceptor with token-backed auth. -- Authenticate callers using issued KeePassGO API tokens. -- Authorize every gRPC method against token policy. -- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs. - -Exit criteria: -- gRPC requests authenticate through stored API tokens rather than one static shared secret. -- Every RPC enforces token-specific authorization before mutating or revealing vault data. -- Unauthorized requests return the correct authz/authn gRPC status. -- Integration tests cover permitted, denied, expired, and revoked token behavior. -- `go test ./...` passes. - -### API Segment F: Approval Queue And Pending Access Requests - -Scope: -- When a token requests access to a resource that is neither explicitly allowed nor denied: - create a pending approval request. -- Include: - token identity, - client name, - requested operation, - requested group/entry scope, - requested time, - and permanence choice. -- Allow the request to be accepted, denied, or canceled by the user. - -Exit criteria: -- Unspecified access creates a pending approval instead of silently denying or allowing. -- Pending approvals are queryable from the application layer. -- Canceling the prompt results in the API request failing without granting access. -- Tests cover pending creation, approval, denial, and cancellation. -- `go test ./...` passes. - -### API Segment G: Approval UI - -Scope: -- Show a user-facing approval screen/dialog when a pending API request needs a decision. -- Provide actions: - allow once, - deny once, - allow permanently, - deny permanently, - cancel. -- Make the requested scope and operation clear to the user. -- Ensure the dialog appears only for requests not already decided. - -Exit criteria: -- A pending request triggers a visible approval surface in the app. -- The user can allow, deny, or cancel from the UI. -- Permanent decisions become persisted policy rules. -- UI tests cover each approval outcome. -- `go test ./...` passes. - -### API Segment H: gRPC Request Blocking And Resume Behavior - -Scope: -- Define how an in-flight gRPC call waits for or fails on user approval. -- Hold the request while approval is pending within a bounded timeout. -- Return unauthenticated or permission-denied when denied/canceled/expired. -- Resume the original call automatically when approval is granted. - -Exit criteria: -- Pending requests block safely without leaking goroutines. -- Allowed requests resume and complete without the client reissuing the call where practical. -- Denied and canceled requests return a consistent gRPC status code and message. -- Tests cover timeout, allow, deny, and cancel paths. -- `go test ./...` passes. - -### API Segment I: Token Management UI - -Scope: -- Add UI for listing API tokens. -- Create token flow with one-time secret display. -- Edit token display metadata and expiration. -- Disable, revoke, and rotate tokens. -- Show effective policy summary per token. - -Exit criteria: -- Users can manage API tokens from the app UI end to end. -- One-time token display is explicit and not re-shown later. -- Expiration and disable state are visible. -- UI tests cover create, rotate, disable, revoke, and edit flows. -- `go test ./...` passes. - -### API Segment J: Policy Management UI - -Scope: -- Let users define folder, entry, and operation scopes for each token. -- Show explicit allow and deny rules. -- Show inherited implications of a folder-level rule. -- Let users review prior permanent decisions created from approval prompts. - -Exit criteria: -- Users can inspect and edit token policy from the UI. -- Folder-level and entry-level rules are distinguishable and editable. -- Permanent prompt decisions are visible as policy. -- UI tests cover rule creation, update, and deletion. -- `go test ./...` passes. - -### API Segment K: Audit And Event History - -Scope: -- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes. -- Record authorization failures and expirations without logging secret material. -- Provide a bounded event history visible in the UI and/or gRPC admin surface. - -Exit criteria: -- Security-relevant API token events are captured without secret leakage. -- Approval outcomes and policy changes are auditable. -- Tests cover audit generation for the main token lifecycle and approval actions. -- `go test ./...` passes. - ### Segment 1: Application State Ownership Scope: diff --git a/internal/api/host_test.go b/internal/api/host_test.go index 8de2477..dea65bf 100644 --- a/internal/api/host_test.go +++ b/internal/api/host_test.go @@ -5,6 +5,7 @@ import ( "net" "testing" + "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/passwords" "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" @@ -19,7 +20,16 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) { lifecycle := &session.Manager{} if err := lifecycle.Create(vault.Model{ Entries: []vault.Entry{ - testAPITokenEntry(t), + testAPITokenEntry(t, + apitokens.PolicyRule{ + Effect: apitokens.EffectAllow, + Operation: apitokens.OperationManageVault, + Resource: apitokens.Resource{ + Kind: apitokens.ResourceGroup, + Path: []string{"Root"}, + }, + }, + ), {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, }, }, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { diff --git a/internal/appui/api_views.go b/internal/appui/api_views.go index fc8c876..f400e05 100644 --- a/internal/appui/api_views.go +++ b/internal/appui/api_views.go @@ -129,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable { return clicks } +func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable { + if count <= 0 { + u.apiPolicyEdits = nil + return nil + } + if len(u.apiPolicyEdits) == count { + return u.apiPolicyEdits + } + clicks := make([]widget.Clickable, count) + copy(clicks, u.apiPolicyEdits) + u.apiPolicyEdits = clicks + return clicks +} + func (u *ui) loadSelectedAPITokenIntoEditor() { + u.selectedAPIPolicyIndex = -1 token, ok := u.selectedAPIToken() if !ok { u.apiTokenSecret = "" @@ -143,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() { u.apiPolicyAllow.Value = true u.apiPolicyGroupScope = true u.apiPolicyGroupScopeW.Value = true + u.ensureAPIPolicyEditClickables(0) u.ensureAPIPolicyRemoveClickables(0) return } @@ -154,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() { u.apiTokenExpiresAt.SetText("") } u.apiTokenDisabled.Value = token.Disabled + u.ensureAPIPolicyEditClickables(len(token.Policies)) u.ensureAPIPolicyRemoveClickables(len(token.Policies)) } @@ -250,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) { 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") - } +func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) { operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text()) if err != nil { - return err + return apitokens.PolicyRule{}, err } rule := apitokens.PolicyRule{ Operation: operation, @@ -270,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error { if u.apiPolicyGroupScope { path := parsePath(u.apiPolicyPath.Text()) if len(path) == 0 { - return fmt.Errorf("policy path is required for group scope") + return apitokens.PolicyRule{}, 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") + return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope") } rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID} } + return rule, nil +} + +func (u *ui) addAPIPolicyRuleAction() error { + token, ok := u.selectedAPIToken() + if !ok { + return fmt.Errorf("no API token selected") + } + rule, err := u.apiPolicyRuleFromEditor() + if err != nil { + return err + } if !uiHasPolicyRule(token.Policies, rule) { token.Policies = append(token.Policies, rule) } @@ -290,6 +315,63 @@ func (u *ui) addAPIPolicyRuleAction() error { return nil } +func (u *ui) editAPIPolicyRuleAction(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) + } + rule := token.Policies[index] + u.selectedAPIPolicyIndex = index + u.apiPolicyOperation.SetText(string(rule.Operation)) + u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow + if rule.Resource.Kind == apitokens.ResourceEntry { + u.apiPolicyGroupScope = false + u.apiPolicyGroupScopeW.Value = false + u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID)) + u.apiPolicyPath.SetText("") + return nil + } + u.apiPolicyGroupScope = true + u.apiPolicyGroupScopeW.Value = true + u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / ")) + u.apiPolicyEntryID.SetText("") + return nil +} + +func (u *ui) saveAPIPolicyRuleAction() error { + token, ok := u.selectedAPIToken() + if !ok { + return fmt.Errorf("no API token selected") + } + index := u.selectedAPIPolicyIndex + if index < 0 || index >= len(token.Policies) { + return fmt.Errorf("no API policy rule selected") + } + rule, err := u.apiPolicyRuleFromEditor() + if err != nil { + return err + } + for i, existing := range token.Policies { + if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) { + token.Policies = append(token.Policies[:index], token.Policies[index+1:]...) + if err := u.state.UpsertAPIToken(token); err != nil { + return err + } + u.loadSelectedAPITokenIntoEditor() + return nil + } + } + token.Policies[index] = rule + if err := u.state.UpsertAPIToken(token); err != nil { + return err + } + u.loadSelectedAPITokenIntoEditor() + return nil +} + func (u *ui) apiPolicyGroupPathSummary() string { path := parsePath(u.apiPolicyPath.Text()) if len(path) == 0 { @@ -357,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error { return nil } +func (u *ui) cancelAPIPolicyEditAction() error { + u.loadSelectedAPITokenIntoEditor() + return nil +} + func (u *ui) apiAuditEvents() []apiaudit.Event { if u.auditLog == nil { return nil @@ -749,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable, func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { token, ok := u.selectedAPIToken() + editClicks := u.ensureAPIPolicyEditClickables(0) removeClicks := u.ensureAPIPolicyRemoveClickables(0) if ok { + editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies)) removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies)) } rows := []layout.Widget{ @@ -918,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, detailLine(u.theme, "Effect", effect)), layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &editClicks[index], "Edit") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &removeClicks[index], "Remove") }), @@ -951,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return card(gtx, func(gtx layout.Context) layout.Dimensions { + actionLabel := "Add Rule" + title := "Policy Composer" + description := "Rules are evaluated per operation. Explicit deny rules override allow rules." + if 0 <= u.selectedAPIPolicyIndex { + actionLabel = "Save Rule" + title = "Policy Editor" + description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer." + } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer") + lbl := material.Label(u.theme, unit.Sp(14), title) lbl.Color = accentColor 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(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.") + lbl := material.Label(u.theme, unit.Sp(12), description) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -1014,7 +1115,22 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule") + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if 0 <= u.selectedAPIPolicyIndex { + return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel) + } + return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.selectedAPIPolicyIndex < 0 { + return layout.Dimensions{} + } + return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit") + }) + }), + ) }), ) }) diff --git a/internal/appui/app.go b/internal/appui/app.go index 0e7f1cf..9ed6b6d 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -364,12 +364,13 @@ type ui struct { showAutofillApprovalAsk widget.Clickable showAutofillApprovalAllow widget.Clickable showAutofillApprovalBlock widget.Clickable - allowApproval widget.Clickable - denyApproval widget.Clickable + allowApprovalOnce widget.Clickable + allowApprovalPermanent widget.Clickable + denyApprovalOnce widget.Clickable + denyApprovalPermanent widget.Clickable cancelApproval widget.Clickable cancelLifecycleProgress widget.Clickable retryLifecycleOpen widget.Clickable - approvalPermanent widget.Bool syncSetupAutomatic widget.Bool apiPolicyAllow widget.Bool apiPolicyGroupScopeW widget.Bool @@ -381,6 +382,7 @@ type ui struct { settingsDebugHeaderBounds widget.Bool entryClicks []widget.Clickable apiTokenClicks []widget.Clickable + apiPolicyEdits []widget.Clickable apiPolicyRemoves []widget.Clickable apiAuditClicks []widget.Clickable apiAuditTokenFilters []widget.Clickable @@ -416,6 +418,8 @@ type ui struct { useSelectedEntryForPolicy widget.Clickable clearAPIPolicyTarget widget.Clickable addAPIPolicyRule widget.Clickable + saveAPIPolicyRule widget.Clickable + cancelAPIPolicyEdit widget.Clickable phoneSplit widget.Float splitDrag gesture.Drag splitBase float32 @@ -488,6 +492,7 @@ type ui struct { entriesState entriesSectionState deleteGroupPath []string apiPolicyGroupScope bool + selectedAPIPolicyIndex int apiTokenSecret string phoneSyncMenuOrigin image.Point phoneMainMenuOrigin image.Point @@ -665,6 +670,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) vaultSharer: platform.NewVaultSharer(runtime.GOOS), backgroundResults: make(chan backgroundActionResult, 8), phoneGroupBrowserExpanded: true, + selectedAPIPolicyIndex: -1, } if mode == "phone" { u.groupControlsHidden = true @@ -1431,23 +1437,33 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent") - check.Color = accentColor - return check.Layout(gtx) - }), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.allowApproval, "Allow") + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.allowApprovalOnce, "Allow Once") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.allowApprovalPermanent, "Allow Permanently") + }), + ) }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.denyApproval, "Deny") + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.denyApprovalOnce, "Deny Once") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.denyApprovalPermanent, "Deny Permanently") + }), + ) }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel") }), diff --git a/internal/appui/frame.go b/internal/appui/frame.go index b3137cf..cee50f6 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -928,33 +928,29 @@ func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) { } func (u *ui) handleApprovalClicks(gtx layout.Context) { - for u.allowApproval.Clicked(gtx) { + for u.allowApprovalOnce.Clicked(gtx) { u.runAction("allow API request", func() error { - outcome := apiapproval.OutcomeAllowOnce - if u.approvalPermanent.Value { - outcome = apiapproval.OutcomeAllowPermanent - } - err := u.resolvePendingApproval(outcome) - u.approvalPermanent.Value = false - return err + return u.resolvePendingApproval(apiapproval.OutcomeAllowOnce) }) } - for u.denyApproval.Clicked(gtx) { + for u.allowApprovalPermanent.Clicked(gtx) { + u.runAction("allow API request permanently", func() error { + return u.resolvePendingApproval(apiapproval.OutcomeAllowPermanent) + }) + } + for u.denyApprovalOnce.Clicked(gtx) { u.runAction("deny API request", func() error { - outcome := apiapproval.OutcomeDenyOnce - if u.approvalPermanent.Value { - outcome = apiapproval.OutcomeDenyPermanent - } - err := u.resolvePendingApproval(outcome) - u.approvalPermanent.Value = false - return err + return u.resolvePendingApproval(apiapproval.OutcomeDenyOnce) + }) + } + for u.denyApprovalPermanent.Clicked(gtx) { + u.runAction("deny API request permanently", func() error { + return u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent) }) } for u.cancelApproval.Clicked(gtx) { u.runAction("cancel API request", func() error { - err := u.resolvePendingApproval(apiapproval.OutcomeCancel) - u.approvalPermanent.Value = false - return err + return u.resolvePendingApproval(apiapproval.OutcomeCancel) }) } } @@ -996,6 +992,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { for u.addAPIPolicyRule.Clicked(gtx) { u.runAction("add API policy rule", u.addAPIPolicyRuleAction) } + for u.saveAPIPolicyRule.Clicked(gtx) { + u.runAction("save API policy rule", u.saveAPIPolicyRuleAction) + } + for u.cancelAPIPolicyEdit.Clicked(gtx) { + u.runAction("cancel API policy edit", u.cancelAPIPolicyEditAction) + } for u.useCurrentGroupForPolicy.Clicked(gtx) { u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction) } @@ -1005,6 +1007,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) { for u.clearAPIPolicyTarget.Clicked(gtx) { u.runAction("clear API policy target", u.clearAPIPolicyTargetAction) } + for i := range u.apiPolicyEdits { + for u.apiPolicyEdits[i].Clicked(gtx) { + index := i + u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) }) + } + } for i := range u.apiPolicyRemoves { for u.apiPolicyRemoves[i].Clicked(gtx) { index := i diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 8ded18e..54684dc 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -754,6 +754,23 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) { t.Fatal("apiTokenSecret after rotate = empty, want one-time secret") } + u.apiTokenName.SetText("Browser Extension Updated") + u.apiTokenClientName.SetText("firefox-desktop") + u.apiTokenExpiresAt.SetText("2026-05-01T00:00:00Z") + if err := u.saveAPITokenAction(); err != nil { + t.Fatalf("saveAPITokenAction() error = %v", err) + } + updated, ok := u.selectedAPIToken() + if !ok { + t.Fatal("selectedAPIToken() ok = false, want true after save") + } + if updated.Name != "Browser Extension Updated" || updated.ClientName != "firefox-desktop" { + t.Fatalf("updated token = %#v, want renamed/firefox-desktop", updated) + } + if updated.ExpiresAt == nil || updated.ExpiresAt.UTC().Format(time.RFC3339) != "2026-05-01T00:00:00Z" { + t.Fatalf("updated.ExpiresAt = %#v, want 2026-05-01T00:00:00Z", updated.ExpiresAt) + } + if err := u.disableAPITokenAction(); err != nil { t.Fatalf("disableAPITokenAction() error = %v", err) } @@ -761,9 +778,17 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) { if !ok || !disabled.Disabled { t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled) } + + if err := u.revokeAPITokenAction(); err != nil { + t.Fatalf("revokeAPITokenAction() error = %v", err) + } + revoked, ok := u.selectedAPIToken() + if !ok || revoked.RevokedAt == nil { + t.Fatalf("selectedAPIToken() = %#v, want revoked token", revoked) + } } -func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) { +func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(t *testing.T) { t.Parallel() u := newUIWithSession("desktop", &session.Manager{}, statePaths{ @@ -799,6 +824,33 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) { if token.Policies[0].Resource.Kind != apitokens.ResourceGroup { t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind) } + if len(u.apiPolicyEdits) != 1 { + t.Fatalf("len(apiPolicyEdits) = %d, want 1", len(u.apiPolicyEdits)) + } + + u.apiPolicyEdits[0].Click() + u.handleAPIPolicyClicks(layout.Context{}) + if u.selectedAPIPolicyIndex != 0 { + t.Fatalf("selectedAPIPolicyIndex = %d, want 0 after edit click", u.selectedAPIPolicyIndex) + } + if got := u.apiPolicyPath.Text(); got != "Root / Internet" { + t.Fatalf("apiPolicyPath = %q, want Root / Internet after edit load", got) + } + + u.apiPolicyPath.SetText("Root / Security") + u.saveAPIPolicyRule.Click() + u.handleAPIPolicyClicks(layout.Context{}) + + token, ok = u.selectedAPIToken() + if !ok || len(token.Policies) != 1 { + t.Fatalf("selectedAPIToken().Policies after save = %#v, want 1 rule", token.Policies) + } + if got := strings.Join(token.Policies[0].Resource.Path, " / "); got != "Root / Security" { + t.Fatalf("updated policy path = %q, want Root / Security", got) + } + if u.selectedAPIPolicyIndex != -1 { + t.Fatalf("selectedAPIPolicyIndex after save = %d, want -1", u.selectedAPIPolicyIndex) + } if err := u.removeAPIPolicyRuleAction(0); err != nil { t.Fatalf("removeAPIPolicyRuleAction() error = %v", err) @@ -4858,6 +4910,112 @@ func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) { } } +func TestUIApprovalDialogVisibleForPendingRequest(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.state.Approvals = &mainStubApprovalManager{ + pending: []apiapproval.Request{{ + ID: "approval-1", + TokenName: "CLI", + ClientName: "grpc-cli", + Operation: apitokens.OperationReadEntry, + Resource: apitokens.Resource{ + Kind: apitokens.ResourceEntry, + EntryID: "vault-console", + }, + }}, + } + + dims := u.approvalDialogContent(layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(800, 600)), + }) + if dims.Size.X == 0 || dims.Size.Y == 0 { + t.Fatalf("approvalDialogContent() = %v, want visible dimensions for pending approval", dims.Size) + } +} + +func TestUIApprovalButtonsResolveAllOutcomes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + click func(*ui) + want apiapproval.Outcome + wantMsg string + }{ + { + name: "allow once", + click: func(u *ui) { + u.allowApprovalOnce.Click() + }, + want: apiapproval.OutcomeAllowOnce, + wantMsg: "allow API request complete", + }, + { + name: "allow permanently", + click: func(u *ui) { + u.allowApprovalPermanent.Click() + }, + want: apiapproval.OutcomeAllowPermanent, + wantMsg: "allow API request permanently complete", + }, + { + name: "deny once", + click: func(u *ui) { + u.denyApprovalOnce.Click() + }, + want: apiapproval.OutcomeDenyOnce, + wantMsg: "deny API request complete", + }, + { + name: "deny permanently", + click: func(u *ui) { + u.denyApprovalPermanent.Click() + }, + want: apiapproval.OutcomeDenyPermanent, + wantMsg: "deny API request permanently complete", + }, + { + name: "cancel", + click: func(u *ui) { + u.cancelApproval.Click() + }, + want: apiapproval.OutcomeCancel, + wantMsg: "cancel API request complete", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + manager := &mainStubApprovalManager{ + pending: []apiapproval.Request{{ + ID: "approval-1", + TokenName: "CLI", + ClientName: "grpc-cli", + Operation: apitokens.OperationReadEntry, + }}, + } + u := newUIWithModel("desktop", vault.Model{}) + u.state.Approvals = manager + + tt.click(u) + u.handleApprovalClicks(layout.Context{}) + + if manager.lastID != "approval-1" || manager.lastOutcome != tt.want { + t.Fatalf("handleApprovalClicks() delegated (%q, %q), want (approval-1, %q)", manager.lastID, manager.lastOutcome, tt.want) + } + if got := u.state.StatusMessage; got != tt.wantMsg { + t.Fatalf("state.StatusMessage = %q, want %q", got, tt.wantMsg) + } + }) + } +} + func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) { t.Parallel()