Compare commits
3 Commits
v0.2.0
...
533fb2d550
| Author | SHA1 | Date | |
|---|---|---|---|
| 533fb2d550 | |||
| 8dfba6e94f | |||
| 6cc86bb944 |
@@ -389,19 +389,6 @@ Exit criteria:
|
||||
- Validation and visible error states exist for missing or invalid key material.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 5: KDBX Security Settings Preservation
|
||||
|
||||
Scope:
|
||||
- Preserve supported cipher and KDF settings when reopening and saving.
|
||||
- Surface relevant settings in product-facing docs or UI where appropriate.
|
||||
- Document unsupported settings explicitly.
|
||||
|
||||
Exit criteria:
|
||||
- Reopen-and-save cycles preserve supported KDBX security settings.
|
||||
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
|
||||
- Tests cover settings preservation across save cycles.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 6: Entry CRUD UI
|
||||
|
||||
Scope:
|
||||
@@ -607,33 +594,6 @@ Exit criteria:
|
||||
- UI tests or controller-integrated tests cover these states.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 18: Desktop Automation Resolution
|
||||
|
||||
Scope:
|
||||
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
|
||||
- or explicitly finalize the design that secure gRPC supersedes auto-type.
|
||||
- Keep the decision documented in-repo.
|
||||
|
||||
Exit criteria:
|
||||
- The desktop automation requirement is explicitly resolved in code or docs.
|
||||
- The chosen approach is documented in `docs/desktop-automation.md`.
|
||||
- Any implemented behavior is tested.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 19: Packaging And Runbook
|
||||
|
||||
Scope:
|
||||
- Keep the app runnable from source.
|
||||
- Document desktop build and run steps.
|
||||
- Document Android packaging with `gogio`.
|
||||
- Add icon and metadata placeholders if missing.
|
||||
|
||||
Exit criteria:
|
||||
- `README.md` is accurate for local build, run, and Android packaging guidance.
|
||||
- Placeholder metadata exists where needed for packaging.
|
||||
- The app still builds from the repo.
|
||||
- `go test ./...` passes.
|
||||
|
||||
### Segment 20: Regression And Integration Coverage
|
||||
|
||||
Scope:
|
||||
@@ -651,11 +611,10 @@ Exit criteria:
|
||||
|
||||
Do not treat the product as complete until all of the following are true:
|
||||
|
||||
- Segment 1 through Segment 20 are all complete.
|
||||
- All remaining numbered segments, API segments, and UI review follow-ups are complete.
|
||||
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
|
||||
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
|
||||
- KeePassGO supports master password, key file, and composite key workflows in the product.
|
||||
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
|
||||
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
|
||||
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
|
||||
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
||||
@@ -665,17 +624,7 @@ Do not treat the product as complete until all of the following are true:
|
||||
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
|
||||
- KeePassGO exposes password generation profiles through both UI and gRPC.
|
||||
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
|
||||
- The desktop automation requirement is explicitly resolved.
|
||||
- Keyboard-first navigation and common shortcuts exist for major product workflows.
|
||||
- The UI no longer depends on prototype-only mock behavior for any core workflow.
|
||||
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
|
||||
- `go test ./...` passes.
|
||||
- `go tool golangci-lint run ./...` passes.
|
||||
|
||||
## Remaining Gaps Against AGENTS.md
|
||||
|
||||
None currently identified.
|
||||
|
||||
The last explicitly tracked gaps are now closed:
|
||||
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
|
||||
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
|
||||
|
||||
+71
-14
@@ -89,7 +89,10 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
||||
s.dirty = dirty
|
||||
}
|
||||
|
||||
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -100,7 +103,10 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
|
||||
func (s *Server) OpenVault(ctx context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
|
||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||
}
|
||||
@@ -124,7 +130,10 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
|
||||
return &keepassgov1.OpenVaultResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
|
||||
func (s *Server) OpenRemoteVault(ctx context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
|
||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||
}
|
||||
@@ -153,7 +162,10 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
|
||||
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
|
||||
func (s *Server) SaveVault(ctx context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
|
||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||
}
|
||||
@@ -169,7 +181,10 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
|
||||
return &keepassgov1.SaveVaultResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
||||
func (s *Server) LockVault(ctx context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||
}
|
||||
@@ -185,7 +200,10 @@ func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (
|
||||
return &keepassgov1.LockVaultResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
||||
func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.lifecycle == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||
}
|
||||
@@ -465,7 +483,10 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
|
||||
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
||||
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
||||
if _, err := s.authorizeTemplateCollectionRequest(ctx, apitokens.OperationListTemplates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -483,10 +504,13 @@ func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRe
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
|
||||
func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
|
||||
if req.GetTemplate() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "missing template")
|
||||
}
|
||||
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetTemplate().GetId()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -502,7 +526,10 @@ func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTempla
|
||||
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
||||
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
||||
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetId()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -521,10 +548,16 @@ func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTempla
|
||||
return &keepassgov1.DeleteTemplateResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
|
||||
func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
|
||||
if req.GetOverrides() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "missing overrides")
|
||||
}
|
||||
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -685,12 +718,11 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF
|
||||
}
|
||||
|
||||
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if _, err := s.authenticateRequest(ctx); err != nil {
|
||||
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.locked {
|
||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||
@@ -784,6 +816,11 @@ func (s *Server) snapshotModel() (vault.Model, bool) {
|
||||
|
||||
var timeNow = func() time.Time { return time.Now().UTC() }
|
||||
|
||||
var (
|
||||
vaultPolicyPath = []string{"Root"}
|
||||
templatePolicyPath = []string{"Root", "Templates"}
|
||||
)
|
||||
|
||||
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
@@ -826,6 +863,14 @@ func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operatio
|
||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
||||
}
|
||||
|
||||
func (s *Server) authorizeVaultRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
|
||||
return s.authorizePathRequest(ctx, op, vaultPolicyPath)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTemplateCollectionRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
|
||||
return s.authorizePathRequest(ctx, op, templatePolicyPath)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
||||
token, err := s.authenticateRequest(ctx)
|
||||
if err != nil {
|
||||
@@ -834,6 +879,18 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati
|
||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
|
||||
token, err := s.authenticateRequest(ctx)
|
||||
if err != nil {
|
||||
return apitokens.Token{}, err
|
||||
}
|
||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{
|
||||
Kind: apitokens.ResourceEntry,
|
||||
Path: templatePolicyPath,
|
||||
EntryID: templateID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
||||
switch apitokens.Evaluate(token, op, resource) {
|
||||
case apitokens.DecisionAllow:
|
||||
|
||||
@@ -99,6 +99,66 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
),
|
||||
},
|
||||
Templates: []vault.Entry{
|
||||
{ID: "website-login", Title: "Website Login", Path: []string{"Templates"}},
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{
|
||||
Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"},
|
||||
})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1087,9 +1147,12 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||
|
||||
@@ -16,6 +16,12 @@ const (
|
||||
EventApprovalDenied EventType = "approval_denied"
|
||||
EventApprovalCanceled EventType = "approval_canceled"
|
||||
EventApprovalTimedOut EventType = "approval_timed_out"
|
||||
EventTokenIssued EventType = "token_issued"
|
||||
EventTokenUpdated EventType = "token_updated"
|
||||
EventTokenRotated EventType = "token_rotated"
|
||||
EventTokenDisabled EventType = "token_disabled"
|
||||
EventTokenRevoked EventType = "token_revoked"
|
||||
EventTokenDeleted EventType = "token_deleted"
|
||||
EventAutofillFound EventType = "autofill_found"
|
||||
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
||||
EventAutofillBlocked EventType = "autofill_blocked"
|
||||
|
||||
@@ -55,15 +55,18 @@ const (
|
||||
DecisionDeny Decision = "deny"
|
||||
DecisionPrompt Decision = "prompt"
|
||||
|
||||
OperationListEntries Operation = "list_entries"
|
||||
OperationListGroups Operation = "list_groups"
|
||||
OperationReadEntry Operation = "read_entry"
|
||||
OperationCopyPassword Operation = "copy_password"
|
||||
OperationCopyUsername Operation = "copy_username"
|
||||
OperationCopyURL Operation = "copy_url"
|
||||
OperationMutateEntry Operation = "mutate_entry"
|
||||
OperationMutateGroup Operation = "mutate_group"
|
||||
OperationManageVault Operation = "manage_vault"
|
||||
OperationListEntries Operation = "list_entries"
|
||||
OperationListGroups Operation = "list_groups"
|
||||
OperationListTemplates Operation = "list_templates"
|
||||
OperationReadEntry Operation = "read_entry"
|
||||
OperationCopyPassword Operation = "copy_password"
|
||||
OperationCopyUsername Operation = "copy_username"
|
||||
OperationCopyURL Operation = "copy_url"
|
||||
OperationMutateEntry Operation = "mutate_entry"
|
||||
OperationMutateGroup Operation = "mutate_group"
|
||||
OperationMutateTemplate Operation = "mutate_template"
|
||||
OperationGeneratePassword Operation = "generate_password"
|
||||
OperationManageVault Operation = "manage_vault"
|
||||
)
|
||||
|
||||
type Resource struct {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||
@@ -101,6 +102,7 @@ type ApprovalManager interface {
|
||||
type State struct {
|
||||
Session CurrentSession
|
||||
Approvals ApprovalManager
|
||||
AuditLog *apiaudit.Log
|
||||
Section Section
|
||||
CurrentPath []string
|
||||
SearchQuery string
|
||||
@@ -195,6 +197,7 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
||||
return token, secret, nil
|
||||
}
|
||||
|
||||
@@ -218,6 +221,7 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
||||
return token, secret, nil
|
||||
}
|
||||
|
||||
@@ -233,6 +237,7 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -249,9 +254,11 @@ func (s *State) DisableAPIToken(id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, apitokens.Disable(token))
|
||||
token = apitokens.Disable(token)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -268,9 +275,11 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apitokens.Upsert(&model, apitokens.Revoke(token, when))
|
||||
token = apitokens.Revoke(token, when)
|
||||
apitokens.Upsert(&model, token)
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -283,14 +292,37 @@ func (s *State) DeleteAPIToken(id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := apitokens.Find(model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apitokens.Delete(&model, id); err != nil {
|
||||
return err
|
||||
}
|
||||
session.Replace(model)
|
||||
s.Dirty = true
|
||||
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
||||
if s.AuditLog == nil {
|
||||
return
|
||||
}
|
||||
s.AuditLog.Record(apiaudit.Event{
|
||||
Type: eventType,
|
||||
TokenID: token.ID,
|
||||
TokenName: token.Name,
|
||||
ClientName: token.ClientName,
|
||||
Resource: apitokens.Resource{
|
||||
Kind: apitokens.ResourceEntry,
|
||||
Path: apitokens.EntryPath,
|
||||
EntryID: token.ID,
|
||||
},
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
||||
security, ok := s.Session.(SecurityConfigurableSession)
|
||||
if !ok {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||
"git.julianfamily.org/keepassgo/internal/session"
|
||||
"git.julianfamily.org/keepassgo/internal/vault"
|
||||
@@ -109,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := &mutableStubSession{model: vault.Model{}}
|
||||
state := State{Session: session}
|
||||
auditLog := apiaudit.New(10)
|
||||
state := State{Session: session, AuditLog: auditLog}
|
||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||
expiresAt := now.Add(24 * time.Hour)
|
||||
|
||||
@@ -162,6 +164,24 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
||||
if len(tokens) != 0 {
|
||||
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
||||
}
|
||||
|
||||
events := auditLog.Events()
|
||||
if len(events) != 5 {
|
||||
t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events))
|
||||
}
|
||||
if events[0].Type != apiaudit.EventTokenDeleted ||
|
||||
events[1].Type != apiaudit.EventTokenRevoked ||
|
||||
events[2].Type != apiaudit.EventTokenDisabled ||
|
||||
events[3].Type != apiaudit.EventTokenRotated ||
|
||||
events[4].Type != apiaudit.EventTokenIssued {
|
||||
t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events)
|
||||
}
|
||||
if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID {
|
||||
t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID)
|
||||
}
|
||||
if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" {
|
||||
t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||
|
||||
@@ -16,12 +16,15 @@ func Operations() []apitokens.Operation {
|
||||
return []apitokens.Operation{
|
||||
apitokens.OperationListEntries,
|
||||
apitokens.OperationListGroups,
|
||||
apitokens.OperationListTemplates,
|
||||
apitokens.OperationReadEntry,
|
||||
apitokens.OperationCopyPassword,
|
||||
apitokens.OperationCopyUsername,
|
||||
apitokens.OperationCopyURL,
|
||||
apitokens.OperationMutateEntry,
|
||||
apitokens.OperationMutateGroup,
|
||||
apitokens.OperationMutateTemplate,
|
||||
apitokens.OperationGeneratePassword,
|
||||
apitokens.OperationManageVault,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||
} else if host != nil {
|
||||
ui.apiHost = host
|
||||
ui.auditLog = host.Server().AuditLog()
|
||||
ui.state.AuditLog = ui.auditLog
|
||||
ui.grpcAddress = host.Address()
|
||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||
defer func() { _ = host.Stop() }()
|
||||
|
||||
Reference in New Issue
Block a user