Host the gRPC API and add token admin views
This commit is contained in:
+122
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+109
@@ -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()
|
||||
|
||||
|
||||
@@ -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, " / ")
|
||||
}
|
||||
Reference in New Issue
Block a user