1336 lines
42 KiB
Go
1336 lines
42 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/url"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
|
"git.julianfamily.org/keepassgo/internal/session"
|
|
"git.julianfamily.org/keepassgo/internal/vault"
|
|
"git.julianfamily.org/keepassgo/internal/vaultview"
|
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
type Server struct {
|
|
keepassgov1.UnimplementedVaultServiceServer
|
|
|
|
mu sync.RWMutex
|
|
model vault.Model
|
|
locked bool
|
|
dirty bool
|
|
lifecycle lifecycleBackend
|
|
profiles map[string]passwords.Profile
|
|
clipboard clipboard.Writer
|
|
approvals *apiapproval.Broker
|
|
audit *apiaudit.Log
|
|
notify func()
|
|
}
|
|
|
|
type lifecycleBackend interface {
|
|
Current() (vault.Model, error)
|
|
Open(string, vault.MasterKey) error
|
|
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
|
Save() error
|
|
Lock() error
|
|
Unlock(vault.MasterKey) error
|
|
}
|
|
|
|
type modelReplaceableLifecycle interface {
|
|
lifecycleBackend
|
|
Replace(vault.Model)
|
|
}
|
|
|
|
type rankedBrowserMatch struct {
|
|
match *keepassgov1.BrowserLoginMatch
|
|
score int
|
|
resource apitokens.Resource
|
|
decision apitokens.Decision
|
|
}
|
|
|
|
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
|
|
return &Server{
|
|
model: model,
|
|
profiles: profiles,
|
|
clipboard: clipboardWriter,
|
|
approvals: apiapproval.NewBroker(30 * time.Second),
|
|
audit: apiaudit.New(200),
|
|
}
|
|
}
|
|
|
|
func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, lifecycle lifecycleBackend) *Server {
|
|
server := NewServer(model, profiles, clipboardWriter)
|
|
server.lifecycle = lifecycle
|
|
return server
|
|
}
|
|
|
|
func (s *Server) ApprovalBroker() *apiapproval.Broker {
|
|
return s.approvals
|
|
}
|
|
|
|
func (s *Server) AuditLog() *apiaudit.Log {
|
|
return s.audit
|
|
}
|
|
|
|
func (s *Server) SetChangeNotifier(notify func()) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.notify = notify
|
|
}
|
|
|
|
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
|
return s.approvals.Resolve(id, outcome)
|
|
}
|
|
|
|
func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.model = model
|
|
s.locked = locked
|
|
s.dirty = dirty
|
|
}
|
|
|
|
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
|
if _, err := s.authenticateRequest(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return &keepassgov1.GetSessionStatusResponse{
|
|
Locked: s.locked,
|
|
Dirty: s.dirty,
|
|
EntryCount: uint32(len(s.model.Entries)),
|
|
}, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
|
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
|
|
return nil, mapLifecycleError("open vault", err)
|
|
}
|
|
|
|
model, err := s.lifecycle.Current()
|
|
if err != nil {
|
|
return nil, mapLifecycleError("load opened vault", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.model = model
|
|
s.locked = false
|
|
s.dirty = false
|
|
s.mu.Unlock()
|
|
|
|
return &keepassgov1.OpenVaultResponse{}, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
client := webdav.Client{
|
|
BaseURL: req.GetBaseUrl(),
|
|
Username: req.GetUsername(),
|
|
Password: req.GetPassword(),
|
|
}
|
|
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
|
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
|
|
return nil, mapLifecycleError("open remote vault", err)
|
|
}
|
|
|
|
model, err := s.lifecycle.Current()
|
|
if err != nil {
|
|
return nil, mapLifecycleError("load opened remote vault", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.model = model
|
|
s.locked = false
|
|
s.dirty = false
|
|
s.mu.Unlock()
|
|
|
|
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
if err := s.lifecycle.Save(); err != nil {
|
|
return nil, mapLifecycleError("save vault", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.dirty = false
|
|
s.mu.Unlock()
|
|
|
|
return &keepassgov1.SaveVaultResponse{}, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
if err := s.lifecycle.Lock(); err != nil {
|
|
return nil, mapLifecycleError("lock vault", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.locked = true
|
|
s.mu.Unlock()
|
|
|
|
return &keepassgov1.LockVaultResponse{}, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
|
|
if err := s.lifecycle.Unlock(key); err != nil {
|
|
return nil, mapLifecycleError("unlock vault", err)
|
|
}
|
|
|
|
model, err := s.lifecycle.Current()
|
|
if err != nil {
|
|
return nil, mapLifecycleError("load unlocked vault", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.model = model
|
|
s.locked = false
|
|
s.mu.Unlock()
|
|
|
|
return &keepassgov1.UnlockVaultResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
displayModel := visibleModel(model)
|
|
token, err := s.authenticateRequest(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
|
|
if err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
}
|
|
|
|
var matches []rankedBrowserMatch
|
|
for _, entry := range displayModel.Entries {
|
|
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
|
if score == 0 {
|
|
continue
|
|
}
|
|
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
|
|
matches = append(matches, rankedBrowserMatch{
|
|
match: &keepassgov1.BrowserLoginMatch{
|
|
Id: entry.ID,
|
|
Title: entry.Title,
|
|
Username: entry.Username,
|
|
Url: entry.URL,
|
|
Path: append([]string(nil), entry.Path...),
|
|
Quality: quality,
|
|
},
|
|
score: score,
|
|
resource: resource,
|
|
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource),
|
|
})
|
|
}
|
|
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
|
|
switch {
|
|
case a.score != b.score:
|
|
return b.score - a.score
|
|
case a.match.GetTitle() != b.match.GetTitle():
|
|
return strings.Compare(a.match.GetTitle(), b.match.GetTitle())
|
|
case a.match.GetUsername() != b.match.GetUsername():
|
|
return strings.Compare(a.match.GetUsername(), b.match.GetUsername())
|
|
default:
|
|
return strings.Compare(a.match.GetId(), b.match.GetId())
|
|
}
|
|
})
|
|
out, err := s.authorizedBrowserMatches(ctx, token, matches)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch len(out) {
|
|
case 1:
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventAutofillFound,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: apitokens.OperationListEntries,
|
|
Message: "browser login match found for " + pageHost,
|
|
})
|
|
case 2, 3, 4, 5:
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventAutofillAmbiguous,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: apitokens.OperationListEntries,
|
|
Message: "browser login search returned multiple matches for " + pageHost,
|
|
})
|
|
}
|
|
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
|
}
|
|
|
|
func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) {
|
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
|
for _, match := range matches {
|
|
if match.decision == apitokens.DecisionAllow {
|
|
out = append(out, match.match)
|
|
}
|
|
}
|
|
if len(out) != 0 {
|
|
return out, nil
|
|
}
|
|
for _, match := range matches {
|
|
if match.decision != apitokens.DecisionPrompt {
|
|
continue
|
|
}
|
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
|
|
return nil, err
|
|
}
|
|
return browserMatchesWithinPath(matches, match.resource.Path), nil
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func browserMatchesWithinPath(matches []rankedBrowserMatch, path []string) []*keepassgov1.BrowserLoginMatch {
|
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
|
for _, match := range matches {
|
|
if len(path) > len(match.resource.Path) {
|
|
continue
|
|
}
|
|
if slices.Equal(path, match.resource.Path[:len(path)]) {
|
|
out = append(out, match.match)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
token, err := s.authenticateRequest(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry, err := findEntryByID(model, req.GetId())
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
if pageURL := strings.TrimSpace(req.GetPageUrl()); pageURL != "" {
|
|
pageHost, err := normalizedBrowserHost(pageURL)
|
|
if err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
}
|
|
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
|
}
|
|
}
|
|
if strings.TrimSpace(entry.Username) != "" {
|
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(entry.URL) != "" {
|
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyURL, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventAutofillFound,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: apitokens.OperationCopyPassword,
|
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path},
|
|
Message: "browser credential returned for " + entry.ID,
|
|
})
|
|
return &keepassgov1.GetBrowserCredentialResponse{
|
|
Id: entry.ID,
|
|
Username: entry.Username,
|
|
Password: entry.Password,
|
|
Url: entry.URL,
|
|
}, nil
|
|
}
|
|
|
|
func mapLifecycleError(operation string, err error) error {
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
return status.Errorf(codes.NotFound, "%s: %v", operation, err)
|
|
case errors.Is(err, vault.ErrInvalidMasterKey):
|
|
return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err)
|
|
case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath):
|
|
return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err)
|
|
case errors.Is(err, webdav.ErrConflict):
|
|
return status.Errorf(codes.Aborted, "%s: %v", operation, err)
|
|
default:
|
|
return status.Errorf(codes.Internal, "%s: %v", operation, err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
displayModel := visibleModel(model)
|
|
internalPath := expandClientPath(displayModel, req.GetPath())
|
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
model = displayModel
|
|
var entries []vault.Entry
|
|
if strings.TrimSpace(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 = model.EntriesInPath(internalPath)
|
|
}
|
|
|
|
resp := &keepassgov1.ListEntriesResponse{
|
|
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
|
|
}
|
|
for _, entry := range entries {
|
|
resp.Entries = append(resp.Entries, entryToProtoWithModel(model, entry))
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
displayModel := visibleModel(model)
|
|
internalPath := expandClientPath(displayModel, req.GetPath())
|
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, internalPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &keepassgov1.ListGroupsResponse{
|
|
Names: displayModel.ChildGroups(internalPath),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
parentPath := expandClientPath(visibleModel(model), req.GetParentPath())
|
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, parentPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.model.CreateGroup(parentPath, req.GetName())
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
return &keepassgov1.CreateGroupResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
groupPath := expandClientPath(visibleModel(model), req.GetPath())
|
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.model.RenameGroup(groupPath, req.GetNewName()); err != nil {
|
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
|
|
}
|
|
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
return &keepassgov1.RenameGroupResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
groupPath := expandClientPath(visibleModel(model), req.GetPath())
|
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, groupPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.model.DeleteGroup(groupPath); 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
|
|
s.syncMutationLocked()
|
|
return &keepassgov1.DeleteGroupResponse{}, nil
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
entry := entryFromProtoWithModel(visibleModel(model), req.GetEntry())
|
|
if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.model.UpsertEntry(entry)
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
s.mu.Unlock()
|
|
|
|
return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil
|
|
}
|
|
|
|
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
if entry, err := findEntryByID(model, req.GetId()); err == nil {
|
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.model.DeleteEntry(req.GetId()); err != nil {
|
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "delete entry: %v", err)
|
|
}
|
|
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
return &keepassgov1.DeleteEntryResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
|
|
var restored vault.Entry
|
|
for _, entry := range model.RecycleBin {
|
|
if entry.ID == req.GetId() {
|
|
restored = entry
|
|
break
|
|
}
|
|
}
|
|
if restored.ID != "" {
|
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, restored); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.model.RestoreEntry(req.GetId()); err != nil {
|
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "restore entry: %v", err)
|
|
}
|
|
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
return &keepassgov1.RestoreEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), restored)}, nil
|
|
}
|
|
|
|
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
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, apitokens.OperationReadEntry, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := &keepassgov1.ListEntryHistoryResponse{
|
|
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
|
|
}
|
|
for _, historical := range entry.History {
|
|
resp.Entries = append(resp.Entries, entryToProtoWithModel(visibleModel(model), historical))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
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, apitokens.OperationMutateEntry, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
|
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
|
|
}
|
|
|
|
entry, err = findEntryByID(s.model, req.GetId())
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
|
|
}
|
|
|
|
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()
|
|
|
|
if s.locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
|
|
resp := &keepassgov1.ListTemplatesResponse{
|
|
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
|
|
}
|
|
for _, template := range s.model.Templates {
|
|
resp.Templates = append(resp.Templates, entryToProtoWithModel(visibleModel(s.model), template))
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
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()
|
|
|
|
if s.locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
|
|
entry := entryFromProtoWithModel(visibleModel(s.model), req.GetTemplate())
|
|
s.model.UpsertTemplate(entry)
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
|
|
return &keepassgov1.UpsertTemplateResponse{Template: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
|
|
}
|
|
|
|
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()
|
|
|
|
if s.locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
|
|
if err := s.model.DeleteTemplate(req.GetId()); err != nil {
|
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
|
|
}
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
|
|
return &keepassgov1.DeleteTemplateResponse{}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
overridePath := expandClientPath(visibleModel(s.model), req.GetOverrides().GetPath())
|
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, overridePath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
|
|
overrides := entryFromProtoWithModel(visibleModel(s.model), req.GetOverrides())
|
|
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), overrides)
|
|
if err != nil {
|
|
if errors.Is(err, vault.ErrEntryNotFound) {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "instantiate template: %v", err)
|
|
}
|
|
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProtoWithModel(visibleModel(s.model), entry)}, nil
|
|
}
|
|
|
|
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
|
|
entry, err := findEntryByID(model, req.GetEntryId())
|
|
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 {
|
|
names = append(names, name)
|
|
}
|
|
slices.Sort(names)
|
|
|
|
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
|
|
}
|
|
|
|
func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
entry, err := findEntryByID(model, req.GetEntryId())
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
|
|
if entry.Attachments == nil {
|
|
entry.Attachments = map[string][]byte{}
|
|
}
|
|
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
|
|
s.model.Entries[index] = entry
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
|
|
return &keepassgov1.UploadAttachmentResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
|
|
entry, err := findEntryByID(model, req.GetEntryId())
|
|
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 {
|
|
return nil, status.Error(codes.NotFound, "attachment not found")
|
|
}
|
|
|
|
return &keepassgov1.DownloadAttachmentResponse{
|
|
Content: append([]byte(nil), content...),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
entry, err := findEntryByID(model, req.GetEntryId())
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
}
|
|
|
|
if _, ok := entry.Attachments[req.GetName()]; !ok {
|
|
return nil, status.Error(codes.NotFound, "attachment not found")
|
|
}
|
|
|
|
delete(entry.Attachments, req.GetName())
|
|
if len(entry.Attachments) == 0 {
|
|
entry.Attachments = nil
|
|
}
|
|
s.model.Entries[index] = entry
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
|
|
return &keepassgov1.DeleteAttachmentResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
|
|
model, locked := s.snapshotModel()
|
|
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 {
|
|
switch {
|
|
case errors.Is(err, vault.ErrEntryNotFound):
|
|
return nil, status.Error(codes.NotFound, err.Error())
|
|
case errors.Is(err, clipboard.ErrUnsupportedTarget):
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
default:
|
|
return nil, status.Errorf(codes.Internal, "copy entry field: %v", err)
|
|
}
|
|
}
|
|
|
|
return &keepassgov1.CopyEntryFieldResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
|
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")
|
|
}
|
|
|
|
profile, err := passwords.LookupProfile(req.GetProfile(), s.profiles)
|
|
if err != nil {
|
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
|
}
|
|
|
|
password, err := passwords.Generate(profile)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "generate password: %v", err)
|
|
}
|
|
|
|
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
|
|
}
|
|
|
|
func entryToProtoWithModel(model vault.Model, entry vault.Entry) *keepassgov1.Entry {
|
|
return &keepassgov1.Entry{
|
|
Id: entry.ID,
|
|
Title: entry.Title,
|
|
Username: entry.Username,
|
|
Password: entry.Password,
|
|
Url: entry.URL,
|
|
Notes: entry.Notes,
|
|
Tags: append([]string(nil), entry.Tags...),
|
|
Path: collapseInternalPath(model, entry.Path),
|
|
Fields: maps.Clone(entry.Fields),
|
|
}
|
|
}
|
|
|
|
func entryFromProtoWithModel(model vault.Model, entry *keepassgov1.Entry) vault.Entry {
|
|
return vault.Entry{
|
|
ID: entry.GetId(),
|
|
Title: entry.GetTitle(),
|
|
Username: entry.GetUsername(),
|
|
Password: entry.GetPassword(),
|
|
URL: entry.GetUrl(),
|
|
Notes: entry.GetNotes(),
|
|
Tags: append([]string(nil), entry.GetTags()...),
|
|
Path: expandClientPath(model, entry.GetPath()),
|
|
Fields: maps.Clone(entry.GetFields()),
|
|
}
|
|
}
|
|
|
|
func expandClientPath(model vault.Model, path []string) []string {
|
|
root := vaultview.HiddenRoot(model)
|
|
if root == "" {
|
|
return append([]string(nil), path...)
|
|
}
|
|
if len(path) == 0 {
|
|
return []string{root}
|
|
}
|
|
if path[0] == root {
|
|
return append([]string(nil), path...)
|
|
}
|
|
return append([]string{root}, path...)
|
|
}
|
|
|
|
func collapseInternalPath(model vault.Model, path []string) []string {
|
|
root := vaultview.HiddenRoot(model)
|
|
if root == "" || len(path) == 0 || path[0] != root {
|
|
return append([]string(nil), path...)
|
|
}
|
|
return append([]string(nil), path[1:]...)
|
|
}
|
|
|
|
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
|
|
for _, entry := range model.Entries {
|
|
if entry.ID == id {
|
|
return entry, nil
|
|
}
|
|
}
|
|
return vault.Entry{}, vault.ErrEntryNotFound
|
|
}
|
|
|
|
func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, error) {
|
|
for i, entry := range model.Entries {
|
|
if entry.ID == id {
|
|
entry.Attachments = maps.Clone(entry.Attachments)
|
|
return entry, i, nil
|
|
}
|
|
}
|
|
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
|
}
|
|
|
|
func normalizedBrowserHost(raw string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse page url: %w", err)
|
|
}
|
|
host := strings.ToLower(parsed.Hostname())
|
|
if host == "" {
|
|
return "", fmt.Errorf("page url must include a hostname")
|
|
}
|
|
return host, nil
|
|
}
|
|
|
|
func normalizedBrowserEntryHost(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err == nil {
|
|
if host := strings.ToLower(parsed.Hostname()); host != "" {
|
|
return host
|
|
}
|
|
}
|
|
if !strings.Contains(raw, "://") {
|
|
parsed, err = url.Parse("https://" + raw)
|
|
if err == nil {
|
|
return strings.ToLower(parsed.Hostname())
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
|
entryHost := normalizedBrowserEntryHost(rawEntryURL)
|
|
if entryHost == "" {
|
|
return "", 0
|
|
}
|
|
switch {
|
|
case pageHost == entryHost:
|
|
return "exact-host", 3
|
|
case strings.HasSuffix(pageHost, "."+entryHost):
|
|
return "subdomain", 2
|
|
case strings.HasSuffix(entryHost, "."+pageHost):
|
|
return "parent-domain", 1
|
|
default:
|
|
return "", 0
|
|
}
|
|
}
|
|
|
|
func visibleModel(model vault.Model) vault.Model {
|
|
out := model
|
|
out.Entries = nil
|
|
for _, entry := range 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 model.Groups {
|
|
if len(path) >= 2 && path[0] == "Root" && path[1] == "API Tokens" {
|
|
continue
|
|
}
|
|
out.Groups = append(out.Groups, path)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) snapshotModel() (vault.Model, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.model, s.locked
|
|
}
|
|
|
|
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 {
|
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing metadata")
|
|
}
|
|
values := md.Get("authorization")
|
|
if len(values) == 0 {
|
|
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"})
|
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
|
|
}
|
|
const prefix = "Bearer "
|
|
if !strings.HasPrefix(values[0], prefix) {
|
|
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "invalid bearer token"})
|
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token")
|
|
}
|
|
s.mu.RLock()
|
|
tokens, err := apitokens.Entries(s.model)
|
|
s.mu.RUnlock()
|
|
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:
|
|
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: err.Error()})
|
|
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
|
|
}
|
|
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 {
|
|
return apitokens.Token{}, err
|
|
}
|
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
|
}
|
|
|
|
func (s *Server) authorizeUpsertEntryRequest(ctx context.Context, entry vault.Entry) (apitokens.Token, error) {
|
|
token, err := s.authenticateRequest(ctx)
|
|
if err != nil {
|
|
return apitokens.Token{}, err
|
|
}
|
|
model, locked := s.snapshotModel()
|
|
if locked {
|
|
return apitokens.Token{}, status.Error(codes.FailedPrecondition, "vault is locked")
|
|
}
|
|
existing, err := findEntryByID(model, entry.ID)
|
|
switch {
|
|
case err == nil:
|
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
|
|
Kind: apitokens.ResourceEntry,
|
|
EntryID: existing.ID,
|
|
Path: existing.Path,
|
|
}); err != nil {
|
|
return apitokens.Token{}, err
|
|
}
|
|
if !slices.Equal(existing.Path, entry.Path) {
|
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
|
|
Kind: apitokens.ResourceGroup,
|
|
Path: entry.Path,
|
|
}); err != nil {
|
|
return apitokens.Token{}, err
|
|
}
|
|
}
|
|
return token, nil
|
|
case errors.Is(err, vault.ErrEntryNotFound):
|
|
return s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{
|
|
Kind: apitokens.ResourceGroup,
|
|
Path: entry.Path,
|
|
})
|
|
default:
|
|
return apitokens.Token{}, status.Errorf(codes.Internal, "lookup existing entry: %v", err)
|
|
}
|
|
}
|
|
|
|
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:
|
|
return token, nil
|
|
case apitokens.DecisionDeny:
|
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
|
case apitokens.DecisionPrompt:
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalRequested,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: op,
|
|
Resource: resource,
|
|
})
|
|
result, err := s.approvals.Request(ctx, token, op, resource)
|
|
if result.Rule != nil {
|
|
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
|
|
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
|
|
}
|
|
}
|
|
switch {
|
|
case err == nil:
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalAllowed,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: op,
|
|
Resource: resource,
|
|
})
|
|
return token, nil
|
|
case errors.Is(err, apiapproval.ErrRequestDenied):
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalDenied,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: op,
|
|
Resource: resource,
|
|
})
|
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
|
|
case errors.Is(err, apiapproval.ErrRequestCanceled):
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalCanceled,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: op,
|
|
Resource: resource,
|
|
})
|
|
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
|
|
case errors.Is(err, apiapproval.ErrRequestTimedOut):
|
|
s.audit.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalTimedOut,
|
|
TokenID: token.ID,
|
|
TokenName: token.Name,
|
|
ClientName: token.ClientName,
|
|
Operation: op,
|
|
Resource: resource,
|
|
})
|
|
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
|
case errors.Is(err, context.Canceled):
|
|
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
|
default:
|
|
return apitokens.Token{}, status.Errorf(codes.Internal, "await authorization request: %v", err)
|
|
}
|
|
default:
|
|
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
|
|
}
|
|
}
|
|
|
|
func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, entry := range s.model.Entries {
|
|
token, ok, err := apitokens.TokenFromEntry(entry)
|
|
if err != nil || !ok || token.ID != tokenID {
|
|
continue
|
|
}
|
|
if !hasPolicyRule(token.Policies, rule) {
|
|
token.Policies = append(token.Policies, rule)
|
|
}
|
|
s.model.Entries[i] = token.Entry(entry.Path)
|
|
s.dirty = true
|
|
s.syncMutationLocked()
|
|
return nil
|
|
}
|
|
return status.Error(codes.NotFound, "api token entry not found")
|
|
}
|
|
|
|
func (s *Server) syncMutationLocked() {
|
|
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
|
|
lifecycle.Replace(s.model)
|
|
}
|
|
if s.notify != nil {
|
|
s.notify()
|
|
}
|
|
}
|
|
|
|
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
|
|
for _, rule := range rules {
|
|
if rule.Effect != target.Effect || rule.Operation != target.Operation {
|
|
continue
|
|
}
|
|
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
|
|
continue
|
|
}
|
|
if slices.Equal(rule.Resource.Path, target.Resource.Path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|