Compare commits
18 Commits
d522af7d51
..
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 989b41735f | |||
| a88b8a824b | |||
| eccfb886ee | |||
| 6790399e24 | |||
| 9882d3fc04 | |||
| 59cd01f8e7 | |||
| ea30775eb7 | |||
| 0ce25a9712 | |||
| 32e6fc6c90 | |||
| e8a48fb7aa | |||
| 3b323ea4fd | |||
| 8117e3e8c1 | |||
| 77e92a2368 | |||
| 4b8c1de1a6 | |||
| af2ce66b78 | |||
| a02d4a3b1c | |||
| 57870ca4f1 | |||
| dc7dd19543 |
@@ -135,6 +135,7 @@ These features are product requirements, not “nice to have” ideas.
|
||||
|
||||
## Delivery Discipline
|
||||
|
||||
- Treat bug fixes as the highest-priority items in `TODO.md`.
|
||||
- Do not treat this product as complete until the stated requirements in this file are actually satisfied.
|
||||
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
|
||||
- Continue iterating in test-first slices:
|
||||
|
||||
@@ -46,15 +46,6 @@ feeling like the same application rather than three related UIs.
|
||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||
|
||||
- Browser extension:
|
||||
make browser autofill page-driven rather than popup-driven so the user does not need to manually open the extension to discover or use matches.
|
||||
- Browser extension:
|
||||
add per-page or per-tab match detection and visible state so the browser can indicate when KeePassGO has a candidate for the current login form.
|
||||
- Browser extension:
|
||||
persist page match state so opening the extension popup is not required multiple times for the same page and does not repeat the whole discovery workflow unnecessarily.
|
||||
- Browser extension:
|
||||
show inline field affordances on detected username and password inputs, with a candidate chooser anchored to the field, so browser autofill is selectable from the form itself like Android autofill.
|
||||
- Browser extension:
|
||||
treat missing visibility of a pending authorization request during browser autofill as a workflow failure and fix approval surfacing before expanding extension scope further.
|
||||
- Local open flow:
|
||||
make the start screen primarily about opening a vault, not configuring one.
|
||||
- Local open flow:
|
||||
|
||||
@@ -8,7 +8,7 @@ The Arch package installs this directory under `/usr/share/keepassgo/browser-ext
|
||||
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||
- `background.js` caches per-tab match state, updates the toolbar badge, keeps token-scoped approval state visible, and talks to the native messaging host `com.keepassgo.browser`
|
||||
- `content.js` fills username and password fields on the current page, keeps fills tied to the focused form when possible, and shows inline KeePassGO field affordances when matches exist
|
||||
- `options.html` stores the local gRPC address and API token in browser extension storage
|
||||
- `options.html` stores the API token in browser extension storage
|
||||
|
||||
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ const nativeHost = "com.keepassgo.browser";
|
||||
const isNodeTestEnv = typeof module !== "undefined" && module.exports;
|
||||
const usePromiseAPI = typeof globalThis.browser !== "undefined";
|
||||
const defaultSettings = {
|
||||
grpcAddress: "",
|
||||
bearerToken: ""
|
||||
};
|
||||
const pageStatePrefix = "keepassgo-page-state:";
|
||||
@@ -174,9 +173,8 @@ function connectNative(message) {
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const stored = await storageGet(["grpcAddress", "bearerToken"]);
|
||||
const stored = await storageGet(["bearerToken"]);
|
||||
return {
|
||||
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim()
|
||||
};
|
||||
}
|
||||
@@ -418,7 +416,6 @@ async function fetchStatus(settings) {
|
||||
}
|
||||
const status = await connectNative({
|
||||
action: "status",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken
|
||||
});
|
||||
return {
|
||||
@@ -515,7 +512,6 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
|
||||
|
||||
const matches = await connectNative({
|
||||
action: "find-logins",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
url: resolvedURL
|
||||
});
|
||||
@@ -601,7 +597,6 @@ async function fillLogin(tabId, entryId) {
|
||||
|
||||
const response = await connectNative({
|
||||
action: "get-login",
|
||||
grpcAddress: settings.grpcAddress,
|
||||
bearerToken: settings.bearerToken,
|
||||
entryId,
|
||||
url: pageUrl
|
||||
@@ -697,7 +692,6 @@ if (isNodeTestEnv) {
|
||||
return;
|
||||
case "keepassgo-save-settings":
|
||||
await storageSet({
|
||||
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||
});
|
||||
await refreshActivePage({ force: true }).catch(() => null);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "KeePassGO Browser",
|
||||
"version": "0.1.0",
|
||||
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||
"description": "Fill credentials from KeePassGO on sign-in pages.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"nativeMessaging",
|
||||
|
||||
@@ -11,14 +11,10 @@
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Browser Settings</h1>
|
||||
<p class="subtle">Configure how the extension reaches KeePassGO.</p>
|
||||
<p class="subtle">Connect the extension to KeePassGO.</p>
|
||||
</div>
|
||||
</header>
|
||||
<form id="settings-form" class="settings-form">
|
||||
<label>
|
||||
<span>gRPC address</span>
|
||||
<input id="grpc-address" name="grpc-address" type="text" value="" placeholder="Leave blank for the local default socket" autocomplete="off">
|
||||
</label>
|
||||
<label>
|
||||
<span>API token</span>
|
||||
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
||||
|
||||
@@ -22,7 +22,6 @@ async function loadSettings() {
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || "Could not load settings.");
|
||||
}
|
||||
document.getElementById("grpc-address").value = response.settings.grpcAddress || "";
|
||||
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
||||
}
|
||||
|
||||
@@ -34,7 +33,6 @@ async function saveSettings(event) {
|
||||
const response = await runtimeSend({
|
||||
type: "keepassgo-save-settings",
|
||||
settings: {
|
||||
grpcAddress: document.getElementById("grpc-address").value,
|
||||
bearerToken: document.getElementById("bearer-token").value
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,9 +11,17 @@ import (
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type bridgeConfig struct {
|
||||
grpcAddr string
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := bridgeConfig{
|
||||
grpcAddr: resolveGlobalGRPCAddr(os.Args[1:]),
|
||||
}
|
||||
if len(os.Args) > 1 {
|
||||
switch strings.TrimSpace(os.Args[1]) {
|
||||
case "install-native-host":
|
||||
@@ -22,13 +30,13 @@ func main() {
|
||||
}
|
||||
return
|
||||
case "status":
|
||||
if err := runStatus(os.Args[2:]); err != nil {
|
||||
if err := runStatus(cfg, stripGlobalGRPCAddrFlags(os.Args[2:])); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := runNativeMessage(); err != nil {
|
||||
if err := runNativeMessage(cfg); err != nil {
|
||||
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -79,54 +87,79 @@ func runInstallNativeHost(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStatus(args []string) error {
|
||||
func runStatus(cfg bridgeConfig, args []string) error {
|
||||
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||
grpcAddr := fs.String("grpc-addr", grpcaddr.Default(runtime.GOOS), "KeePassGO local gRPC address")
|
||||
token := fs.String("token", "", "KeePassGO API bearer token")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
req := browserbridge.Request{
|
||||
Action: "status",
|
||||
GRPCAddress: strings.TrimSpace(*grpcAddr),
|
||||
BearerToken: strings.TrimSpace(*token),
|
||||
}
|
||||
connCfg, err := req.Connection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
||||
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
resp := browserbridge.HandleRequest(ctx, req, client)
|
||||
resp := browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client)
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func runNativeMessage() error {
|
||||
func runNativeMessage(cfg bridgeConfig) error {
|
||||
req, err := browserbridge.ReadRequest(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connCfg, err := req.Connection()
|
||||
if err != nil {
|
||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||
}
|
||||
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
||||
conn, client, ctx, err := dialBridge(context.Background(), cfg, req)
|
||||
if err != nil {
|
||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, client))
|
||||
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, cfg.grpcAddr, client))
|
||||
}
|
||||
|
||||
func dialBridge(ctx context.Context, cfg bridgeConfig, req browserbridge.Request) (*grpc.ClientConn, *browserbridge.GRPCClient, context.Context, error) {
|
||||
return browserbridge.DialRequest(ctx, req, cfg.grpcAddr)
|
||||
}
|
||||
|
||||
func defaultBinaryPath() (string, error) {
|
||||
return browserbridge.ResolveBridgeBinaryPath("")
|
||||
}
|
||||
|
||||
func resolveGlobalGRPCAddr(args []string) string {
|
||||
addr := grpcaddr.Default(runtime.GOOS)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--grpc-addr" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case strings.HasPrefix(arg, "--grpc-addr="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--grpc-addr="))
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func stripGlobalGRPCAddrFlags(args []string) []string {
|
||||
out := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--grpc-addr" && i+1 < len(args):
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--grpc-addr="):
|
||||
continue
|
||||
default:
|
||||
out = append(out, args[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -11,7 +11,7 @@ The browser extension does **not** talk to vault files directly.
|
||||
## Security Model
|
||||
|
||||
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
||||
- The browser extension stores the gRPC address and API token in browser extension storage.
|
||||
- The browser extension stores the API token in browser extension storage.
|
||||
- The native messaging host receives the token on each request from the extension.
|
||||
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
|
||||
- The native messaging host does not persist the token to disk.
|
||||
@@ -78,14 +78,13 @@ Firefox:
|
||||
|
||||
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
||||
2. Open the extension settings page.
|
||||
3. Leave the gRPC address blank to use the local default Unix socket, or set an explicit address if you overrode the listener.
|
||||
4. Paste an API token scoped for browser login lookup and credential copy.
|
||||
3. Paste an API token scoped for browser login lookup and credential copy.
|
||||
|
||||
Chromium / Chrome:
|
||||
|
||||
1. Load a Chromium manifest based on `browser/extension/manifest.chromium.json`, or install the published extension when that distribution exists.
|
||||
2. Start KeePassGO once so it can refresh the native host manifest for the discovered extension id.
|
||||
3. Configure the gRPC address and API token in the extension settings page.
|
||||
3. Configure the API token in the extension settings page.
|
||||
|
||||
## Current Browser Flow
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ func (h *Host) Stop() error {
|
||||
h.started = false
|
||||
h.grpcServer.Stop()
|
||||
err := h.listener.Close()
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
err = nil
|
||||
}
|
||||
if h.socketPath != "" {
|
||||
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
|
||||
err = removeErr
|
||||
|
||||
+135
-76
@@ -113,9 +113,6 @@ func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSession
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
pendingApprovals := s.approvals.Pending()
|
||||
var tokenPending uint32
|
||||
for _, pending := range pendingApprovals {
|
||||
@@ -123,11 +120,16 @@ func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSession
|
||||
tokenPending++
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
locked := s.locked
|
||||
dirty := s.dirty
|
||||
entryCount := uint32(len(s.model.Entries))
|
||||
s.mu.RUnlock()
|
||||
|
||||
return &keepassgov1.GetSessionStatusResponse{
|
||||
Locked: s.locked,
|
||||
Dirty: s.dirty,
|
||||
EntryCount: uint32(len(s.model.Entries)),
|
||||
Locked: locked,
|
||||
Dirty: dirty,
|
||||
EntryCount: entryCount,
|
||||
PendingApprovalCount: uint32(len(pendingApprovals)),
|
||||
TokenPendingApprovalCount: tokenPending,
|
||||
}, nil
|
||||
@@ -289,7 +291,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
|
||||
},
|
||||
score: score,
|
||||
resource: resource,
|
||||
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource),
|
||||
decision: evaluateAuthorization(model, token, apitokens.OperationListEntries, resource),
|
||||
})
|
||||
}
|
||||
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
|
||||
@@ -348,22 +350,26 @@ func (s *Server) authorizedBrowserMatches(ctx context.Context, token apitokens.T
|
||||
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return browserMatchesWithinPath(matches, match.resource.Path), nil
|
||||
return s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func browserMatchesWithinPath(matches []rankedBrowserMatch, path []string) []*keepassgov1.BrowserLoginMatch {
|
||||
func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
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)
|
||||
if !slices.Equal(path, match.resource.Path[:len(path)]) {
|
||||
continue
|
||||
}
|
||||
if match.decision == apitokens.DecisionDeny {
|
||||
continue
|
||||
}
|
||||
out = append(out, match.match)
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
@@ -482,76 +488,75 @@ func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequ
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||
func (s *Server) mutateAuthorizedVisiblePath(ctx context.Context, clientPath []string, op apitokens.Operation, mutate func(*vault.Model, []string) error) error {
|
||||
model, locked := s.snapshotModel()
|
||||
if locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
return 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
|
||||
internalPath := expandClientPath(visibleModel(model), clientPath)
|
||||
if _, err := s.authorizePathRequest(ctx, op, internalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
|
||||
return mutate(model, internalPath)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) mutateAuthorizedModel(authorize func() error, mutate func(*vault.Model) error) error {
|
||||
if err := authorize(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.model.CreateGroup(parentPath, req.GetName())
|
||||
if err := mutate(&s.model); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dirty = true
|
||||
s.syncMutationLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
|
||||
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetParentPath(), apitokens.OperationMutateGroup, func(model *vault.Model, parentPath []string) error {
|
||||
model.CreateGroup(parentPath, req.GetName())
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
|
||||
if err := model.RenameGroup(groupPath, req.GetNewName()); err != nil {
|
||||
if errors.Is(err, vault.ErrEntryNotFound) {
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return status.Errorf(codes.Internal, "rename group: %v", err)
|
||||
}
|
||||
return nil
|
||||
}); 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 {
|
||||
if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error {
|
||||
if err := model.DeleteGroup(groupPath); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, vault.ErrEntryNotFound):
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
case errors.Is(err, vault.ErrGroupNotEmpty):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
default:
|
||||
return status.Errorf(codes.Internal, "delete group: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); 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
|
||||
}
|
||||
|
||||
@@ -568,12 +573,12 @@ func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRe
|
||||
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()
|
||||
if err := s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error {
|
||||
model.UpsertEntry(entry)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), entry)}, nil
|
||||
}
|
||||
@@ -1068,8 +1073,6 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
||||
return "exact-host", 3
|
||||
case strings.HasSuffix(pageHost, "."+entryHost):
|
||||
return "subdomain", 2
|
||||
case strings.HasSuffix(entryHost, "."+pageHost):
|
||||
return "parent-domain", 1
|
||||
default:
|
||||
return "", 0
|
||||
}
|
||||
@@ -1217,7 +1220,9 @@ func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Oper
|
||||
}
|
||||
|
||||
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) {
|
||||
model, _ := s.snapshotModel()
|
||||
displayResource := displayAuthorizationResource(resource)
|
||||
switch evaluateAuthorization(model, token, op, resource) {
|
||||
case apitokens.DecisionAllow:
|
||||
return token, nil
|
||||
case apitokens.DecisionDeny:
|
||||
@@ -1229,9 +1234,9 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
Resource: displayResource,
|
||||
})
|
||||
result, err := s.approvals.Request(ctx, token, op, resource)
|
||||
result, err := s.approvals.Request(ctx, token, op, displayResource)
|
||||
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)
|
||||
@@ -1245,7 +1250,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
Resource: displayResource,
|
||||
})
|
||||
return token, nil
|
||||
case errors.Is(err, apiapproval.ErrRequestDenied):
|
||||
@@ -1255,7 +1260,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
Resource: displayResource,
|
||||
})
|
||||
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
|
||||
case errors.Is(err, apiapproval.ErrRequestCanceled):
|
||||
@@ -1265,7 +1270,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
Resource: displayResource,
|
||||
})
|
||||
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
|
||||
case errors.Is(err, apiapproval.ErrRequestTimedOut):
|
||||
@@ -1275,7 +1280,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Operation: op,
|
||||
Resource: resource,
|
||||
Resource: displayResource,
|
||||
})
|
||||
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
|
||||
case errors.Is(err, context.Canceled):
|
||||
@@ -1334,6 +1339,60 @@ func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bo
|
||||
return false
|
||||
}
|
||||
|
||||
func evaluateAuthorization(model vault.Model, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) apitokens.Decision {
|
||||
return apitokens.Evaluate(canonicalizeTokenForAuthorization(model, token), op, canonicalizeAuthorizationResource(model, resource))
|
||||
}
|
||||
|
||||
func canonicalizeTokenForAuthorization(model vault.Model, token apitokens.Token) apitokens.Token {
|
||||
token.Policies = append([]apitokens.PolicyRule(nil), token.Policies...)
|
||||
for i := range token.Policies {
|
||||
token.Policies[i].Resource = canonicalizeAuthorizationResource(model, token.Policies[i].Resource)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func canonicalizeAuthorizationResource(model vault.Model, resource apitokens.Resource) apitokens.Resource {
|
||||
resource.Path = canonicalAuthorizationPath(model, resource.Path)
|
||||
return resource
|
||||
}
|
||||
|
||||
func displayAuthorizationResource(resource apitokens.Resource) apitokens.Resource {
|
||||
resource.Path = displayAuthorizationPath(resource.Path)
|
||||
return resource
|
||||
}
|
||||
|
||||
func canonicalAuthorizationPath(model vault.Model, path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
if path[0] == vaultview.KeepassRoot {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
if path[0] == "Root" {
|
||||
if len(path) > 1 && (path[1] == "Templates" || path[1] == "API Tokens") {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
return vaultview.VaultRoot(model).ToPhysicalPath(path[1:])
|
||||
}
|
||||
if path[0] == "Templates" || path[0] == "API Tokens" {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
return vaultview.VaultRoot(model).ToPhysicalPath(path)
|
||||
}
|
||||
|
||||
func displayAuthorizationPath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
if path[0] == vaultview.KeepassRoot {
|
||||
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
|
||||
}
|
||||
if path[0] == "Root" {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
|
||||
func copyOperation(target string) apitokens.Operation {
|
||||
switch clipboard.Target(target) {
|
||||
case clipboard.TargetUsername:
|
||||
|
||||
+161
-7
@@ -316,7 +316,7 @@ func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T)
|
||||
Path: []string{"keepass", "Joe", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
@@ -336,6 +336,160 @@ func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "rusty-casino",
|
||||
Title: "Rusty Casino",
|
||||
Username: "rustyryan",
|
||||
Password: "bellagio-1",
|
||||
URL: "https://vault.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bellagio"},
|
||||
},
|
||||
{
|
||||
ID: "benedict-vault",
|
||||
Title: "Benedict Vault",
|
||||
Username: "terrybenedict",
|
||||
Password: "bellagio-2",
|
||||
URL: "https://vault.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bellagio", "Denied"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews", "Bellagio", "Denied"}}},
|
||||
),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
respCh := make(chan *keepassgov1.FindBrowserLoginsResponse, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://vault.heist.example.invalid/login",
|
||||
})
|
||||
respCh <- resp
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if got := pending.Resource.Path; !slices.Equal(got, []string{"Crews", "Bellagio"}) {
|
||||
t.Fatalf("pending.Resource.Path = %v, want [Crews Bellagio]", got)
|
||||
}
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow once) error = %v", err)
|
||||
}
|
||||
|
||||
resp := <-respCh
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 1 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||
}
|
||||
if got := resp.Matches[0].Id; got != "rusty-casino" {
|
||||
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want rusty-casino", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceApprovalRequestsUseLogicalRootPathForPhysicalVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "codex-nextcloud",
|
||||
Title: "Nextcloud (codex)",
|
||||
Username: "jjulian",
|
||||
Password: "secret-1",
|
||||
URL: "https://nextcloud.example.invalid",
|
||||
Path: []string{"keepass", "Joe", "codex"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Joe"},
|
||||
{"keepass", "Joe", "codex"},
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
|
||||
Path: []string{"Joe", "codex"},
|
||||
})
|
||||
respCh <- resp
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if got := pending.Resource.Path; !slices.Equal(got, []string{"Root", "Joe", "codex"}) {
|
||||
t.Fatalf("pending.Resource.Path = %v, want [Root Joe codex]", got)
|
||||
}
|
||||
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow once) error = %v", err)
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
resp := <-respCh
|
||||
if len(resp.GetEntries()) != 1 || resp.GetEntries()[0].GetId() != "codex-nextcloud" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want codex-nextcloud after approval", resp.GetEntries())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "inside-man-accounts",
|
||||
Title: "Inside Man Accounts",
|
||||
Username: "daltonrussell",
|
||||
Password: "diamond-1",
|
||||
URL: "https://accounts.heist.example.invalid",
|
||||
Path: []string{"Crews", "Bank"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Crews"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "inside-man-accounts", Path: []string{"Crews", "Bank"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
|
||||
PageUrl: "https://heist.example.invalid/login",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||
}
|
||||
if len(resp.Matches) != 0 {
|
||||
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 0", len(resp.Matches))
|
||||
}
|
||||
|
||||
_, err = client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||
Id: "inside-man-accounts",
|
||||
PageUrl: "https://heist.example.invalid/login",
|
||||
})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -350,7 +504,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
|
||||
Path: []string{"keepass", "Joe", "codex"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
@@ -389,7 +543,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists
|
||||
Path: []string{"keepass", "Joe", "codex"},
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
@@ -421,7 +575,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
@@ -447,7 +601,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
@@ -1285,8 +1439,8 @@ func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) {
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
|
||||
),
|
||||
},
|
||||
Groups: [][]string{
|
||||
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRequestDenied = errors.New("authorization request denied")
|
||||
ErrRequestCanceled = errors.New("authorization request canceled")
|
||||
ErrRequestTimedOut = errors.New("authorization request timed out")
|
||||
ErrRequestNotFound = errors.New("authorization request not found")
|
||||
ErrRequestDenied = errors.New("authorization request denied")
|
||||
ErrRequestCanceled = errors.New("authorization request canceled")
|
||||
ErrRequestTimedOut = errors.New("authorization request timed out")
|
||||
ErrRequestNotFound = errors.New("authorization request not found")
|
||||
ErrBrokerNotConfigured = errors.New("authorization broker is not configured")
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
@@ -120,7 +121,7 @@ func (b *Broker) SetChangeNotifier(notify func()) {
|
||||
|
||||
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
||||
if b == nil {
|
||||
return Result{}, ErrRequestTimedOut
|
||||
return Result{}, ErrBrokerNotConfigured
|
||||
}
|
||||
|
||||
pending := &pendingRequest{
|
||||
|
||||
@@ -121,6 +121,16 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilBrokerReturnsConfigurationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var broker *Broker
|
||||
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||
if !errors.Is(err, ErrBrokerNotConfigured) {
|
||||
t.Fatalf("Request(nil broker) error = %v, want %v", err, ErrBrokerNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+307
-125
@@ -11,6 +11,7 @@ import (
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,9 @@ const (
|
||||
SectionAbout Section = "about"
|
||||
)
|
||||
|
||||
const entriesRootLabel = "Root"
|
||||
const templatesRootLabel = "Templates"
|
||||
|
||||
type CurrentSession interface {
|
||||
Current() (vault.Model, error)
|
||||
}
|
||||
@@ -98,6 +102,10 @@ type RemoteOpenableSession interface {
|
||||
OpenRemote(webdav.Client, string, vault.MasterKey) error
|
||||
}
|
||||
|
||||
type WarningSession interface {
|
||||
ConsumeWarning() string
|
||||
}
|
||||
|
||||
type SecurityConfigurableSession interface {
|
||||
ConfigureSecurity(vault.SecuritySettings) error
|
||||
SecuritySettings() vault.SecuritySettings
|
||||
@@ -192,139 +200,111 @@ func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||
}
|
||||
|
||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
result, err := s.mutateAPITokens(apiaudit.EventTokenIssued, "issued API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token, secret: secret}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, secret, err := apitokens.Issue(name, clientName, expiresAt, now)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
||||
return token, secret, nil
|
||||
return result.token, result.secret, nil
|
||||
}
|
||||
|
||||
func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return apitokens.Token{}, "", fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
result, err := s.mutateAPITokens(apiaudit.EventTokenRotated, "rotated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token, secret, err := apitokens.Rotate(token, now)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token, secret: secret}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
token, secret, err := apitokens.Rotate(token, now)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return apitokens.Token{}, "", err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
||||
return token, secret, nil
|
||||
return result.token, result.secret, nil
|
||||
}
|
||||
|
||||
func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
||||
return nil
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenUpdated, "updated API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) DisableAPIToken(id string) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = apitokens.Disable(token)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
||||
return nil
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenDisabled, "disabled API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token = apitokens.Disable(token)
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = apitokens.Revoke(token, when)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
||||
return nil
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenRevoked, "revoked API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token = apitokens.Revoke(token, when)
|
||||
apitokens.Upsert(model, token)
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *State) DeleteAPIToken(id string) error {
|
||||
_, err := s.mutateAPITokens(apiaudit.EventTokenDeleted, "deleted API token", func(model *vault.Model) (tokenMutationResult, error) {
|
||||
token, err := apitokens.Find(*model, id)
|
||||
if err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
if err := apitokens.Delete(model, id); err != nil {
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
return tokenMutationResult{token: token}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type tokenMutationResult struct {
|
||||
token apitokens.Token
|
||||
secret string
|
||||
}
|
||||
|
||||
func (s *State) mutateAPITokens(eventType apiaudit.EventType, message string, mutate func(*vault.Model) (tokenMutationResult, error)) (tokenMutationResult, error) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
return fmt.Errorf("session is not mutable")
|
||||
return tokenMutationResult{}, fmt.Errorf("session is not mutable")
|
||||
}
|
||||
model, err := session.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
result, err := mutate(&model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apitokens.Delete(&model, id); err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
session.Replace(model)
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return err
|
||||
return tokenMutationResult{}, err
|
||||
}
|
||||
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
||||
return nil
|
||||
s.recordTokenAudit(eventType, result.token, message)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
||||
@@ -403,7 +383,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||
}
|
||||
|
||||
if s.Section == SectionEntries {
|
||||
return entriesInPath(model.Entries, s.CurrentPath), nil
|
||||
return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil
|
||||
}
|
||||
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
|
||||
return entries, nil
|
||||
@@ -423,13 +403,13 @@ func (s *State) ChildGroups() ([]string, error) {
|
||||
}
|
||||
|
||||
if s.Section != SectionEntries {
|
||||
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
|
||||
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
|
||||
if s.Section == SectionTemplates {
|
||||
return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
|
||||
}
|
||||
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
|
||||
}
|
||||
|
||||
return model.ChildGroups(s.CurrentPath), nil
|
||||
return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil
|
||||
}
|
||||
|
||||
func (s *State) SelectVisibleIndex(index int) error {
|
||||
@@ -473,13 +453,13 @@ func (s *State) currentModel() (vault.Model, error) {
|
||||
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||
switch s.Section {
|
||||
case SectionTemplates:
|
||||
return slices.Clone(model.Templates)
|
||||
return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
|
||||
case SectionRecycleBin:
|
||||
return slices.Clone(model.RecycleBin)
|
||||
return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
|
||||
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||
return nil
|
||||
default:
|
||||
return slices.Clone(model.Entries)
|
||||
return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,11 +467,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
|
||||
path := slices.Clone(entry.Path)
|
||||
switch s.Section {
|
||||
case SectionTemplates:
|
||||
if len(path) == 0 || path[0] != "Templates" {
|
||||
path = append([]string{"Templates"}, path...)
|
||||
}
|
||||
path = logicalTemplatePath(path)
|
||||
case SectionRecycleBin:
|
||||
path = append([]string{"Recycle Bin"}, path...)
|
||||
path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
|
||||
case SectionEntries:
|
||||
path = logicalEntriesPath(path)
|
||||
}
|
||||
return strings.Join(path, " / ")
|
||||
}
|
||||
@@ -548,6 +528,163 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
|
||||
return out
|
||||
}
|
||||
|
||||
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return []string{entriesRootLabel}
|
||||
}
|
||||
if path[0] == entriesRootLabel {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
|
||||
path = path[1:]
|
||||
}
|
||||
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
|
||||
}
|
||||
|
||||
func logicalEntriesPath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return []string{entriesRootLabel}
|
||||
}
|
||||
if path[0] == entriesRootLabel {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
if path[0] == vaultview.KeepassRoot {
|
||||
path = path[1:]
|
||||
}
|
||||
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
|
||||
}
|
||||
|
||||
func logicalTemplatePath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return []string{templatesRootLabel}
|
||||
}
|
||||
if path[0] == templatesRootLabel {
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
|
||||
}
|
||||
|
||||
func templatesViewPath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
if path[0] == templatesRootLabel {
|
||||
return append([]string(nil), path[1:]...)
|
||||
}
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
|
||||
func entriesViewPathForModel(model vault.Model, path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
|
||||
return append([]string(nil), path[1:]...)
|
||||
case usesLogicalEntriesRoot(model):
|
||||
return append([]string(nil), path...)
|
||||
case path[0] == entriesRootLabel:
|
||||
return append([]string(nil), path[1:]...)
|
||||
default:
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
}
|
||||
|
||||
func logicalEntry(entry vault.Entry) vault.Entry {
|
||||
entry.Path = logicalEntriesPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = logicalEntry(entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func logicalEntries(entries []vault.Entry) []vault.Entry {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]vault.Entry, len(entries))
|
||||
for i := range entries {
|
||||
out[i] = logicalEntry(entries[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
|
||||
entry.Path = logicalTemplatePath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = logicalTemplateEntry(entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]vault.Entry, len(entries))
|
||||
for i := range entries {
|
||||
out[i] = logicalTemplateEntry(entries[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
|
||||
entry.Path = entriesViewPathForModel(model, entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = entryForModel(model, entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func templateEntryForModel(entry vault.Entry) vault.Entry {
|
||||
entry.Path = templatesViewPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i] = templateEntryForModel(entry.History[i])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func usesPhysicalEntriesRoot(model vault.Model) bool {
|
||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func usesLogicalEntriesRoot(model vault.Model) bool {
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == entriesRootLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func childGroups(entries []vault.Entry, path []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var groups []string
|
||||
@@ -572,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
|
||||
return groups
|
||||
}
|
||||
|
||||
func sectionGroupView(model vault.Model, section Section) vaultview.View {
|
||||
switch section {
|
||||
case SectionTemplates:
|
||||
return vaultview.VaultTemplates(model)
|
||||
default:
|
||||
return vaultview.VaultRoot(model)
|
||||
}
|
||||
}
|
||||
|
||||
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
|
||||
switch section {
|
||||
case SectionTemplates:
|
||||
return templatesViewPath(path)
|
||||
default:
|
||||
return entriesViewPathForModel(model, path)
|
||||
}
|
||||
}
|
||||
|
||||
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
|
||||
switch section {
|
||||
case SectionTemplates:
|
||||
return logicalTemplatePath(path)
|
||||
default:
|
||||
return logicalEntriesPathForModel(model, path)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) DeleteSelectedEntry() error {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -622,7 +786,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
|
||||
return err
|
||||
}
|
||||
|
||||
model.UpsertEntry(entry)
|
||||
model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
return s.markDirtyAndAutoSave()
|
||||
@@ -639,7 +803,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
|
||||
return err
|
||||
}
|
||||
|
||||
model.UpsertTemplate(entry)
|
||||
model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry)))
|
||||
session.Replace(model)
|
||||
s.SelectedEntryID = entry.ID
|
||||
return s.markDirtyAndAutoSave()
|
||||
@@ -656,7 +820,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
|
||||
entry, err := model.InstantiateTemplate(templateID, overrides)
|
||||
entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides)))
|
||||
if err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
@@ -666,7 +830,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
|
||||
if err := s.markDirtyAndAutoSave(); err != nil {
|
||||
return vault.Entry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
return logicalEntry(entry), nil
|
||||
}
|
||||
|
||||
func (s *State) DeleteTemplate(id string) error {
|
||||
@@ -754,7 +918,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
|
||||
return fmt.Errorf("session is not lockable")
|
||||
}
|
||||
|
||||
return session.Unlock(key)
|
||||
if err := session.Unlock(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||
s.StatusMessage = warningSession.ConsumeWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) ChangeMasterKey(key vault.MasterKey) error {
|
||||
@@ -916,6 +1086,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
|
||||
s.CurrentPath = nil
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = false
|
||||
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||
s.StatusMessage = warningSession.ConsumeWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -946,6 +1119,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
||||
s.CurrentPath = nil
|
||||
s.SelectedEntryID = ""
|
||||
s.Dirty = false
|
||||
if warningSession, ok := s.Session.(WarningSession); ok {
|
||||
s.StatusMessage = warningSession.ConsumeWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1021,7 +1197,8 @@ func (s *State) CreateGroup(name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
model.CreateGroup(s.CurrentPath, name)
|
||||
view := sectionGroupView(model, s.Section)
|
||||
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
|
||||
session.Replace(model)
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
@@ -1035,13 +1212,16 @@ func (s *State) MoveCurrentGroup(parent []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := append([]string(nil), s.CurrentPath...)
|
||||
if err := model.MoveGroup(current, parent); err != nil {
|
||||
view := sectionGroupView(model, s.Section)
|
||||
current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath)
|
||||
currentViewPath := sectionGroupViewPath(model, s.Section, current)
|
||||
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
|
||||
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
session.Replace(model)
|
||||
if len(current) > 0 {
|
||||
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1])
|
||||
if len(currentViewPath) > 0 {
|
||||
s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
|
||||
}
|
||||
return s.markDirtyAndAutoSave()
|
||||
}
|
||||
@@ -1057,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.RenameGroup(s.CurrentPath, newName); err != nil {
|
||||
view := sectionGroupView(model, s.Section)
|
||||
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1079,7 +1260,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil {
|
||||
if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1098,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := model.DeleteGroup(s.CurrentPath); err != nil {
|
||||
view := sectionGroupView(model, s.Section)
|
||||
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
CurrentPath: []string{"Root", "Crew", "Internet"},
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
@@ -583,6 +583,75 @@ func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Crew"},
|
||||
{"keepass", "Crew", "Internet"},
|
||||
{"keepass", "Crew", "Security Office"},
|
||||
},
|
||||
},
|
||||
},
|
||||
CurrentPath: []string{"Crew", "Internet"},
|
||||
}
|
||||
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
}
|
||||
|
||||
titles := make([]string, 0, len(got))
|
||||
for _, entry := range got {
|
||||
titles = append(titles, entry.Title)
|
||||
}
|
||||
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
|
||||
t.Fatalf("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles)
|
||||
}
|
||||
if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) {
|
||||
t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
state := State{
|
||||
Session: stubSession{
|
||||
model: vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Crew"},
|
||||
{"keepass", "Crew", "Internet"},
|
||||
{"keepass", "Crew", "Security Office"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := state.ChildGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Crew"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Crew]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1634,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
|
||||
t.Fatalf("CreateGroup() error = %v", err)
|
||||
}
|
||||
|
||||
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
|
||||
if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
|
||||
}
|
||||
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
|
||||
if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
|
||||
event.ClientName,
|
||||
string(event.Operation),
|
||||
AuditOperationLabel(event.Operation),
|
||||
strings.Join(event.Resource.Path, " / "),
|
||||
FormatResourcePath(event.Resource.Path),
|
||||
event.Resource.EntryID,
|
||||
event.Message,
|
||||
}
|
||||
@@ -91,3 +91,17 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
|
||||
}
|
||||
return strings.ToLower(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func DisplayResourcePath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
if path[0] == "keepass" {
|
||||
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
|
||||
}
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
|
||||
func FormatResourcePath(path []string) string {
|
||||
return strings.Join(DisplayResourcePath(path), " / ")
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ func (u *ui) editAPIPolicyRuleAction(index int) error {
|
||||
}
|
||||
u.apiPolicyGroupScope = true
|
||||
u.apiPolicyGroupScopeW.Value = true
|
||||
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
|
||||
u.apiPolicyPath.SetText(apiui.FormatResourcePath(rule.Resource.Path))
|
||||
u.apiPolicyEntryID.SetText("")
|
||||
return nil
|
||||
}
|
||||
@@ -476,7 +476,7 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
|
||||
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||
resource = "Entry: " + rule.Resource.EntryID
|
||||
} else if len(rule.Resource.Path) > 0 {
|
||||
resource = strings.Join(rule.Resource.Path, " / ")
|
||||
resource = apiui.FormatResourcePath(rule.Resource.Path)
|
||||
}
|
||||
return effect, operation, resource
|
||||
}
|
||||
@@ -1211,5 +1211,5 @@ func formatAuditResource(resource apitokens.Resource) string {
|
||||
if len(resource.Path) == 0 {
|
||||
return "/"
|
||||
}
|
||||
return strings.Join(resource.Path, " / ")
|
||||
return apiui.FormatResourcePath(resource.Path)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
|
||||
detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail"
|
||||
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
|
||||
lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle"
|
||||
@@ -1511,7 +1512,7 @@ func approvalResourceText(request apiapproval.Request) string {
|
||||
}
|
||||
case apitokens.ResourceGroup:
|
||||
if len(request.Resource.Path) > 0 {
|
||||
return strings.Join(request.Resource.Path, " / ")
|
||||
return apiui.FormatResourcePath(request.Resource.Path)
|
||||
}
|
||||
}
|
||||
return "Vault root"
|
||||
|
||||
+11
-2
@@ -24,6 +24,7 @@ import (
|
||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||
)
|
||||
|
||||
func (u *ui) bannerSurface() uiBanner {
|
||||
@@ -558,6 +559,11 @@ func copyPath(path []string) []string {
|
||||
}
|
||||
|
||||
func pathExistsInModel(model vault.Model, path []string) bool {
|
||||
if len(path) > 0 && path[0] == "Root" {
|
||||
view := vaultview.VaultRoot(model)
|
||||
viewPath := entriesViewPathForModel(model, path)
|
||||
return len(view.EntriesInPath(viewPath)) > 0 || len(view.ChildGroups(viewPath)) > 0 || hasExactGroup(model, view.ToPhysicalPath(viewPath))
|
||||
}
|
||||
return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path)
|
||||
}
|
||||
|
||||
@@ -569,9 +575,12 @@ func normalizeEntriesPathWithoutModel(path []string, root string) []string {
|
||||
return []string{root}
|
||||
}
|
||||
if path[0] == "Root" {
|
||||
return copyPath(path)
|
||||
}
|
||||
if path[0] == vaultview.KeepassRoot {
|
||||
return append([]string{root}, path[1:]...)
|
||||
}
|
||||
return copyPath(path)
|
||||
return append([]string{root}, copyPath(path)...)
|
||||
}
|
||||
|
||||
func (u *ui) normalizedEntriesPath(path []string) []string {
|
||||
@@ -590,7 +599,7 @@ func (u *ui) normalizedEntriesPath(path []string) []string {
|
||||
return []string{root}
|
||||
}
|
||||
if path[0] == "Root" && root != "" {
|
||||
candidate := append([]string{root}, path[1:]...)
|
||||
candidate := copyPath(path)
|
||||
if pathExistsInModel(model, candidate) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
+108
-32
@@ -2441,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Session.Current() error = %v", err)
|
||||
}
|
||||
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
|
||||
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2675,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Session.Current() error = %v", err)
|
||||
}
|
||||
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got)
|
||||
if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3180,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
||||
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
|
||||
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3241,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
|
||||
if err != nil {
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
||||
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
|
||||
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3406,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
||||
if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
|
||||
t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3606,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
|
||||
t.Fatalf("createGroupAction() error = %v", err)
|
||||
}
|
||||
|
||||
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
|
||||
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
||||
t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
|
||||
}
|
||||
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
|
||||
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
||||
t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5125,8 +5125,8 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
|
||||
t.Fatalf("openVaultAction() error = %v", err)
|
||||
}
|
||||
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
||||
t.Fatalf("currentPath = %v, want [keepass]", got)
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||
t.Fatalf("currentPath = %v, want [Root]", got)
|
||||
}
|
||||
if got := u.displayPath(); len(got) != 0 {
|
||||
t.Fatalf("displayPath() = %v, want root slash path", got)
|
||||
@@ -5136,6 +5136,39 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIOpenVaultShowsLegacyRootNormalizationWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
|
||||
var encoded bytes.Buffer
|
||||
if err := vault.SaveKDBX(&encoded, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Crew", "Internet"}},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"Root"},
|
||||
{"Root", "Crew"},
|
||||
{"Root", "Crew", "Internet"},
|
||||
},
|
||||
}, "correct horse battery staple"); err != nil {
|
||||
t.Fatalf("SaveKDBX() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(legacy-root.kdbx) error = %v", err)
|
||||
}
|
||||
|
||||
u := newUIWithSession("desktop", &session.Manager{})
|
||||
u.masterPassword.SetText("correct horse battery staple")
|
||||
u.vaultPath.SetText(path)
|
||||
if err := u.openVaultAction(); err != nil {
|
||||
t.Fatalf("openVaultAction() error = %v", err)
|
||||
}
|
||||
|
||||
if got := u.state.StatusMessage; !strings.Contains(got, "legacy vault root") {
|
||||
t.Fatalf("StatusMessage = %q, want legacy vault root normalization warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -5152,8 +5185,8 @@ func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
|
||||
|
||||
u.showEntriesSection()
|
||||
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
||||
t.Fatalf("currentPath = %v, want [keepass]", got)
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||
t.Fatalf("currentPath = %v, want [Root]", got)
|
||||
}
|
||||
if got := u.displayPath(); len(got) != 0 {
|
||||
t.Fatalf("displayPath() = %v, want root slash path", got)
|
||||
@@ -5174,15 +5207,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
|
||||
})
|
||||
|
||||
u.showEntriesSection()
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
||||
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got)
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||
t.Fatalf("currentPath after initial entries section = %v, want [Root]", got)
|
||||
}
|
||||
|
||||
u.showAPITokensSection()
|
||||
u.showEntriesSection()
|
||||
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
||||
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got)
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||
t.Fatalf("currentPath after returning to entries = %v, want [Root]", got)
|
||||
}
|
||||
if got := u.displayPath(); len(got) != 0 {
|
||||
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
|
||||
@@ -5215,8 +5248,8 @@ func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) {
|
||||
|
||||
u.syncCurrentPath()
|
||||
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
||||
t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got)
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
||||
t.Fatalf("currentPath after syncCurrentPath() = %v, want [Root]", got)
|
||||
}
|
||||
if got := u.displayPath(); len(got) != 0 {
|
||||
t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got)
|
||||
@@ -5235,7 +5268,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
|
||||
})
|
||||
|
||||
u.showEntriesSection()
|
||||
u.setCurrentPath([]string{"keepass", "Crew", "Internet"})
|
||||
u.setCurrentPath([]string{"Root", "Crew", "Internet"})
|
||||
u.search.SetText("amazon")
|
||||
u.filter()
|
||||
u.state.SelectedEntryID = "amazon"
|
||||
@@ -5245,8 +5278,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
|
||||
u.showAPITokensSection()
|
||||
u.showEntriesSection()
|
||||
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
|
||||
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got)
|
||||
if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) {
|
||||
t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got)
|
||||
}
|
||||
if got := u.search.Text(); got != "amazon" {
|
||||
t.Fatalf("search text after returning to entries = %q, want amazon", got)
|
||||
@@ -8073,7 +8106,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
|
||||
wantDetails := []string{
|
||||
"/vaults/cache",
|
||||
"Sync target: home.kdbx · dav.example.invalid",
|
||||
"Last group: Root / Internet",
|
||||
"Last group: Internet",
|
||||
}
|
||||
if !slices.Equal(gotDetails, wantDetails) {
|
||||
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
|
||||
@@ -8105,7 +8138,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T
|
||||
wantDetails := []string{
|
||||
"Path: vaults/home.kdbx",
|
||||
"Server: https://dav.example.invalid",
|
||||
"Last group: Root / Internet",
|
||||
"Last group: Internet",
|
||||
}
|
||||
if !slices.Equal(gotDetails, wantDetails) {
|
||||
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
|
||||
@@ -8480,7 +8513,7 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
|
||||
if err := reopened.openVaultAction(); err != nil {
|
||||
t.Fatalf("openVaultAction(imported) error = %v", err)
|
||||
}
|
||||
reopened.state.NavigateToPath([]string{"Crew", "Internet"})
|
||||
reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
|
||||
reopened.filter()
|
||||
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
|
||||
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
|
||||
@@ -9327,8 +9360,8 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
|
||||
if err := u.useCurrentGroupForPolicyAction(); err != nil {
|
||||
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
|
||||
}
|
||||
if got := u.apiPolicyPath.Text(); got != "bashertarr" {
|
||||
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr")
|
||||
if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" {
|
||||
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr")
|
||||
}
|
||||
if !u.apiPolicyGroupScopeW.Value {
|
||||
t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
|
||||
@@ -9355,6 +9388,49 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIEditAPIPolicyRuleHidesPhysicalKeepassRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := apitokens.Token{
|
||||
ID: "token-1",
|
||||
Name: "Crew Browser",
|
||||
Policies: []apitokens.PolicyRule{{
|
||||
Effect: apitokens.EffectAllow,
|
||||
Operation: apitokens.OperationListEntries,
|
||||
Resource: apitokens.Resource{
|
||||
Kind: apitokens.ResourceGroup,
|
||||
Path: []string{"keepass", "Crew", "bashertarr"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
u := newUIWithModel("desktop", vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
token.Entry(apitokens.EntryPath),
|
||||
},
|
||||
})
|
||||
u.showAPITokensSection()
|
||||
u.state.SelectedEntryID = "token-1"
|
||||
|
||||
if err := u.editAPIPolicyRuleAction(0); err != nil {
|
||||
t.Fatalf("editAPIPolicyRuleAction() error = %v", err)
|
||||
}
|
||||
if got := u.apiPolicyPath.Text(); got != "Root / Crew / bashertarr" {
|
||||
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Root / Crew / bashertarr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIAuditAndApprovalFormattingHidePhysicalKeepassRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Crew", "bashertarr"}}
|
||||
if got := formatAuditResource(resource); got != "Root / Crew / bashertarr" {
|
||||
t.Fatalf("formatAuditResource() = %q, want %q", got, "Root / Crew / bashertarr")
|
||||
}
|
||||
if got := approvalResourceText(apiapproval.Request{Resource: resource}); got != "Root / Crew / bashertarr" {
|
||||
t.Fatalf("approvalResourceText() = %q, want %q", got, "Root / Crew / bashertarr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1260,14 +1260,10 @@ func (u *ui) recentVaultGroup(path string) []string {
|
||||
}
|
||||
|
||||
func (u *ui) hiddenVaultRoot() string {
|
||||
if u.state.Section != appstate.SectionEntries {
|
||||
return ""
|
||||
if u.state.Section == appstate.SectionEntries {
|
||||
return "Root"
|
||||
}
|
||||
model, err := u.state.Session.Current()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return vaultview.HiddenRoot(model)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u *ui) enterHiddenVaultRoot() {
|
||||
@@ -1294,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) {
|
||||
u.setCurrentPath(saved)
|
||||
return
|
||||
}
|
||||
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
|
||||
if pathExistsInModel(model, saved) {
|
||||
u.setCurrentPath(saved)
|
||||
return
|
||||
}
|
||||
@@ -1317,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
|
||||
u.setCurrentPath(saved)
|
||||
return
|
||||
}
|
||||
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
|
||||
if pathExistsInModel(model, saved) {
|
||||
u.setCurrentPath(saved)
|
||||
return
|
||||
}
|
||||
@@ -1339,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) {
|
||||
u.setCurrentPath(path)
|
||||
return
|
||||
}
|
||||
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) {
|
||||
if pathExistsInModel(model, path) {
|
||||
u.setCurrentPath(path)
|
||||
return
|
||||
}
|
||||
@@ -1415,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool {
|
||||
return slices.Equal(path[:len(prefix)], prefix)
|
||||
}
|
||||
|
||||
func entriesViewPathForModel(model vault.Model, path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case usesPhysicalEntriesRoot(model) && path[0] == "Root":
|
||||
return append([]string(nil), path[1:]...)
|
||||
case usesLogicalEntriesRoot(model):
|
||||
return append([]string(nil), path...)
|
||||
case path[0] == "Root":
|
||||
return append([]string(nil), path[1:]...)
|
||||
default:
|
||||
return append([]string(nil), path...)
|
||||
}
|
||||
}
|
||||
|
||||
func hasExactGroup(model vault.Model, path []string) bool {
|
||||
for _, group := range model.Groups {
|
||||
if slices.Equal(group, path) {
|
||||
@@ -1433,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
path := append([]string(nil), u.currentPath...)
|
||||
if len(model.ChildGroups(path)) > 0 {
|
||||
view := vaultview.VaultRoot(model)
|
||||
path := entriesViewPathForModel(model, u.currentPath)
|
||||
physicalPath := view.ToPhysicalPath(path)
|
||||
if len(model.ChildGroups(physicalPath)) > 0 {
|
||||
return false, "This group contains child groups. Move or delete them before removing the group."
|
||||
}
|
||||
for _, item := range model.Entries {
|
||||
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
|
||||
if slices.Equal(item.Path, physicalPath) || pathHasPrefix(item.Path, physicalPath) {
|
||||
return false, "This group contains entries. Move or delete them before removing the group."
|
||||
}
|
||||
}
|
||||
@@ -1450,6 +1464,47 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
|
||||
return true, "Deleting this empty group will not remove any entries."
|
||||
}
|
||||
|
||||
func usesPhysicalEntriesRoot(model vault.Model) bool {
|
||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func usesLogicalEntriesRoot(model vault.Model) bool {
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == "Root" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *ui) deleteGroupPendingConfirmation() bool {
|
||||
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,9 @@ func ensureBrowserNativeHosts() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = browserbridge.EnsureNativeHostManifests(appBinaryPath)
|
||||
if err := browserbridge.EnsureNativeHostManifests(appBinaryPath); err != nil {
|
||||
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo browser native host registration failed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
type uiApprovalManager struct {
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,11 +24,11 @@ const (
|
||||
defaultFirefoxID = "browser@keepassgo.com"
|
||||
maxNativeMessageSize = 1024 * 1024
|
||||
chromiumIDBytes = 16
|
||||
responseVersion = "1"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"`
|
||||
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||
BearerToken string `json:"bearerToken,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
EntryID string `json:"entryId,omitempty"`
|
||||
@@ -136,22 +138,27 @@ func WriteResponse(w io.Writer, resp Response) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Request) Connection() (Connection, error) {
|
||||
conn := Connection{
|
||||
GRPCAddress: strings.TrimSpace(r.GRPCAddress),
|
||||
func (r Request) Connection(grpcAddr string) (Connection, error) {
|
||||
return normalizeConnection(Connection{
|
||||
GRPCAddress: strings.TrimSpace(grpcAddr),
|
||||
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||
}
|
||||
if conn.GRPCAddress == "" {
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeConnection(conn Connection) (Connection, error) {
|
||||
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||
}
|
||||
if conn.BearerToken == "" {
|
||||
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
conn.GRPCAddress = strings.TrimSpace(conn.GRPCAddress)
|
||||
conn.BearerToken = strings.TrimSpace(conn.BearerToken)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func HandleRequest(ctx context.Context, req Request, client Client) Response {
|
||||
conn, err := req.Connection()
|
||||
func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Client) Response {
|
||||
conn, err := req.Connection(grpcAddr)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error()}
|
||||
}
|
||||
@@ -162,33 +169,25 @@ func HandleRequest(ctx context.Context, req Request, client Client) Response {
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: status, Version: "1"}
|
||||
return Response{Success: true, Status: status, Version: responseVersion}
|
||||
case "find-logins":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
if status.Locked {
|
||||
return Response{Success: true, Status: status, Matches: nil, Version: "1"}
|
||||
}
|
||||
matches, err := findMatches(ctx, client, req.URL)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: true, Status: status, Matches: matches, Version: "1"}
|
||||
case "get-login":
|
||||
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||
if err != nil {
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
if status.Locked {
|
||||
return Response{Success: false, Error: "vault is locked", Status: status}
|
||||
}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
|
||||
case "get-login":
|
||||
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||
if err != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
|
||||
return Response{Success: false, Error: err.Error(), Status: status}
|
||||
}
|
||||
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||
}
|
||||
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
|
||||
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
|
||||
default:
|
||||
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||
}
|
||||
@@ -198,6 +197,21 @@ func disconnectedStatus(addr string) *Status {
|
||||
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func availableStatus(addr string) *Status {
|
||||
return &Status{Connected: true, Locked: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||
}
|
||||
|
||||
func inferredActionStatus(addr string, err error) *Status {
|
||||
switch gstatus.Code(err) {
|
||||
case gcodes.FailedPrecondition:
|
||||
return &Status{Connected: true, Locked: true, GRPCAddress: strings.TrimSpace(addr)}
|
||||
case gcodes.OK:
|
||||
return availableStatus(addr)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||
resp, err := client.Status(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"testing"
|
||||
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
@@ -21,7 +23,6 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
var input bytes.Buffer
|
||||
body, err := json.Marshal(Request{
|
||||
Action: "find-logins",
|
||||
GRPCAddress: "127.0.0.1:47777",
|
||||
BearerToken: "secret",
|
||||
URL: "https://example.invalid/login",
|
||||
})
|
||||
@@ -42,8 +43,8 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||
}
|
||||
if conn, err := req.Connection(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||
t.Fatalf("req.Connection() = (%#v, %v), want explicit tcp address preserved", conn, err)
|
||||
if conn, err := req.Connection("127.0.0.1:47777"); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||
t.Fatalf("req.Connection(127.0.0.1:47777) = (%#v, %v), want explicit tcp address preserved", conn, err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
@@ -70,8 +71,7 @@ func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||
func TestHandleRequestFindLogins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
|
||||
client := &fakeClient{
|
||||
matches: []*keepassgov1.BrowserLoginMatch{
|
||||
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||
},
|
||||
@@ -80,19 +80,22 @@ func TestHandleRequestFindLogins(t *testing.T) {
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, client)
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(find-logins) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fakeClient{
|
||||
client := &fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{
|
||||
Locked: false,
|
||||
EntryCount: 2,
|
||||
@@ -103,7 +106,7 @@ func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "status",
|
||||
BearerToken: "secret",
|
||||
}, client)
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest(status) success = false, error = %q", resp.Error)
|
||||
}
|
||||
@@ -121,8 +124,7 @@ func TestHandleRequestStatusIncludesPendingApprovalCounts(t *testing.T) {
|
||||
func TestHandleRequestGetLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fakeClient{
|
||||
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
|
||||
client := &fakeClient{
|
||||
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||
Id: "vault-console",
|
||||
Username: "dannyocean",
|
||||
@@ -135,19 +137,42 @@ func TestHandleRequestGetLogin(t *testing.T) {
|
||||
BearerToken: "secret",
|
||||
EntryID: "vault-console",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, client)
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(get-login) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &fakeClient{matchesErr: gstatus.Error(gcodes.FailedPrecondition, "vault is locked")}
|
||||
resp := HandleRequest(context.Background(), Request{
|
||||
Action: "find-logins",
|
||||
BearerToken: "secret",
|
||||
URL: "https://vault.example.invalid/login",
|
||||
}, "", client)
|
||||
if !resp.Success {
|
||||
t.Fatalf("HandleRequest(find-logins locked) success = false, error = %q", resp.Error)
|
||||
}
|
||||
if resp.Status == nil || !resp.Status.Locked {
|
||||
t.Fatalf("HandleRequest(find-logins locked).Status = %#v, want locked status", resp.Status)
|
||||
}
|
||||
if client.statusCalls != 0 {
|
||||
t.Fatalf("HandleRequest(find-logins locked) statusCalls = %d, want 0", client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{})
|
||||
resp := HandleRequest(context.Background(), Request{Action: "status"}, "", &fakeClient{})
|
||||
if resp.Success {
|
||||
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||
}
|
||||
@@ -157,9 +182,9 @@ func TestRequestConnectionDefaultsAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := Request{Action: "status", BearerToken: "secret"}
|
||||
conn, err := req.Connection()
|
||||
conn, err := req.Connection("")
|
||||
if err != nil {
|
||||
t.Fatalf("Connection() error = %v", err)
|
||||
t.Fatalf("Connection(\"\") error = %v", err)
|
||||
}
|
||||
if conn.GRPCAddress == "" {
|
||||
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
||||
@@ -282,10 +307,13 @@ func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testin
|
||||
}
|
||||
|
||||
type fakeClient struct {
|
||||
status *keepassgov1.GetSessionStatusResponse
|
||||
matches []*keepassgov1.BrowserLoginMatch
|
||||
credential *keepassgov1.GetBrowserCredentialResponse
|
||||
err error
|
||||
status *keepassgov1.GetSessionStatusResponse
|
||||
matches []*keepassgov1.BrowserLoginMatch
|
||||
credential *keepassgov1.GetBrowserCredentialResponse
|
||||
err error
|
||||
matchesErr error
|
||||
credentialErr error
|
||||
statusCalls int
|
||||
}
|
||||
|
||||
func writeExtensionManifest(t *testing.T, path, name string) {
|
||||
@@ -333,7 +361,8 @@ func assertManifestContainsExtension(t *testing.T, path, field, want string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
func (f *fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
f.statusCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
@@ -343,14 +372,20 @@ func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusRespon
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||
if f.matchesErr != nil {
|
||||
return nil, f.matchesErr
|
||||
}
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.matches, nil
|
||||
}
|
||||
|
||||
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||
if f.credentialErr != nil {
|
||||
return nil, f.credentialErr
|
||||
}
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||
@@ -18,14 +17,20 @@ type GRPCClient struct {
|
||||
client keepassgov1.VaultServiceClient
|
||||
}
|
||||
|
||||
func DialRequest(ctx context.Context, req Request, grpcAddr string) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
conn, err := req.Connection(grpcAddr)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return Dial(ctx, conn)
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||
normalized, err := normalizeConnection(conn)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
|
||||
}
|
||||
network, endpoint, err := grpcaddr.Parse(conn.GRPCAddress)
|
||||
network, endpoint, err := grpcaddr.Parse(normalized.GRPCAddress)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
@@ -40,9 +45,9 @@ func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", strings.TrimSpace(conn.GRPCAddress), err)
|
||||
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", normalized.GRPCAddress, err)
|
||||
}
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken))
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+normalized.BearerToken)
|
||||
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||
}
|
||||
|
||||
|
||||
+72
-25
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/vaultview"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,7 @@ type Manager struct {
|
||||
remoteClient *webdav.Client
|
||||
remotePath string
|
||||
remoteVersion webdav.Version
|
||||
warning string
|
||||
}
|
||||
|
||||
type PreparedLocalOpen struct {
|
||||
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
|
||||
Key vault.MasterKey
|
||||
Encoded []byte
|
||||
VaultRoot string
|
||||
Warning string
|
||||
}
|
||||
|
||||
type PreparedRemoteOpen struct {
|
||||
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
|
||||
Encoded []byte
|
||||
VaultRoot string
|
||||
RemoteVersion webdav.Version
|
||||
Warning string
|
||||
}
|
||||
|
||||
type PreparedUnlock struct {
|
||||
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
|
||||
Config *vault.KDBXConfig
|
||||
Key vault.MasterKey
|
||||
VaultRoot string
|
||||
Warning string
|
||||
}
|
||||
|
||||
func (m *Manager) SecuritySettings() vault.SecuritySettings {
|
||||
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
|
||||
}
|
||||
|
||||
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||
root := detectSingleVaultRoot(model)
|
||||
root := vaultview.KeepassRoot
|
||||
model = normalizeUnderRoot(model, root)
|
||||
var encoded bytes.Buffer
|
||||
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
|
||||
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
|
||||
m.vaultRoot = root
|
||||
m.encoded = encoded.Bytes()
|
||||
m.locked = false
|
||||
m.warning = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -118,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ConsumeWarning() string {
|
||||
warning := strings.TrimSpace(m.warning)
|
||||
m.warning = ""
|
||||
return warning
|
||||
}
|
||||
|
||||
func (m *Manager) Save() error {
|
||||
if m.remoteClient != nil && m.remotePath != "" {
|
||||
return m.SaveRemote()
|
||||
@@ -254,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
|
||||
func (m *Manager) Replace(model vault.Model) {
|
||||
root := m.vaultRoot
|
||||
if root == "" {
|
||||
root = detectSingleVaultRoot(model)
|
||||
root = vaultview.KeepassRoot
|
||||
}
|
||||
m.model = normalizeUnderRoot(model, root)
|
||||
m.vaultRoot = root
|
||||
@@ -305,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
|
||||
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
return PreparedLocalOpen{
|
||||
Model: model,
|
||||
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||
Config: config,
|
||||
Path: path,
|
||||
Key: key,
|
||||
Encoded: content,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
VaultRoot: vaultview.KeepassRoot,
|
||||
Warning: normalizationWarning(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -324,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
|
||||
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
|
||||
}
|
||||
return PreparedRemoteOpen{
|
||||
Model: model,
|
||||
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||
Config: config,
|
||||
Client: client,
|
||||
Path: path,
|
||||
Key: key,
|
||||
Encoded: content,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
VaultRoot: vaultview.KeepassRoot,
|
||||
RemoteVersion: version,
|
||||
Warning: normalizationWarning(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -341,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
|
||||
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
|
||||
}
|
||||
return PreparedUnlock{
|
||||
Model: model,
|
||||
Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
|
||||
Config: config,
|
||||
Key: key,
|
||||
VaultRoot: detectSingleVaultRoot(model),
|
||||
VaultRoot: vaultview.KeepassRoot,
|
||||
Warning: normalizationWarning(model),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -359,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
|
||||
m.remoteClient = nil
|
||||
m.remotePath = ""
|
||||
m.remoteVersion = webdav.Version{}
|
||||
m.warning = prepared.Warning
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
||||
@@ -372,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
|
||||
m.remotePath = prepared.Path
|
||||
m.remoteVersion = prepared.RemoteVersion
|
||||
m.path = ""
|
||||
m.warning = prepared.Warning
|
||||
}
|
||||
|
||||
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
||||
@@ -380,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
|
||||
m.key = prepared.Key
|
||||
m.vaultRoot = prepared.VaultRoot
|
||||
m.locked = false
|
||||
m.warning = prepared.Warning
|
||||
}
|
||||
|
||||
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
|
||||
@@ -584,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
|
||||
return err
|
||||
}
|
||||
m.model = merged
|
||||
if root := detectSingleVaultRoot(merged); root != "" {
|
||||
m.vaultRoot = root
|
||||
}
|
||||
m.vaultRoot = vaultview.KeepassRoot
|
||||
m.encoded = encoded
|
||||
m.locked = false
|
||||
return nil
|
||||
@@ -603,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
|
||||
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
|
||||
}
|
||||
m.model = merged
|
||||
if root := detectSingleVaultRoot(merged); root != "" {
|
||||
m.vaultRoot = root
|
||||
}
|
||||
m.vaultRoot = vaultview.KeepassRoot
|
||||
m.encoded = encoded
|
||||
m.remoteVersion = version
|
||||
m.locked = false
|
||||
@@ -867,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func detectSingleVaultRoot(model vault.Model) string {
|
||||
if len(model.EntriesInPath(nil)) != 0 {
|
||||
return ""
|
||||
}
|
||||
groups := model.ChildGroups(nil)
|
||||
if len(groups) != 1 {
|
||||
return ""
|
||||
}
|
||||
return groups[0]
|
||||
}
|
||||
|
||||
func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||
if root == "" {
|
||||
return model
|
||||
@@ -888,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||
switch {
|
||||
case len(path) == 0:
|
||||
return []string{root}
|
||||
case path[0] == "Root":
|
||||
if len(path) == 1 {
|
||||
return []string{root}
|
||||
}
|
||||
return append([]string{root}, path[1:]...)
|
||||
case path[0] == root:
|
||||
return path
|
||||
case path[0] == "Templates":
|
||||
return path
|
||||
default:
|
||||
return append([]string{root}, path...)
|
||||
}
|
||||
@@ -907,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
|
||||
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
|
||||
}
|
||||
}
|
||||
for i := range out.Templates {
|
||||
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
|
||||
for j := range out.Templates[i].History {
|
||||
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
|
||||
}
|
||||
}
|
||||
for i := range out.Groups {
|
||||
out.Groups[i] = normalizePath(out.Groups[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizationWarning(model vault.Model) string {
|
||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||
return ""
|
||||
}
|
||||
if usesKeepassStorageRoot(model) {
|
||||
return ""
|
||||
}
|
||||
return "Opened legacy vault root layout and normalized it under keepass."
|
||||
}
|
||||
|
||||
func usesKeepassStorageRoot(model vault.Model) bool {
|
||||
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
|
||||
for _, entry := range model.Entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, entry := range model.RecycleBin {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, group := range model.Groups {
|
||||
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
|
||||
t.Fatalf("Current() after Unlock() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
|
||||
}
|
||||
@@ -110,12 +110,63 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenNormalizesLegacyVaultRootToKeepassAndReportsWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := vault.MasterKey{Password: "correct horse battery staple"}
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Path: []string{"Root", "Home Assistant"},
|
||||
},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"Root"},
|
||||
{"Root", "Home Assistant"},
|
||||
},
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(legacy path) error = %v", err)
|
||||
}
|
||||
if err := vault.SaveKDBXWithKey(file, model, key); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close(legacy path) error = %v", err)
|
||||
}
|
||||
|
||||
var sess Manager
|
||||
if err := sess.Open(path, key); err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
|
||||
current, err := sess.Current()
|
||||
if err != nil {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
if got := current.EntriesInPath([]string{"keepass", "Home Assistant"}); len(got) != 1 || got[0].ID != "entry-1" {
|
||||
t.Fatalf("Current().EntriesInPath([keepass Home Assistant]) = %#v, want normalized legacy entry", got)
|
||||
}
|
||||
if got := sess.ConsumeWarning(); got == "" {
|
||||
t.Fatal("ConsumeWarning() = empty, want legacy root normalization warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("loaded entries = %#v, want updated password token-2", got)
|
||||
}
|
||||
@@ -307,7 +358,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 1 || got[0].Password != "token-1" {
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
|
||||
}
|
||||
@@ -392,7 +443,7 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
|
||||
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
|
||||
}
|
||||
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Current() error = %v", err)
|
||||
}
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 1 || got[0].Title != "Vault Console" {
|
||||
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
|
||||
}
|
||||
@@ -720,7 +771,7 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
|
||||
t.Fatalf("Current() after reopen error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
|
||||
}
|
||||
@@ -879,7 +930,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
|
||||
}
|
||||
@@ -947,7 +998,7 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||
}
|
||||
@@ -1004,7 +1055,7 @@ func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||
}
|
||||
@@ -1063,7 +1114,7 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||
}
|
||||
@@ -1148,7 +1199,7 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
||||
t.Fatalf("reopened Current() error = %v", err)
|
||||
}
|
||||
|
||||
got := current.EntriesInPath([]string{"Root", "Internet"})
|
||||
got := current.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||
const (
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassRoot = "keepass"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||
)
|
||||
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
|
||||
return -1
|
||||
case b == "Root":
|
||||
return 1
|
||||
case a == keepassRoot:
|
||||
return -1
|
||||
case b == keepassRoot:
|
||||
return 1
|
||||
case a == templatesRoot:
|
||||
return -1
|
||||
case b == templatesRoot:
|
||||
|
||||
@@ -755,6 +755,57 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDBXKeepassRootEntriesPreserveAttachmentsWithTemplates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := Model{
|
||||
Entries: []Entry{
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"keepass", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
"token.txt": []byte("secret attachment contents"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Templates: []Entry{
|
||||
{
|
||||
ID: "tpl-1",
|
||||
Title: "Website Login",
|
||||
Username: "template-user",
|
||||
Password: "template-password",
|
||||
Path: []string{"Templates", "Web"},
|
||||
},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass", "Internet"},
|
||||
{"Templates", "Web"},
|
||||
},
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||
t.Fatalf("SaveKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKDBX() error = %v", err)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
|
||||
t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
|
||||
}
|
||||
}
|
||||
|
||||
func mustGroup(name string, children ...any) gokeepasslib.Group {
|
||||
group := gokeepasslib.NewGroup()
|
||||
group.Name = name
|
||||
|
||||
@@ -5,19 +5,27 @@ import "git.julianfamily.org/keepassgo/internal/vault"
|
||||
// HiddenRoot returns the single synthetic top-level vault group that should be
|
||||
// treated as an internal storage root rather than as a user-visible group.
|
||||
func HiddenRoot(model vault.Model) string {
|
||||
if len(model.EntriesInPath(nil)) != 0 {
|
||||
if !hasGroup(model.Groups, []string{KeepassRoot}) {
|
||||
return ""
|
||||
}
|
||||
groups := model.ChildGroups(nil)
|
||||
roots := make([]string, 0, len(groups))
|
||||
return KeepassRoot
|
||||
}
|
||||
|
||||
func hasGroup(groups [][]string, path []string) bool {
|
||||
for _, group := range groups {
|
||||
if group == "Recycle Bin" {
|
||||
if len(group) != len(path) {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, group)
|
||||
match := true
|
||||
for i := range group {
|
||||
if group[i] != path[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(roots) != 1 {
|
||||
return ""
|
||||
}
|
||||
return roots[0]
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
package vaultview
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
const KeepassRoot = "keepass"
|
||||
const TemplatesRoot = "Templates"
|
||||
|
||||
// View projects the physical vault model into a logical tree for a specific
|
||||
// product surface.
|
||||
type View interface {
|
||||
ChildGroups(path []string) []string
|
||||
EntriesInPath(path []string) []vault.Entry
|
||||
EntriesUnderPath(path []string) []vault.Entry
|
||||
ToPhysicalPath(path []string) []string
|
||||
FromPhysicalPath(path []string) []string
|
||||
ToPhysicalEntry(entry vault.Entry) vault.Entry
|
||||
FromPhysicalEntry(entry vault.Entry) vault.Entry
|
||||
}
|
||||
|
||||
// Vault returns the physical datastore view.
|
||||
func Vault(model vault.Model) View {
|
||||
return physicalView{model: model}
|
||||
}
|
||||
|
||||
// VaultRoot returns the logical main-vault view rooted at the physical
|
||||
// keepass storage group.
|
||||
func VaultRoot(model vault.Model) View {
|
||||
return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)}
|
||||
}
|
||||
|
||||
// VaultTemplates returns the logical templates view rooted at the physical
|
||||
// Templates storage group.
|
||||
func VaultTemplates(model vault.Model) View {
|
||||
return templatesView{model: model}
|
||||
}
|
||||
|
||||
// VaultRecycleBin returns the logical recycle-bin view.
|
||||
func VaultRecycleBin(model vault.Model) View {
|
||||
return recycleBinView{model: model}
|
||||
}
|
||||
|
||||
type physicalView struct {
|
||||
model vault.Model
|
||||
}
|
||||
|
||||
func (v physicalView) ChildGroups(path []string) []string {
|
||||
return v.model.ChildGroups(path)
|
||||
}
|
||||
|
||||
func (v physicalView) EntriesInPath(path []string) []vault.Entry {
|
||||
return cloneEntries(v.model.EntriesInPath(path))
|
||||
}
|
||||
|
||||
func (v physicalView) EntriesUnderPath(path []string) []vault.Entry {
|
||||
return cloneEntries(v.model.EntriesUnderPath(path))
|
||||
}
|
||||
|
||||
func (v physicalView) ToPhysicalPath(path []string) []string {
|
||||
return clonePath(path)
|
||||
}
|
||||
|
||||
func (v physicalView) FromPhysicalPath(path []string) []string {
|
||||
return clonePath(path)
|
||||
}
|
||||
|
||||
func (v physicalView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
return cloneEntry(entry)
|
||||
}
|
||||
|
||||
func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
return cloneEntry(entry)
|
||||
}
|
||||
|
||||
type prefixedView struct {
|
||||
model vault.Model
|
||||
root string
|
||||
rooted bool
|
||||
}
|
||||
|
||||
func (v prefixedView) ChildGroups(path []string) []string {
|
||||
return v.model.ChildGroups(v.ToPhysicalPath(path))
|
||||
}
|
||||
|
||||
func (v prefixedView) EntriesInPath(path []string) []vault.Entry {
|
||||
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
|
||||
}
|
||||
|
||||
func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry {
|
||||
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
|
||||
}
|
||||
|
||||
func (v prefixedView) ToPhysicalPath(path []string) []string {
|
||||
if !v.rooted {
|
||||
return clonePath(path)
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return []string{v.root}
|
||||
}
|
||||
return append([]string{v.root}, clonePath(path)...)
|
||||
}
|
||||
|
||||
func (v prefixedView) FromPhysicalPath(path []string) []string {
|
||||
if !v.rooted {
|
||||
return clonePath(path)
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
if path[0] != v.root {
|
||||
return clonePath(path)
|
||||
}
|
||||
return clonePath(path[1:])
|
||||
}
|
||||
|
||||
func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
entry = cloneEntry(entry)
|
||||
entry.Path = v.ToPhysicalPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
entry = cloneEntry(entry)
|
||||
entry.Path = v.FromPhysicalPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry {
|
||||
out := make([]vault.Entry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, v.FromPhysicalEntry(entry))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type recycleBinView struct {
|
||||
model vault.Model
|
||||
}
|
||||
|
||||
type templatesView struct {
|
||||
model vault.Model
|
||||
}
|
||||
|
||||
func (v templatesView) ChildGroups(path []string) []string {
|
||||
return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path)
|
||||
}
|
||||
|
||||
func (v templatesView) EntriesInPath(path []string) []vault.Entry {
|
||||
return entriesInPath(v.EntriesUnderPath(nil), path)
|
||||
}
|
||||
|
||||
func (v templatesView) EntriesUnderPath(path []string) []vault.Entry {
|
||||
var out []vault.Entry
|
||||
for _, entry := range v.model.Templates {
|
||||
if len(path) > len(entry.Path) {
|
||||
continue
|
||||
}
|
||||
physical := entry.Path
|
||||
if len(physical) > 0 && physical[0] == TemplatesRoot {
|
||||
physical = physical[1:]
|
||||
}
|
||||
if len(path) > len(physical) {
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(physical[:len(path)], path) {
|
||||
continue
|
||||
}
|
||||
item := cloneEntry(entry)
|
||||
item.Path = clonePath(physical)
|
||||
for i := range item.History {
|
||||
item.History[i].Path = v.FromPhysicalPath(item.History[i].Path)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (v templatesView) ToPhysicalPath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return []string{TemplatesRoot}
|
||||
}
|
||||
return append([]string{TemplatesRoot}, clonePath(path)...)
|
||||
}
|
||||
|
||||
func (v templatesView) FromPhysicalPath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
if path[0] != TemplatesRoot {
|
||||
return clonePath(path)
|
||||
}
|
||||
return clonePath(path[1:])
|
||||
}
|
||||
|
||||
func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
entry = cloneEntry(entry)
|
||||
entry.Path = v.ToPhysicalPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
entry = cloneEntry(entry)
|
||||
entry.Path = v.FromPhysicalPath(entry.Path)
|
||||
for i := range entry.History {
|
||||
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (v recycleBinView) ChildGroups(path []string) []string {
|
||||
return childGroups(v.model.RecycleBin, path)
|
||||
}
|
||||
|
||||
func (v recycleBinView) EntriesInPath(path []string) []vault.Entry {
|
||||
return entriesInPath(v.model.RecycleBin, path)
|
||||
}
|
||||
|
||||
func (v recycleBinView) EntriesUnderPath(path []string) []vault.Entry {
|
||||
var out []vault.Entry
|
||||
for _, entry := range v.model.RecycleBin {
|
||||
if len(path) > len(entry.Path) {
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(entry.Path[:len(path)], path) {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneEntry(entry))
|
||||
}
|
||||
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (v recycleBinView) ToPhysicalPath(path []string) []string {
|
||||
return clonePath(path)
|
||||
}
|
||||
|
||||
func (v recycleBinView) FromPhysicalPath(path []string) []string {
|
||||
return clonePath(path)
|
||||
}
|
||||
|
||||
func (v recycleBinView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
return cloneEntry(entry)
|
||||
}
|
||||
|
||||
func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||
return cloneEntry(entry)
|
||||
}
|
||||
|
||||
func childGroups(entries []vault.Entry, path []string) []string {
|
||||
return groupChildren(nil, entries, path)
|
||||
}
|
||||
|
||||
func groupChildren(groupPaths [][]string, entries []vault.Entry, path []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var groups []string
|
||||
for _, entry := range entries {
|
||||
if len(path) > len(entry.Path) {
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(entry.Path[:len(path)], path) {
|
||||
continue
|
||||
}
|
||||
if len(entry.Path) == len(path) {
|
||||
continue
|
||||
}
|
||||
group := entry.Path[len(path)]
|
||||
if seen[group] {
|
||||
continue
|
||||
}
|
||||
seen[group] = true
|
||||
groups = append(groups, group)
|
||||
}
|
||||
for _, groupPath := range groupPaths {
|
||||
if len(path) > len(groupPath) {
|
||||
continue
|
||||
}
|
||||
if !slices.Equal(groupPath[:len(path)], path) {
|
||||
continue
|
||||
}
|
||||
if len(groupPath) == len(path) {
|
||||
continue
|
||||
}
|
||||
group := groupPath[len(path)]
|
||||
if seen[group] {
|
||||
continue
|
||||
}
|
||||
seen[group] = true
|
||||
groups = append(groups, group)
|
||||
}
|
||||
slices.Sort(groups)
|
||||
return groups
|
||||
}
|
||||
|
||||
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
|
||||
var out []vault.Entry
|
||||
for _, entry := range entries {
|
||||
if slices.Equal(entry.Path, path) {
|
||||
out = append(out, cloneEntry(entry))
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||
switch {
|
||||
case a.Title < b.Title:
|
||||
return -1
|
||||
case a.Title > b.Title:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneEntries(entries []vault.Entry) []vault.Entry {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]vault.Entry, len(entries))
|
||||
for i := range entries {
|
||||
out[i] = cloneEntry(entries[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneEntry(entry vault.Entry) vault.Entry {
|
||||
entry.Path = clonePath(entry.Path)
|
||||
entry.Tags = slices.Clone(entry.Tags)
|
||||
if entry.Fields != nil {
|
||||
fields := make(map[string]string, len(entry.Fields))
|
||||
for key, value := range entry.Fields {
|
||||
fields[key] = value
|
||||
}
|
||||
entry.Fields = fields
|
||||
}
|
||||
if entry.Attachments != nil {
|
||||
attachments := make(map[string][]byte, len(entry.Attachments))
|
||||
for key, value := range entry.Attachments {
|
||||
attachments[key] = slices.Clone(value)
|
||||
}
|
||||
entry.Attachments = attachments
|
||||
}
|
||||
if len(entry.History) != 0 {
|
||||
history := make([]vault.Entry, len(entry.History))
|
||||
for i := range entry.History {
|
||||
history[i] = cloneEntry(entry.History[i])
|
||||
}
|
||||
entry.History = history
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func clonePath(path []string) []string {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
return slices.Clone(path)
|
||||
}
|
||||
|
||||
func templateGroupPaths(model vault.Model) [][]string {
|
||||
var out [][]string
|
||||
for _, group := range model.Groups {
|
||||
if len(group) == 0 || group[0] != TemplatesRoot {
|
||||
continue
|
||||
}
|
||||
out = append(out, clonePath(group[1:]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func usesTopLevelRoot(model vault.Model, root string) bool {
|
||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||
return root == KeepassRoot
|
||||
}
|
||||
return groupsUseRoot(model.Groups, root) ||
|
||||
entriesUseRoot(model.Entries, root) ||
|
||||
entriesUseRoot(model.Templates, root) ||
|
||||
entriesUseRoot(model.RecycleBin, root)
|
||||
}
|
||||
|
||||
func groupsUseRoot(groups [][]string, root string) bool {
|
||||
for _, group := range groups {
|
||||
if len(group) > 0 && group[0] == root {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func entriesUseRoot(entries []vault.Entry, root string) bool {
|
||||
for _, entry := range entries {
|
||||
if len(entry.Path) > 0 && entry.Path[0] == root {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package vaultview
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
)
|
||||
|
||||
func TestVaultRootProjectsKeepassStorageRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"keepass", "Crew", "Security"}},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"keepass"},
|
||||
{"keepass", "Crew"},
|
||||
{"keepass", "Crew", "Internet"},
|
||||
{"keepass", "Crew", "Security"},
|
||||
{"Recycle Bin"},
|
||||
},
|
||||
}
|
||||
|
||||
view := VaultRoot(model)
|
||||
|
||||
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
|
||||
t.Fatalf("VaultRoot(model).ChildGroups(nil) = %v, want [Crew]", got)
|
||||
}
|
||||
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
|
||||
t.Fatalf("VaultRoot(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
|
||||
}
|
||||
|
||||
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
|
||||
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
|
||||
t.Fatalf("VaultRoot(model).EntriesInPath([Crew Internet]) = %#v, want logical path [Crew Internet]", gotEntries)
|
||||
}
|
||||
|
||||
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"keepass"}) {
|
||||
t.Fatalf("VaultRoot(model).ToPhysicalPath(nil) = %v, want [keepass]", got)
|
||||
}
|
||||
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
|
||||
t.Fatalf("VaultRoot(model).ToPhysicalPath([Crew Internet]) = %v, want [keepass Crew Internet]", got)
|
||||
}
|
||||
if got := view.FromPhysicalPath([]string{"keepass", "Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
|
||||
t.Fatalf("VaultRoot(model).FromPhysicalPath([keepass Crew Internet]) = %v, want [Crew Internet]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
RecycleBin: []vault.Entry{
|
||||
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"Crew", "Internet"}},
|
||||
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"Crew", "Security"}},
|
||||
},
|
||||
}
|
||||
|
||||
view := VaultRecycleBin(model)
|
||||
|
||||
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
|
||||
t.Fatalf("VaultRecycleBin(model).ChildGroups(nil) = %v, want [Crew]", got)
|
||||
}
|
||||
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
|
||||
t.Fatalf("VaultRecycleBin(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
|
||||
}
|
||||
|
||||
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
|
||||
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
|
||||
t.Fatalf("VaultRecycleBin(model).EntriesInPath([Crew Internet]) = %#v, want logical recycle-bin path [Crew Internet]", gotEntries)
|
||||
}
|
||||
|
||||
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
|
||||
t.Fatalf("VaultRecycleBin(model).ToPhysicalPath([Crew Internet]) = %v, want [Crew Internet]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Templates: []vault.Entry{
|
||||
{ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
||||
{ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
|
||||
},
|
||||
Groups: [][]string{
|
||||
{"Templates"},
|
||||
{"Templates", "Infra"},
|
||||
{"Templates", "Web"},
|
||||
{"keepass"},
|
||||
},
|
||||
}
|
||||
|
||||
view := VaultTemplates(model)
|
||||
|
||||
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) {
|
||||
t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got)
|
||||
}
|
||||
|
||||
gotEntries := view.EntriesInPath([]string{"Web"})
|
||||
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) {
|
||||
t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries)
|
||||
}
|
||||
|
||||
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) {
|
||||
t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got)
|
||||
}
|
||||
if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) {
|
||||
t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got)
|
||||
}
|
||||
if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) {
|
||||
t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
|
||||
},
|
||||
}
|
||||
|
||||
view := Vault(model)
|
||||
|
||||
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"keepass"}) {
|
||||
t.Fatalf("Vault(model).ChildGroups(nil) = %v, want [keepass]", got)
|
||||
}
|
||||
if got := view.ToPhysicalPath([]string{"keepass", "Crew"}); !slices.Equal(got, []string{"keepass", "Crew"}) {
|
||||
t.Fatalf("Vault(model).ToPhysicalPath([keepass Crew]) = %v, want [keepass Crew]", got)
|
||||
}
|
||||
if got := view.FromPhysicalEntry(model.Entries[0]); !slices.Equal(got.Path, []string{"keepass", "Crew", "Internet"}) {
|
||||
t.Fatalf("Vault(model).FromPhysicalEntry(entry).Path = %v, want [keepass Crew Internet]", got.Path)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user