From dba0bf1f2c8f20f3942105d17fbbf217dc6f8ca5 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 22:56:18 -0700 Subject: [PATCH] Authorize gRPC requests with vault API tokens --- api/server.go | 217 +++++++++++++++++++++++++++++++++++++------- api/server_test.go | 169 +++++++++++++++++++++++++++------- apitokens/tokens.go | 2 + 3 files changed, 321 insertions(+), 67 deletions(-) diff --git a/api/server.go b/api/server.go index 7f684aa..58a34a0 100644 --- a/api/server.go +++ b/api/server.go @@ -8,7 +8,9 @@ import ( "slices" "strings" "sync" + "time" + "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" @@ -190,23 +192,27 @@ func mapLifecycleError(operation string, err error) error { } } -func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) { +func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } + if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil { + return nil, err + } + model := s.visibleModel() var entries []vault.Entry if strings.TrimSpace(req.GetQuery()) != "" { - results := s.model.Search(req.GetQuery()) + results := model.Search(req.GetQuery()) entries = make([]vault.Entry, 0, len(results)) for _, result := range results { entries = append(entries, result.Entry) } } else { - entries = s.model.EntriesInPath(req.GetPath()) + entries = model.EntriesInPath(req.GetPath()) } resp := &keepassgov1.ListEntriesResponse{ @@ -219,23 +225,30 @@ func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequ return resp, nil } -func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) { +func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } + if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil { + return nil, err + } return &keepassgov1.ListGroupsResponse{ - Names: s.model.ChildGroups(req.GetPath()), + Names: s.visibleModel().ChildGroups(req.GetPath()), }, nil } -func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) { +func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) { s.mu.Lock() defer s.mu.Unlock() + if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil { + return nil, err + } + if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } @@ -245,10 +258,14 @@ func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequ return &keepassgov1.CreateGroupResponse{}, nil } -func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) { +func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) { s.mu.Lock() defer s.mu.Unlock() + if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil { + return nil, err + } + if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } @@ -264,10 +281,14 @@ 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) { +func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) { s.mu.Lock() defer s.mu.Unlock() + if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil { + return nil, err + } + if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } @@ -287,7 +308,7 @@ func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequ return &keepassgov1.DeleteGroupResponse{}, nil } -func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) { +func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) { if req.GetEntry() == nil { return nil, status.Error(codes.InvalidArgument, "missing entry") } @@ -299,6 +320,10 @@ func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequ s.mu.Unlock() return nil, status.Error(codes.FailedPrecondition, "vault is locked") } + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil { + s.mu.Unlock() + return nil, err + } s.model.UpsertEntry(entry) s.dirty = true s.mu.Unlock() @@ -306,13 +331,18 @@ func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequ return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil } -func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) { +func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) { s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } + if entry, err := findEntryByID(s.model, req.GetId()); err == nil { + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil { + return nil, err + } + } if err := s.model.DeleteEntry(req.GetId()); err != nil { if errors.Is(err, vault.ErrEntryNotFound) { @@ -325,7 +355,7 @@ func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequ return &keepassgov1.DeleteEntryResponse{}, nil } -func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) { +func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) { s.mu.Lock() defer s.mu.Unlock() @@ -340,6 +370,11 @@ func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRe break } } + if restored.ID != "" { + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, restored); err != nil { + return nil, err + } + } if err := s.model.RestoreEntry(req.GetId()); err != nil { if errors.Is(err, vault.ErrEntryNotFound) { @@ -352,7 +387,7 @@ func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRe return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil } -func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) { +func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -364,6 +399,9 @@ func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryH if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil { + return nil, err + } resp := &keepassgov1.ListEntryHistoryResponse{ Entries: make([]*keepassgov1.Entry, 0, len(entry.History)), @@ -374,13 +412,20 @@ func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryH return resp, nil } -func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) { +func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) { s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } + entry, err := findEntryByID(s.model, req.GetId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil { + return nil, err + } if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil { if errors.Is(err, vault.ErrEntryNotFound) { @@ -389,7 +434,7 @@ func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.Restore return nil, status.Errorf(codes.Internal, "restore entry history: %v", err) } - entry, err := findEntryByID(s.model, req.GetId()) + entry, err = findEntryByID(s.model, req.GetId()) if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } @@ -477,7 +522,7 @@ func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.Instant return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil } -func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) { +func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -489,6 +534,9 @@ func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachm if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil { + return nil, err + } names := make([]string, 0, len(entry.Attachments)) for name := range entry.Attachments { @@ -499,7 +547,7 @@ func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachm return &keepassgov1.ListAttachmentsResponse{Names: names}, nil } -func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) { +func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) { s.mu.Lock() defer s.mu.Unlock() @@ -511,6 +559,9 @@ func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAtta if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil { + return nil, err + } if entry.Attachments == nil { entry.Attachments = map[string][]byte{} @@ -522,7 +573,7 @@ func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAtta return &keepassgov1.UploadAttachmentResponse{}, nil } -func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) { +func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) { s.mu.RLock() defer s.mu.RUnlock() @@ -534,6 +585,9 @@ func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.Download if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil { + return nil, err + } content, ok := entry.Attachments[req.GetName()] if !ok { @@ -545,7 +599,7 @@ func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.Download }, nil } -func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) { +func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) { s.mu.Lock() defer s.mu.Unlock() @@ -557,6 +611,9 @@ func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAtta if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } + if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil { + return nil, err + } if _, ok := entry.Attachments[req.GetName()]; !ok { return nil, status.Error(codes.NotFound, "attachment not found") @@ -572,7 +629,7 @@ func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAtta return &keepassgov1.DeleteAttachmentResponse{}, nil } -func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) { +func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) { s.mu.RLock() model := s.model locked := s.locked @@ -581,6 +638,13 @@ func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFie if locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } + entry, err := findEntryByID(model, req.GetId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + if _, err := s.authorizeEntryRequest(ctx, copyOperation(req.GetTarget()), entry); err != nil { + return nil, err + } service := clipboard.Service{Writer: s.clipboard} if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil { @@ -597,10 +661,14 @@ func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFie return &keepassgov1.CopyEntryFieldResponse{}, nil } -func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) { +func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) { s.mu.RLock() defer s.mu.RUnlock() + if _, err := s.authenticateRequest(ctx); err != nil { + return nil, err + } + if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } @@ -665,27 +733,108 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro return vault.Entry{}, -1, vault.ErrEntryNotFound } -func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor { +func (s *Server) visibleModel() vault.Model { + out := s.model + out.Entries = nil + for _, entry := range s.model.Entries { + token, ok, err := apitokens.TokenFromEntry(entry) + if err == nil && ok && token.ID != "" { + continue + } + out.Entries = append(out.Entries, entry) + } + out.Groups = nil + for _, path := range s.model.Groups { + if len(path) >= 2 && path[0] == "Root" && path[1] == "API Tokens" { + continue + } + out.Groups = append(out.Groups, path) + } + return out +} + +var timeNow = func() time.Time { return time.Now().UTC() } + +func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing metadata") + } + values := md.Get("authorization") + if len(values) == 0 { + return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization") + } + const prefix = "Bearer " + if !strings.HasPrefix(values[0], prefix) { + return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token") + } + tokens, err := apitokens.Entries(s.model) + if err != nil { + return apitokens.Token{}, status.Errorf(codes.Internal, "load api tokens: %v", err) + } + token, err := apitokens.Authenticate(tokens, strings.TrimSpace(strings.TrimPrefix(values[0], prefix)), timeNow()) + if err != nil { + switch err { + case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken: + return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error()) + default: + return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err) + } + } + return token, nil +} + +func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operation, path []string) (apitokens.Token, error) { + token, err := s.authenticateRequest(ctx) + if err != nil { + return apitokens.Token{}, err + } + if apitokens.Evaluate(token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}) != apitokens.DecisionAllow { + return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token") + } + return token, nil +} + +func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) { + token, err := s.authenticateRequest(ctx) + if err != nil { + return apitokens.Token{}, err + } + if apitokens.Evaluate(token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}) != apitokens.DecisionAllow { + return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token") + } + return token, nil +} + +func copyOperation(target string) apitokens.Operation { + switch clipboard.Target(target) { + case clipboard.TargetUsername: + return apitokens.OperationCopyUsername + case clipboard.TargetURL: + return apitokens.OperationCopyURL + default: + return apitokens.OperationCopyPassword + } +} + +func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor { return func( ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, ) (any, error) { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, status.Error(codes.Unauthenticated, "missing metadata") + switch info.FullMethod { + case "/keepassgo.v1.VaultService/GetSessionStatus", + "/keepassgo.v1.VaultService/OpenVault", + "/keepassgo.v1.VaultService/OpenRemoteVault", + "/keepassgo.v1.VaultService/SaveVault", + "/keepassgo.v1.VaultService/LockVault", + "/keepassgo.v1.VaultService/UnlockVault": + if _, err := server.authenticateRequest(ctx); err != nil { + return nil, err + } } - - values := md.Get("authorization") - if len(values) == 0 { - return nil, status.Error(codes.Unauthenticated, "missing authorization") - } - - if values[0] != "Bearer "+expectedToken { - return nil, status.Error(codes.Unauthenticated, "invalid bearer token") - } - return handler(ctx, req) } } diff --git a/api/server_test.go b/api/server_test.go index bce8a88..4748be6 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -3,10 +3,14 @@ package api import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "net" "os" "testing" + "time" + "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/passwords" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" "git.julianfamily.org/keepassgo/session" @@ -32,6 +36,66 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) { } } +func TestVaultServiceRejectsExpiredAPITokens(t *testing.T) { + t.Parallel() + + expiredAt := time.Date(2026, 3, 29, 11, 59, 0, 0, time.UTC) + token, _, err := apitokens.Issue("Expired", "grpc-test", &expiredAt, time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + token.SecretHash = hashSecretForTest(defaultTestTokenSecret) + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + { + ID: "git-server", + Title: "Git Server", + Username: "joejulian", + Password: "token-1", + URL: "https://git.julianfamily.org", + Path: []string{"Root", "Internet"}, + }, + token.Entry([]string{"Root", "API Tokens"}), + }, + }) + defer cleanup() + + oldNow := timeNow + timeNow = func() time.Time { return time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) } + defer func() { timeNow = oldNow }() + + _, err = client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("ListEntries() code = %v, want %v for expired token", status.Code(err), codes.Unauthenticated) + } +} + +func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + { + ID: "git-server", + Title: "Git Server", + Username: "joejulian", + Password: "token-1", + URL: "https://git.julianfamily.org", + Path: []string{"Root", "Internet"}, + }, + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}}, + ), + }, + }) + defer cleanup() + + _, err := client.CopyEntryField(tokenContext(defaultTestTokenSecret), &keepassgov1.CopyEntryFieldRequest{Id: "git-server", Target: "password"}) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("CopyEntryField() code = %v, want %v", status.Code(err), codes.PermissionDenied) + } +} + func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { t.Parallel() @@ -52,7 +116,7 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) if err != nil { t.Fatalf("GetSessionStatus() error = %v", err) @@ -108,7 +172,7 @@ func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) { client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{ Path: "/tmp/test.kdbx", Password: lifecycle.unlockPassword, @@ -182,7 +246,7 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) { client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{ Path: "/tmp/test.kdbx", Password: "correct horse battery staple", @@ -228,7 +292,7 @@ func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) { client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) testCases := []struct { name string @@ -350,7 +414,7 @@ func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) { client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) err := tt.call(client, ctx) if status.Code(err) != tt.want { t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want) @@ -365,7 +429,7 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { t.Fatalf("ListEntries() error = %v", err) @@ -389,7 +453,7 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) if err != nil { @@ -436,7 +500,7 @@ func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) { client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{ ParentPath: []string{"Root"}, Name: "Finance", @@ -472,7 +536,7 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) { client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"}) if err != nil { t.Fatalf("GeneratePassword() error = %v", err) @@ -489,7 +553,7 @@ func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) { client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) _, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"}) if status.Code(err) != codes.InvalidArgument { t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument) @@ -502,7 +566,7 @@ func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) { client, clipboardWriter, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ Id: "git-server", Target: "password", @@ -521,7 +585,7 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) { client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ Entry: &keepassgov1.Entry{ Id: "ha-codex", @@ -565,7 +629,7 @@ func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "git-server"}); err != nil { t.Fatalf("DeleteEntry() error = %v", err) } @@ -604,7 +668,7 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) if err != nil { t.Fatalf("ListTemplates() error = %v", err) @@ -659,7 +723,7 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{ Template: &keepassgov1.Entry{ Id: "website-login", @@ -712,7 +776,7 @@ func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "git-server"}) if err != nil { t.Fatalf("ListEntryHistory() error = %v", err) @@ -739,7 +803,7 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) { client, _, cleanup := newTestClient(t) defer cleanup() - ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + ctx := tokenContext(defaultTestTokenSecret) uploaded := []byte("attachment-content") if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ @@ -785,14 +849,31 @@ func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) { } } +const defaultTestTokenSecret = "test-token" + +func tokenContext(secret string) context.Context { + return metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer "+secret) +} + +func hashSecretForTest(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +func testAPITokenEntry(t *testing.T, rules ...apitokens.PolicyRule) vault.Entry { + t.Helper() + token, _, err := apitokens.Issue("Test Client", "grpc-test", nil, time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + token.SecretHash = hashSecretForTest(defaultTestTokenSecret) + token.Policies = append([]apitokens.PolicyRule(nil), rules...) + return token.Entry([]string{"Root", "API Tokens"}) +} + func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { t.Helper() - - listener := bufconn.Listen(1024 * 1024) - server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) - clipboardWriter := &memoryClipboardWriter{} - keepassgov1.RegisterVaultServiceServer(server, NewServer( - vault.Model{ + model := vault.Model{ Entries: []vault.Entry{ { ID: "git-server", @@ -823,6 +904,17 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa URL: "https://lights.julianfamily.org", Path: []string{"Root", "Home Assistant"}, }, + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}}, + ), }, Templates: []vault.Entry{ { @@ -839,10 +931,18 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa Path: []string{"Templates"}, }, }, - }, - passwords.DefaultProfiles(), - clipboardWriter, - )) + } + return newTestClientForModel(t, model) +} + +func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { + t.Helper() + + listener := bufconn.Listen(1024 * 1024) + clipboardWriter := &memoryClipboardWriter{} + service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter) + server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service))) + keepassgov1.RegisterVaultServiceServer(server, service) go func() { _ = server.Serve(listener) @@ -870,14 +970,17 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass t.Helper() listener := bufconn.Listen(1024 * 1024) - server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) clipboardWriter := &memoryClipboardWriter{} - keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle( - lifecycle.model, - passwords.DefaultProfiles(), - clipboardWriter, - lifecycle, + model := lifecycle.model + model.Entries = append(model.Entries, testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, )) + lifecycle.model = model + service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle) + server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service))) + keepassgov1.RegisterVaultServiceServer(server, service) go func() { _ = server.Serve(listener) diff --git a/apitokens/tokens.go b/apitokens/tokens.go index 0ad9c05..39cc6bb 100644 --- a/apitokens/tokens.go +++ b/apitokens/tokens.go @@ -59,6 +59,8 @@ const ( OperationCopyUsername Operation = "copy_username" OperationCopyURL Operation = "copy_url" OperationMutateEntry Operation = "mutate_entry" + OperationMutateGroup Operation = "mutate_group" + OperationManageVault Operation = "manage_vault" ) type Resource struct {