Authorize gRPC requests with vault API tokens
This commit is contained in:
+183
-34
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+136
-33
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user