diff --git a/internal/api/server.go b/internal/api/server.go index 7daa9d6..8840710 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -89,7 +89,10 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) { s.dirty = dirty } -func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) { +func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) { + if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil { + return nil, err + } s.mu.RLock() defer s.mu.RUnlock() @@ -100,7 +103,10 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt }, nil } -func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) { +func (s *Server) OpenVault(ctx context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) { + if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil { + return nil, err + } if s.lifecycle == nil { return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") } @@ -124,7 +130,10 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) return &keepassgov1.OpenVaultResponse{}, nil } -func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) { +func (s *Server) OpenRemoteVault(ctx context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) { + if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil { + return nil, err + } if s.lifecycle == nil { return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") } @@ -153,7 +162,10 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV return &keepassgov1.OpenRemoteVaultResponse{}, nil } -func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) { +func (s *Server) SaveVault(ctx context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) { + if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil { + return nil, err + } if s.lifecycle == nil { return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") } @@ -169,7 +181,10 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) ( return &keepassgov1.SaveVaultResponse{}, nil } -func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) { +func (s *Server) LockVault(ctx context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) { + if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil { + return nil, err + } if s.lifecycle == nil { return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") } @@ -185,7 +200,10 @@ func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) ( return &keepassgov1.LockVaultResponse{}, nil } -func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { +func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { + if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil { + return nil, err + } if s.lifecycle == nil { return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") } @@ -465,7 +483,10 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil } -func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) { +func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) { + if _, err := s.authorizeTemplateCollectionRequest(ctx, apitokens.OperationListTemplates); err != nil { + return nil, err + } s.mu.RLock() defer s.mu.RUnlock() @@ -483,10 +504,13 @@ func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRe return resp, nil } -func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) { +func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) { if req.GetTemplate() == nil { return nil, status.Error(codes.InvalidArgument, "missing template") } + if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetTemplate().GetId()); err != nil { + return nil, err + } s.mu.Lock() defer s.mu.Unlock() @@ -502,7 +526,10 @@ func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTempla return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil } -func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) { +func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) { + if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetId()); err != nil { + return nil, err + } s.mu.Lock() defer s.mu.Unlock() @@ -521,10 +548,16 @@ func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTempla return &keepassgov1.DeleteTemplateResponse{}, nil } -func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) { +func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) { if req.GetOverrides() == nil { return nil, status.Error(codes.InvalidArgument, "missing overrides") } + if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil { + return nil, err + } + if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil { + return nil, err + } s.mu.Lock() defer s.mu.Unlock() @@ -685,12 +718,11 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF } 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 { + if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil { return nil, err } + s.mu.RLock() + defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") @@ -784,6 +816,11 @@ func (s *Server) snapshotModel() (vault.Model, bool) { var timeNow = func() time.Time { return time.Now().UTC() } +var ( + vaultPolicyPath = []string{"Root"} + templatePolicyPath = []string{"Root", "Templates"} +) + func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { @@ -826,6 +863,14 @@ func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operatio return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}) } +func (s *Server) authorizeVaultRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) { + return s.authorizePathRequest(ctx, op, vaultPolicyPath) +} + +func (s *Server) authorizeTemplateCollectionRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) { + return s.authorizePathRequest(ctx, op, templatePolicyPath) +} + func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) { token, err := s.authenticateRequest(ctx) if err != nil { @@ -834,6 +879,18 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}) } +func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) { + token, err := s.authenticateRequest(ctx) + if err != nil { + return apitokens.Token{}, err + } + return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{ + Kind: apitokens.ResourceEntry, + Path: templatePolicyPath, + EntryID: templateID, + }) +} + func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) { switch apitokens.Evaluate(token, op, resource) { case apitokens.DecisionAllow: diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 0dcf686..dfb1fb7 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -99,6 +99,66 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) { } } +func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + ), + }, + }) + defer cleanup() + + _, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{}) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied) + } +} + +func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, + ), + }, + Templates: []vault.Entry{ + {ID: "website-login", Title: "Website Login", Path: []string{"Templates"}}, + }, + }) + defer cleanup() + + _, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{ + Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"}, + }) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied) + } +} + +func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClientForModel(t, vault.Model{ + Entries: []vault.Entry{ + testAPITokenEntry(t, + apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + ), + }, + }) + defer cleanup() + + _, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"}) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied) + } +} + func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) { t.Parallel() @@ -1087,9 +1147,12 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa 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.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, 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.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, + apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}}, diff --git a/internal/apitokens/tokens.go b/internal/apitokens/tokens.go index b23ea60..abe2581 100644 --- a/internal/apitokens/tokens.go +++ b/internal/apitokens/tokens.go @@ -55,15 +55,18 @@ const ( DecisionDeny Decision = "deny" DecisionPrompt Decision = "prompt" - OperationListEntries Operation = "list_entries" - OperationListGroups Operation = "list_groups" - OperationReadEntry Operation = "read_entry" - OperationCopyPassword Operation = "copy_password" - OperationCopyUsername Operation = "copy_username" - OperationCopyURL Operation = "copy_url" - OperationMutateEntry Operation = "mutate_entry" - OperationMutateGroup Operation = "mutate_group" - OperationManageVault Operation = "manage_vault" + OperationListEntries Operation = "list_entries" + OperationListGroups Operation = "list_groups" + OperationListTemplates Operation = "list_templates" + OperationReadEntry Operation = "read_entry" + OperationCopyPassword Operation = "copy_password" + OperationCopyUsername Operation = "copy_username" + OperationCopyURL Operation = "copy_url" + OperationMutateEntry Operation = "mutate_entry" + OperationMutateGroup Operation = "mutate_group" + OperationMutateTemplate Operation = "mutate_template" + OperationGeneratePassword Operation = "generate_password" + OperationManageVault Operation = "manage_vault" ) type Resource struct { diff --git a/internal/appui/api/model.go b/internal/appui/api/model.go index 3ccb55e..1ec92bc 100644 --- a/internal/appui/api/model.go +++ b/internal/appui/api/model.go @@ -16,12 +16,15 @@ func Operations() []apitokens.Operation { return []apitokens.Operation{ apitokens.OperationListEntries, apitokens.OperationListGroups, + apitokens.OperationListTemplates, apitokens.OperationReadEntry, apitokens.OperationCopyPassword, apitokens.OperationCopyUsername, apitokens.OperationCopyURL, apitokens.OperationMutateEntry, apitokens.OperationMutateGroup, + apitokens.OperationMutateTemplate, + apitokens.OperationGeneratePassword, apitokens.OperationManageVault, } }