Sync API mutations into shared session state

This commit is contained in:
Joe Julian
2026-04-11 10:26:55 -07:00
parent 852c115b2a
commit 0de682a3af
3 changed files with 185 additions and 15 deletions
+85 -15
View File
@@ -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 {