diff --git a/api/host.go b/api/host.go new file mode 100644 index 0000000..c0e67f1 --- /dev/null +++ b/api/host.go @@ -0,0 +1,122 @@ +package api + +import ( + "errors" + "fmt" + "net" + "strings" + "sync" + + "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/passwords" + keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" + "git.julianfamily.org/keepassgo/session" + "git.julianfamily.org/keepassgo/vault" + "google.golang.org/grpc" +) + +type DirtyProvider func() bool + +type Host struct { + server *Server + grpcServer *grpc.Server + listener net.Listener + lifecycle lifecycleBackend + dirty DirtyProvider + mu sync.Mutex + lastModel vault.Model + started bool + listenAddr string +} + +func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) { + addr = strings.TrimSpace(addr) + if addr == "" || strings.EqualFold(addr, "off") { + return nil, nil + } + + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err) + } + + service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle) + server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service))) + keepassgov1.RegisterVaultServiceServer(server, service) + + host := &Host{ + server: service, + grpcServer: server, + listener: listener, + lifecycle: lifecycle, + dirty: dirty, + listenAddr: listener.Addr().String(), + started: true, + } + if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) { + _ = listener.Close() + server.Stop() + return nil, err + } + + go func() { + _ = server.Serve(listener) + }() + + return host, nil +} + +func (h *Host) Address() string { + if h == nil { + return "" + } + return h.listenAddr +} + +func (h *Host) Server() *Server { + if h == nil { + return nil + } + return h.server +} + +func (h *Host) Stop() error { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + if !h.started { + return nil + } + h.started = false + h.grpcServer.Stop() + return h.listener.Close() +} + +func (h *Host) SyncFromLifecycle() error { + if h == nil || h.lifecycle == nil || h.server == nil { + return nil + } + + h.mu.Lock() + defer h.mu.Unlock() + + model, err := h.lifecycle.Current() + locked := false + switch { + case err == nil: + h.lastModel = model + case errors.Is(err, session.ErrLocked): + locked = true + default: + return err + } + + dirty := false + if h.dirty != nil { + dirty = h.dirty() + } + h.server.SetSessionState(h.lastModel, locked, dirty) + return nil +} diff --git a/api/host_test.go b/api/host_test.go new file mode 100644 index 0000000..0ab10c1 --- /dev/null +++ b/api/host_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "net" + "testing" + + "git.julianfamily.org/keepassgo/passwords" + keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" + "git.julianfamily.org/keepassgo/session" + "git.julianfamily.org/keepassgo/vault" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) { + t.Parallel() + + lifecycle := &session.Manager{} + if err := lifecycle.Create(vault.Model{ + Entries: []vault.Entry{ + testAPITokenEntry(t), + {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("Create() error = %v", err) + } + + host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true }) + if err != nil { + t.Fatalf("StartHost() error = %v", err) + } + defer func() { _ = host.Stop() }() + + conn, err := grpc.NewClient("passthrough:///"+host.Address(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return net.Dial("tcp", host.Address()) + }), + ) + if err != nil { + t.Fatalf("grpc.NewClient() error = %v", err) + } + defer func() { _ = conn.Close() }() + + client := keepassgov1.NewVaultServiceClient(conn) + statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() error = %v", err) + } + if statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = true, want false") + } + if !statusResp.Dirty { + t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider") + } + + if err := lifecycle.Lock(); err != nil { + t.Fatalf("Lock() error = %v", err) + } + if err := host.SyncFromLifecycle(); err != nil { + t.Fatalf("SyncFromLifecycle() after lock error = %v", err) + } + + statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() after lock error = %v", err) + } + if !statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock") + } +} diff --git a/api/server.go b/api/server.go index 4a11b23..22be4e6 100644 --- a/api/server.go +++ b/api/server.go @@ -77,9 +77,16 @@ func (s *Server) AuditLog() *apiaudit.Log { return s.audit } -func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, error) { - request, _, err := s.approvals.Resolve(id, outcome) - return request, err +func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { + return s.approvals.Resolve(id, outcome) +} + +func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.model = model + s.locked = locked + s.dirty = dirty } func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) { diff --git a/api/server_test.go b/api/server_test.go index 3aa3d6a..3a4d86e 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -131,7 +131,7 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) { if pending.Operation != apitokens.OperationListEntries { t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries) } - if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil { + if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil { t.Fatalf("ResolveApproval(allow) error = %v", err) } @@ -171,7 +171,7 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) { }() pending := waitForServerPendingApproval(t, service, 1)[0] - if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil { + if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil { t.Fatalf("ResolveApproval(deny permanent) error = %v", err) } @@ -211,7 +211,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) { }() pending := waitForServerPendingApproval(t, service, 1)[0] - if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil { + if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil { t.Fatalf("ResolveApproval(cancel) error = %v", err) } @@ -259,7 +259,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) { }() pending := waitForServerPendingApproval(t, service, 1)[0] - if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil { + if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil { t.Fatalf("ResolveApproval(allow permanent) error = %v", err) } if err := <-errCh; err != nil { diff --git a/appstate/state.go b/appstate/state.go index c3383f9..07343c4 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -24,6 +24,8 @@ const ( SectionEntries Section = "" SectionTemplates Section = "templates" SectionRecycleBin Section = "recycle-bin" + SectionAPITokens Section = "api-tokens" + SectionAPIAudit Section = "api-audit" ) type CurrentSession interface { @@ -348,6 +350,8 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry { return slices.Clone(model.Templates) case SectionRecycleBin: return slices.Clone(model.RecycleBin) + case SectionAPITokens, SectionAPIAudit: + return nil default: return slices.Clone(model.Entries) } diff --git a/clipboard/service.go b/clipboard/service.go index c44aa98..7ef9ef7 100644 --- a/clipboard/service.go +++ b/clipboard/service.go @@ -52,6 +52,10 @@ func (s Service) writer() Writer { return systemWriter{} } +func WriteText(text string) error { + return systemWriter{}.WriteText(text) +} + func findEntry(model vault.Model, entryID string) (vault.Entry, error) { for _, entry := range model.Entries { if entry.ID == entryID { diff --git a/main.go b/main.go index 142fbb1..ca15489 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,8 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "git.julianfamily.org/keepassgo/api" + "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/appstate" @@ -128,6 +130,12 @@ type ui struct { remotePassword widget.Editor masterPassword widget.Editor keyFilePath widget.Editor + apiTokenName widget.Editor + apiTokenClientName widget.Editor + apiTokenExpiresAt widget.Editor + apiPolicyOperation widget.Editor + apiPolicyPath widget.Editor + apiPolicyEntryID widget.Editor entryID widget.Editor entryTitle widget.Editor entryUsername widget.Editor @@ -195,6 +203,8 @@ type ui struct { showEntries widget.Clickable showTemplates widget.Clickable showRecycle widget.Clickable + showAPITokens widget.Clickable + showAPIAudit widget.Clickable showLocalLifecycle widget.Clickable showRemoteLifecycle widget.Clickable showSyncLocal widget.Clickable @@ -206,7 +216,13 @@ type ui struct { cancelApproval widget.Clickable approvalPermanent widget.Bool rememberRemoteAuth widget.Bool + apiPolicyAllow widget.Bool + apiPolicyGroupScopeW widget.Bool + apiTokenDisabled widget.Bool entryClicks []widget.Clickable + apiTokenClicks []widget.Clickable + apiPolicyRemoves []widget.Clickable + apiAuditClicks []widget.Clickable historyClicks []widget.Clickable attachmentClicks []widget.Clickable breadcrumbs []widget.Clickable @@ -221,6 +237,14 @@ type ui struct { selectedHistoryIndex int showPassword bool togglePassword widget.Clickable + copyAPITokenSecret widget.Clickable + issueAPIToken widget.Clickable + saveAPIToken widget.Clickable + rotateAPIToken widget.Clickable + disableAPIToken widget.Clickable + revokeAPIToken widget.Clickable + deleteAPIToken widget.Clickable + addAPIPolicyRule widget.Clickable phoneSplit widget.Float splitDrag gesture.Drag splitBase float32 @@ -256,8 +280,14 @@ type ui struct { recentRemotes []recentRemoteRecord recentVaultGroups map[string][]string deleteGroupPath []string + apiPolicyGroupScope bool + apiTokenSecret string + selectedAuditIndex int statusExpiresAt time.Time now func() time.Time + apiHost *api.Host + auditLog *apiaudit.Log + grpcAddress string } var ( @@ -274,10 +304,6 @@ const ( errSaveAsPathRequired = "save-as path is required" ) -func newUI(mode string, paths statePaths) *ui { - return newUIWithSession(mode, &session.Manager{}, paths) -} - func newUIWithModel(mode string, model vault.Model) *ui { return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths("")) } @@ -317,6 +343,12 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'}, masterPassword: widget.Editor{SingleLine: true, Submit: false}, keyFilePath: widget.Editor{SingleLine: true, Submit: false}, + apiTokenName: widget.Editor{SingleLine: true, Submit: false}, + apiTokenClientName: widget.Editor{SingleLine: true, Submit: false}, + apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false}, + apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false}, + apiPolicyPath: widget.Editor{SingleLine: true, Submit: false}, + apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false}, entryID: widget.Editor{SingleLine: true, Submit: false}, entryTitle: widget.Editor{SingleLine: true, Submit: false}, entryUsername: widget.Editor{SingleLine: true, Submit: false}, @@ -343,6 +375,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) }, state: appstate.State{}, selectedHistoryIndex: -1, + selectedAuditIndex: -1, lifecycleMode: "local", defaultSaveAsPath: paths.DefaultSaveAsPath, recentVaultsPath: paths.RecentVaultsPath, @@ -352,7 +385,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) now: time.Now, syncSourceMode: syncSourceLocal, syncDirection: syncDirectionPull, + apiPolicyGroupScope: true, } + u.apiPolicyAllow.Value = true + u.apiPolicyGroupScopeW.Value = true u.state.Session = sess u.phoneSplit.Value = 0.46 u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility) @@ -478,6 +514,20 @@ func (u *ui) showRecycleBinSection() { u.filter() } +func (u *ui) showAPITokensSection() { + u.resetPasswordPeek() + u.state.ShowSection(appstate.SectionAPITokens) + u.loadSelectedAPITokenIntoEditor() + u.filter() +} + +func (u *ui) showAPIAuditSection() { + u.resetPasswordPeek() + u.state.ShowSection(appstate.SectionAPIAudit) + u.selectedAuditIndex = -1 + u.filter() +} + func (u *ui) resetPasswordPeek() { u.showPassword = false } @@ -1350,6 +1400,10 @@ func (u *ui) listEmptyMessage() string { } } switch u.state.Section { + case appstate.SectionAPITokens: + return "No API tokens match the current filter." + case appstate.SectionAPIAudit: + return "No API audit events match the current filter." case appstate.SectionTemplates: return "Templates are not available in this build." case appstate.SectionRecycleBin: @@ -1369,6 +1423,10 @@ func (u *ui) detailPlaceholderMessage() string { return "Complete the form to create a new item or update the current selection." } switch u.state.Section { + case appstate.SectionAPITokens: + return "Select an API token or issue a new one." + case appstate.SectionAPIAudit: + return "Select an audit event to inspect it." case appstate.SectionTemplates: return "Select a template or start a reusable entry." case appstate.SectionRecycleBin: @@ -1430,6 +1488,8 @@ func (u *ui) noteCurrentVaultPath() { } func (u *ui) layout(gtx layout.Context) layout.Dimensions { + u.syncHostedAPI() + u.filter() u.processShortcuts(gtx) for u.createVault.Clicked(gtx) { u.runAction("create vault", u.createVaultAction) @@ -1480,6 +1540,14 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.clearDeleteGroupConfirmation() u.showRecycleBinSection() } + for u.showAPITokens.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAPITokensSection() + } + for u.showAPIAudit.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAPIAuditSection() + } for u.showLocalLifecycle.Clicked(gtx) { u.lifecycleMode = "local" } @@ -1530,6 +1598,45 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.lockVault.Clicked(gtx) { u.runAction("lock vault", u.lockAction) } + for u.issueAPIToken.Clicked(gtx) { + u.runAction("issue API token", u.issueAPITokenAction) + } + for u.saveAPIToken.Clicked(gtx) { + u.runAction("save API token", u.saveAPITokenAction) + } + for u.rotateAPIToken.Clicked(gtx) { + u.runAction("rotate API token", u.rotateAPITokenAction) + } + for u.disableAPIToken.Clicked(gtx) { + u.runAction("disable API token", u.disableAPITokenAction) + } + for u.revokeAPIToken.Clicked(gtx) { + u.runAction("revoke API token", u.revokeAPITokenAction) + } + for u.deleteAPIToken.Clicked(gtx) { + u.runAction("delete API token", u.deleteAPITokenAction) + } + for u.addAPIPolicyRule.Clicked(gtx) { + u.runAction("add API policy rule", u.addAPIPolicyRuleAction) + } + for i := range u.apiPolicyRemoves { + for u.apiPolicyRemoves[i].Clicked(gtx) { + index := i + u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) }) + } + } + for u.copyAPITokenSecret.Clicked(gtx) { + secret := u.apiTokenSecret + u.runAction("copy API token secret", func() error { + if strings.TrimSpace(secret) == "" { + return fmt.Errorf("no API token secret to copy") + } + if u.clipboardWriter != nil { + return u.clipboardWriter.WriteText(secret) + } + return clipboard.WriteText(secret) + }) + } for u.editEntry.Clicked(gtx) { u.editingEntry = true u.loadSelectedEntryIntoEditor() @@ -1737,6 +1844,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) syncHostedAPI() { + if u.apiHost == nil { + return + } + if err := u.apiHost.SyncFromLifecycle(); err != nil { + u.state.ErrorMessage = fmt.Sprintf("sync gRPC API: %v", err) + } +} + func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { panel := card if u.mode == "phone" { @@ -2116,21 +2232,21 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() { + if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { return layout.Dimensions{} } return u.pathBar(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() { + if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupBar(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() { + if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } return u.groupControlsSection(gtx) @@ -2149,18 +2265,31 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { + if u.isVaultLocked() { return layout.Dimensions{} } - label := "Add Entry" - if u.mode == "phone" { - label = "+ " + label + switch u.state.Section { + case appstate.SectionEntries: + label := "Add Entry" + if u.mode == "phone" { + label = "+ " + label + } + btn := material.Button(u.theme, &u.addEntry, label) + return btn.Layout(gtx) + case appstate.SectionAPITokens: + return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") + default: + return layout.Dimensions{} } - btn := material.Button(u.theme, &u.addEntry, label) - return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAPITokens { + return u.apiTokenListPanel(gtx) + } + if u.state.Section == appstate.SectionAPIAudit { + return u.apiAuditListPanel(gtx) + } if len(u.visible) == 0 { lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage()) lbl.Color = mutedColor @@ -2209,6 +2338,24 @@ func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} return btn.Layout(gtx) }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.showAPITokens, "API Tokens") + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + btn.TextSize = unit.Sp(11) + btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.showAPIAudit, "API Audit") + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + btn.TextSize = unit.Sp(11) + btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} + return btn.Layout(gtx) + }), ) } @@ -2395,6 +2542,16 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { layout.Rigid(u.unlockPanel), } } + if u.state.Section == appstate.SectionAPITokens { + return []layout.FlexChild{ + layout.Flexed(1, u.apiTokenDetailPanel), + } + } + if u.state.Section == appstate.SectionAPIAudit { + return []layout.FlexChild{ + layout.Flexed(1, u.apiAuditDetailPanel), + } + } item, ok := u.selectedEntry() if !ok && !u.editingEntry { return []layout.FlexChild{ @@ -2913,10 +3070,12 @@ func fill(c color.NRGBA) layout.Widget { func main() { mode := flag.String("mode", "", "window mode: desktop or phone") stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets") + grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable") flag.Parse() resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") + resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS)) width := unit.Dp(1180) height := unit.Dp(760) @@ -2933,7 +3092,7 @@ func main() { options = append(options, app.Size(width, height)) } w.Option(options...) - if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir)); err != nil { + if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil { panic(err) } if !strings.EqualFold(runtime.GOOS, "android") { @@ -2943,9 +3102,27 @@ func main() { app.Main() } -func run(w *app.Window, mode string, paths statePaths) error { +func defaultGRPCAddr(goos string) string { + if strings.EqualFold(strings.TrimSpace(goos), "android") { + return "off" + } + return "127.0.0.1:47777" +} + +func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { var ops op.Ops - ui := newUI(mode, paths) + manager := &session.Manager{} + ui := newUIWithSession(mode, manager, paths) + host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) + if err != nil { + ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err) + } else if host != nil { + ui.apiHost = host + ui.auditLog = host.Server().AuditLog() + ui.grpcAddress = host.Address() + ui.state.Approvals = &uiApprovalManager{server: host.Server()} + defer func() { _ = host.Stop() }() + } for { e := w.Event() switch e := e.(type) { @@ -2959,6 +3136,24 @@ func run(w *app.Window, mode string, paths statePaths) error { } } +type uiApprovalManager struct { + server *api.Server +} + +func (m *uiApprovalManager) Pending() []apiapproval.Request { + if m == nil || m.server == nil { + return nil + } + return m.server.ApprovalBroker().Pending() +} + +func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { + if m == nil || m.server == nil { + return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured") + } + return m.server.ResolveApproval(id, outcome) +} + type uiSession struct { model vault.Model locked bool diff --git a/main_test.go b/main_test.go index e6783b2..b013c73 100644 --- a/main_test.go +++ b/main_test.go @@ -19,6 +19,7 @@ import ( "gioui.org/widget" "git.julianfamily.org/keepassgo/apiapproval" + "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" @@ -150,6 +151,114 @@ func TestUIChildGroupsComeFromVaultModel(t *testing.T) { } } +func TestUIAPITokenLifecycleManagement(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + + u.showAPITokensSection() + u.apiTokenName.SetText("Browser Extension") + u.apiTokenClientName.SetText("firefox") + u.apiTokenExpiresAt.SetText("2026-04-01T15:04:05Z") + + if err := u.issueAPITokenAction(); err != nil { + t.Fatalf("issueAPITokenAction() error = %v", err) + } + if strings.TrimSpace(u.apiTokenSecret) == "" { + t.Fatal("apiTokenSecret = empty, want one-time secret") + } + + tokens := u.apiTokens() + if len(tokens) != 1 { + t.Fatalf("len(apiTokens()) = %d, want 1", len(tokens)) + } + if tokens[0].Name != "Browser Extension" || tokens[0].ClientName != "firefox" { + t.Fatalf("issued token = %#v, want Browser Extension/firefox", tokens[0]) + } + + if err := u.rotateAPITokenAction(); err != nil { + t.Fatalf("rotateAPITokenAction() error = %v", err) + } + if strings.TrimSpace(u.apiTokenSecret) == "" { + t.Fatal("apiTokenSecret after rotate = empty, want one-time secret") + } + + if err := u.disableAPITokenAction(); err != nil { + t.Fatalf("disableAPITokenAction() error = %v", err) + } + disabled, ok := u.selectedAPIToken() + if !ok || !disabled.Disabled { + t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled) + } +} + +func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + + u.showAPITokensSection() + u.apiTokenName.SetText("CLI") + u.apiTokenClientName.SetText("grpc-cli") + if err := u.issueAPITokenAction(); err != nil { + t.Fatalf("issueAPITokenAction() error = %v", err) + } + + u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries)) + u.apiPolicyPath.SetText("Root / Internet") + u.apiPolicyAllow.Value = true + u.apiPolicyGroupScopeW.Value = true + if err := u.addAPIPolicyRuleAction(); err != nil { + t.Fatalf("addAPIPolicyRuleAction() error = %v", err) + } + + token, ok := u.selectedAPIToken() + if !ok || len(token.Policies) != 1 { + t.Fatalf("selectedAPIToken().Policies = %#v, want 1 rule", token.Policies) + } + if token.Policies[0].Resource.Kind != apitokens.ResourceGroup { + t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind) + } + + if err := u.removeAPIPolicyRuleAction(0); err != nil { + t.Fatalf("removeAPIPolicyRuleAction() error = %v", err) + } + token, ok = u.selectedAPIToken() + if !ok || len(token.Policies) != 0 { + t.Fatalf("selectedAPIToken().Policies after remove = %#v, want empty", token.Policies) + } +} + +func TestUIAPIAuditSectionShowsRecordedEvents(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.auditLog = apiaudit.New(10) + u.auditLog.Record(apiaudit.Event{ + Type: apiaudit.EventApprovalAllowed, + TokenName: "Browser Extension", + ClientName: "firefox", + Message: "approved", + }) + + u.showAPIAuditSection() + events := u.apiAuditEvents() + if len(events) != 1 { + t.Fatalf("len(apiAuditEvents()) = %d, want 1", len(events)) + } + if events[0].TokenName != "Browser Extension" { + t.Fatalf("apiAuditEvents()[0].TokenName = %q, want %q", events[0].TokenName, "Browser Extension") + } +} + func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) { t.Parallel() diff --git a/ui_api.go b/ui_api.go new file mode 100644 index 0000000..1a2fe49 --- /dev/null +++ b/ui_api.go @@ -0,0 +1,639 @@ +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) 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.apiPolicyRemoves = nil + 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 + if len(u.apiPolicyRemoves) < len(token.Policies) { + u.apiPolicyRemoves = make([]widget.Clickable, 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() + 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, &u.apiPolicyRemoves[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, " / ") +}