diff --git a/internal/api/server.go b/internal/api/server.go index b52ea78..ec9da39 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -38,6 +38,7 @@ type Server struct { clipboard clipboard.Writer approvals *apiapproval.Broker audit *apiaudit.Log + notify func() } type lifecycleBackend interface { @@ -54,6 +55,13 @@ type modelReplaceableLifecycle interface { Replace(vault.Model) } +type rankedBrowserMatch struct { + match *keepassgov1.BrowserLoginMatch + score int + resource apitokens.Resource + decision apitokens.Decision +} + func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server { return &Server{ model: model, @@ -78,6 +86,15 @@ func (s *Server) AuditLog() *apiaudit.Log { return s.audit } +func (s *Server) SetChangeNotifier(notify func()) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.notify = notify +} + func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { return s.approvals.Resolve(id, outcome) } @@ -232,7 +249,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro if locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } - token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries) + token, err := s.authenticateRequest(ctx) if err != nil { return nil, err } @@ -241,17 +258,14 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro return nil, status.Error(codes.InvalidArgument, err.Error()) } - type rankedMatch struct { - match *keepassgov1.BrowserLoginMatch - score int - } - var matches []rankedMatch + var matches []rankedBrowserMatch for _, entry := range visibleModel(model).Entries { quality, score := classifyBrowserEntryMatch(pageHost, entry.URL) if score == 0 { continue } - matches = append(matches, rankedMatch{ + resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path} + matches = append(matches, rankedBrowserMatch{ match: &keepassgov1.BrowserLoginMatch{ Id: entry.ID, Title: entry.Title, @@ -260,10 +274,12 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro Path: append([]string(nil), entry.Path...), Quality: quality, }, - score: score, + score: score, + resource: resource, + decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource), }) } - slices.SortFunc(matches, func(a, b rankedMatch) int { + slices.SortFunc(matches, func(a, b rankedBrowserMatch) int { switch { case a.score != b.score: return b.score - a.score @@ -275,9 +291,9 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro return strings.Compare(a.match.GetId(), b.match.GetId()) } }) - out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches)) - for _, match := range matches { - out = append(out, match.match) + out, err := s.authorizedBrowserMatches(ctx, token, matches) + if err != nil { + return nil, err } switch len(out) { case 1: @@ -302,6 +318,41 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil } +func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) { + out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches)) + for _, match := range matches { + if match.decision == apitokens.DecisionAllow { + out = append(out, match.match) + } + } + if len(out) != 0 { + return out, nil + } + for _, match := range matches { + if match.decision != apitokens.DecisionPrompt { + continue + } + if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil { + return nil, err + } + return browserMatchesWithinPath(matches, match.resource.Path), nil + } + return out, nil +} + +func browserMatchesWithinPath(matches []rankedBrowserMatch, path []string) []*keepassgov1.BrowserLoginMatch { + out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches)) + for _, match := range matches { + if len(path) > len(match.resource.Path) { + continue + } + if slices.Equal(path, match.resource.Path[:len(path)]) { + out = append(out, match.match) + } + } + return out +} + func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) { model, locked := s.snapshotModel() if locked { @@ -427,6 +478,7 @@ func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRe s.model.CreateGroup(req.GetParentPath(), req.GetName()) s.dirty = true + s.syncMutationLocked() return &keepassgov1.CreateGroupResponse{}, nil } @@ -449,6 +501,7 @@ func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRe } s.dirty = true + s.syncMutationLocked() return &keepassgov1.RenameGroupResponse{}, nil } @@ -475,6 +528,7 @@ func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRe } s.dirty = true + s.syncMutationLocked() return &keepassgov1.DeleteGroupResponse{}, nil } @@ -495,6 +549,7 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe } s.model.UpsertEntry(entry) s.dirty = true + s.syncMutationLocked() s.mu.Unlock() return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil @@ -522,6 +577,7 @@ func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRe } s.dirty = true + s.syncMutationLocked() return &keepassgov1.DeleteEntryResponse{}, nil } @@ -555,6 +611,7 @@ func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntry } s.dirty = true + s.syncMutationLocked() return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil } @@ -608,6 +665,7 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto return nil, status.Error(codes.NotFound, err.Error()) } s.dirty = true + s.syncMutationLocked() return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil } @@ -650,6 +708,7 @@ func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemp entry := entryFromProto(req.GetTemplate()) s.model.UpsertTemplate(entry) s.dirty = true + s.syncMutationLocked() return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil } @@ -672,6 +731,7 @@ func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemp return nil, status.Errorf(codes.Internal, "delete template: %v", err) } s.dirty = true + s.syncMutationLocked() return &keepassgov1.DeleteTemplateResponse{}, nil } @@ -703,6 +763,7 @@ func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.Insta } s.dirty = true + s.syncMutationLocked() return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil } @@ -755,6 +816,7 @@ func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAt entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...) s.model.Entries[index] = entry s.dirty = true + s.syncMutationLocked() return &keepassgov1.UploadAttachmentResponse{}, nil } @@ -813,6 +875,7 @@ func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAt } s.model.Entries[index] = entry s.dirty = true + s.syncMutationLocked() return &keepassgov1.DeleteAttachmentResponse{}, nil } @@ -1140,14 +1203,21 @@ func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule) } s.model.Entries[i] = token.Entry(entry.Path) s.dirty = true - if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok { - lifecycle.Replace(s.model) - } + s.syncMutationLocked() return nil } return status.Error(codes.NotFound, "api token entry not found") } +func (s *Server) syncMutationLocked() { + if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok { + lifecycle.Replace(s.model) + } + if s.notify != nil { + s.notify() + } +} + func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool { for _, rule := range rules { if rule.Effect != target.Effect || rule.Operation != target.Operation { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index d02be90..ea17623 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -183,6 +183,48 @@ func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) { } } +func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + { + ID: "codex-nextcloud", + Title: "Nextcloud (codex)", + Username: "jjulian", + Password: "secret-1", + URL: "https://nextcloud.example.invalid", + Path: []string{"keepass", "Joe", "codex"}, + }, + { + ID: "joe-nextcloud", + Title: "Nextcloud", + Username: "jjulian", + Password: "secret-2", + URL: "https://nextcloud.example.invalid", + Path: []string{"keepass", "Joe", "Internet"}, + }, + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, + ), + }, + }) + defer cleanup() + + resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{ + PageUrl: "https://nextcloud.example.invalid/login", + }) + if err != nil { + t.Fatalf("FindBrowserLogins() error = %v", err) + } + if len(resp.Matches) != 1 { + t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches)) + } + if resp.Matches[0].Id != "codex-nextcloud" { + t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want codex-nextcloud", resp.Matches[0].Id) + } +} + func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) { t.Parallel() @@ -940,6 +982,60 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) { } } +func TestVaultServiceUpsertEntryUpdatesLifecycleModel(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + ), + }, + } + lifecycle := &stubLifecycle{model: model} + listener := bufconn.Listen(1024 * 1024) + clipboardWriter := &memoryClipboardWriter{} + service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle) + server := grpc.NewServer() + keepassgov1.RegisterVaultServiceServer(server, service) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + client := keepassgov1.NewVaultServiceClient(conn) + + _, err = client.UpsertEntry(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertEntryRequest{ + Entry: &keepassgov1.Entry{ + Id: "lifecycle-visible", + Title: "Lifecycle Visible", + Path: []string{"Root", "Internet"}, + }, + }) + if err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + current, err := lifecycle.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + if _, err := current.EntryByID("lifecycle-visible"); err != nil { + t.Fatalf("Current().EntryByID() error = %v, want persisted lifecycle-visible entry", err) + } +} + func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) { t.Parallel() diff --git a/internal/appui/runtime.go b/internal/appui/runtime.go index 95cbd88..612e287 100644 --- a/internal/appui/runtime.go +++ b/internal/appui/runtime.go @@ -76,6 +76,10 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { ui.state.AuditLog = ui.auditLog ui.grpcAddress = host.Address() ui.state.Approvals = &uiApprovalManager{server: host.Server()} + host.Server().SetChangeNotifier(func() { + ui.state.Dirty = true + ui.invalidate() + }) host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate) defer func() { _ = host.Stop() }() }