Add gRPC group deletion and custom entry fields

This commit is contained in:
Joe Julian
2026-03-29 11:21:41 -07:00
parent 6c1ccdad16
commit 5a6ba8ff57
6 changed files with 456 additions and 210 deletions
+25
View File
@@ -264,6 +264,29 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return nil, status.Error(codes.FailedPrecondition, err.Error())
default:
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
}
}
s.dirty = true
return &keepassgov1.DeleteGroupResponse{}, nil
}
func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
@@ -605,6 +628,7 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry {
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
Fields: maps.Clone(entry.Fields),
}
}
@@ -618,6 +642,7 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Fields: maps.Clone(entry.GetFields()),
}
}
+78 -6
View File
@@ -378,6 +378,9 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
if resp.Entries[0].Title != "Vault Console" {
t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console")
}
if got := resp.Entries[0].Fields["X-Role"]; got != "automation" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation")
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
@@ -427,6 +430,42 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing
}
}
func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClient(t)
defer cleanup()
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{
ParentPath: []string{"Root"},
Name: "Finance",
}); err != nil {
t.Fatalf("CreateGroup() error = %v", err)
}
if _, err := client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
Path: []string{"Root", "Finance"},
}); err != nil {
t.Fatalf("DeleteGroup() error = %v, want success for empty group", err)
}
listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}})
if err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" {
t.Fatalf("ListGroups().Names = %#v, want empty Finance group removed", listed.Names)
}
_, err = client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{
Path: []string{"Root", "Internet"},
})
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("DeleteGroup() code = %v, want %v for non-empty group", status.Code(err), codes.FailedPrecondition)
}
}
func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
t.Parallel()
@@ -477,7 +516,10 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
Username: "codex",
Password: "token-2",
Url: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
Fields: map[string]string{
"X-Role": "lights-admin",
},
Path: []string{"Root", "Home Assistant"},
},
})
if err != nil {
@@ -487,6 +529,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
if upserted.Entry.Title != "Surveillance Console" {
t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console")
}
if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}})
if err != nil {
@@ -496,6 +541,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) {
if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" {
t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries)
}
if got := listed.Entries[0].Fields["X-Role"]; got != "lights-admin" {
t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "lights-admin")
}
}
func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) {
@@ -552,6 +600,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" {
t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates)
}
if got := templates.Templates[0].Fields["Environment"]; got != "prod" {
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "prod")
}
instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{
TemplateId: "website-login",
@@ -561,8 +612,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
Username: "rustyryan",
Password: "hunter2",
Url: "https://bellagio.example.invalid",
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
Fields: map[string]string{
"Environment": "staging",
},
Path: []string{"Root", "Internet"},
Tags: []string{"dns"},
},
})
if err != nil {
@@ -572,6 +626,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin
if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." {
t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry)
}
if got := instantiated.Entry.Fields["Environment"]; got != "staging" {
t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging")
}
listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
if err != nil {
@@ -596,7 +653,10 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
Title: "Website Login Updated",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
Fields: map[string]string{
"Environment": "dev",
},
Path: []string{"Templates", "Web"},
},
})
if err != nil {
@@ -605,6 +665,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
if upserted.Template.Title != "Website Login Updated" {
t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title)
}
if got := upserted.Template.Fields["Environment"]; got != "dev" {
t.Fatalf("UpsertTemplate().Template.Fields[Environment] = %q, want %q", got, "dev")
}
listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{})
if err != nil {
@@ -613,6 +676,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T
if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" {
t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates)
}
if got := listed.Templates[0].Fields["Environment"]; got != "dev" {
t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "dev")
}
if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
@@ -721,6 +787,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Fields: map[string]string{
"X-Role": "automation",
},
History: []vault.Entry{
{
ID: "vault-console-h1",
@@ -750,8 +819,11 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Tags: []string{"template", "web"},
Path: []string{"Templates"},
Fields: map[string]string{
"Environment": "prod",
},
Tags: []string{"template", "web"},
Path: []string{"Templates"},
},
},
},
File diff suppressed because it is too large Load Diff
+8
View File
@@ -15,6 +15,7 @@ service VaultService {
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse);
rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse);
rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse);
rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse);
@@ -88,6 +89,7 @@ message Entry {
string notes = 6;
repeated string tags = 7;
repeated string path = 8;
map<string, string> fields = 9;
}
message ListEntriesResponse {
@@ -116,6 +118,12 @@ message RenameGroupRequest {
message RenameGroupResponse {}
message DeleteGroupRequest {
repeated string path = 1;
}
message DeleteGroupResponse {}
message UpsertEntryRequest {
Entry entry = 1;
}
+38
View File
@@ -29,6 +29,7 @@ const (
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
@@ -60,6 +61,7 @@ type VaultServiceClient interface {
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error)
DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error)
UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error)
DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error)
RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error)
@@ -185,6 +187,16 @@ func (c *vaultServiceClient) RenameGroup(ctx context.Context, in *RenameGroupReq
return out, nil
}
func (c *vaultServiceClient) DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteGroupResponse)
err := c.cc.Invoke(ctx, VaultService_DeleteGroup_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpsertEntryResponse)
@@ -349,6 +361,7 @@ type VaultServiceServer interface {
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error)
DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error)
UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error)
DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error)
RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error)
@@ -404,6 +417,9 @@ func (UnimplementedVaultServiceServer) CreateGroup(context.Context, *CreateGroup
func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented")
}
func (UnimplementedVaultServiceServer) DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteGroup not implemented")
}
func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented")
}
@@ -650,6 +666,24 @@ func _VaultService_RenameGroup_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _VaultService_DeleteGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteGroupRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VaultServiceServer).DeleteGroup(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: VaultService_DeleteGroup_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VaultServiceServer).DeleteGroup(ctx, req.(*DeleteGroupRequest))
}
return interceptor(ctx, in, info, handler)
}
func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertEntryRequest)
if err := dec(in); err != nil {
@@ -967,6 +1001,10 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RenameGroup",
Handler: _VaultService_RenameGroup_Handler,
},
{
MethodName: "DeleteGroup",
Handler: _VaultService_DeleteGroup_Handler,
},
{
MethodName: "UpsertEntry",
Handler: _VaultService_UpsertEntry_Handler,
+13 -12
View File
@@ -7,19 +7,20 @@ import (
)
var ErrEntryNotFound = errors.New("entry not found")
var ErrGroupNotEmpty = errors.New("group is not empty")
type Entry struct {
ID string
Title string
Username string
Password string
URL string
Notes string
Tags []string
Fields map[string]string
ID string
Title string
Username string
Password string
URL string
Notes string
Tags []string
Fields map[string]string
Attachments map[string][]byte
History []Entry
Path []string
History []Entry
Path []string
}
type SearchResult struct {
@@ -323,12 +324,12 @@ func (m *Model) MoveTemplate(id string, path []string) error {
func (m *Model) DeleteGroup(path []string) error {
for _, entry := range m.Entries {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return errors.New("group is not empty")
return ErrGroupNotEmpty
}
}
for _, entry := range m.Templates {
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
return errors.New("group is not empty")
return ErrGroupNotEmpty
}
}