Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 885d599db1 | |||
| e757be66d9 | |||
| bc226647e1 | |||
| 533fb2d550 | |||
| 8dfba6e94f | |||
| 6cc86bb944 |
@@ -132,195 +132,6 @@ These are important, but they should likely move behind a dedicated settings gea
|
|||||||
- Phone and desktop layouts both present a clear information hierarchy.
|
- Phone and desktop layouts both present a clear information hierarchy.
|
||||||
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
||||||
|
|
||||||
## API Token And gRPC Authorization Parallel Segments
|
|
||||||
|
|
||||||
These segments define the work for programmatic access control over gRPC.
|
|
||||||
They are designed to be independently landable wherever file overlap permits.
|
|
||||||
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
|
|
||||||
|
|
||||||
### API Segment A: Token Domain Model
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Represent API tokens as first-class vault-backed records.
|
|
||||||
- Mark token entries explicitly as API credentials rather than generic passwords.
|
|
||||||
- Store token metadata:
|
|
||||||
token id,
|
|
||||||
hashed secret or verifier,
|
|
||||||
display name,
|
|
||||||
client name,
|
|
||||||
created at,
|
|
||||||
expires at,
|
|
||||||
disabled state.
|
|
||||||
- Keep the persisted representation compatible with KDBX entry fields.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A domain type exists for API tokens and round-trips through the persisted vault model.
|
|
||||||
- Generic entry listing can distinguish API token entries from ordinary secrets.
|
|
||||||
- Tests cover create, load, save, and parse behavior for API token entries.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment B: Token Issuance And Rotation
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Generate new API tokens for external tools.
|
|
||||||
- Return the cleartext token only at creation or explicit rotation time.
|
|
||||||
- Rotate an existing token while preserving its identity and policy linkage.
|
|
||||||
- Revoke or disable a token without deleting policy history.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
|
|
||||||
- Cleartext token material is only exposed on creation or rotation paths.
|
|
||||||
- Tests cover generation, rotation, and disable/revoke semantics.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment C: Token Expiration
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Allow tokens to have optional expiration timestamps.
|
|
||||||
- Treat expired tokens as unauthenticated.
|
|
||||||
- Surface expiration in UI and gRPC management views.
|
|
||||||
- Support non-expiring tokens explicitly.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Expired tokens are rejected by the gRPC authentication path.
|
|
||||||
- Token expiration can be created, edited, and removed through the service layer.
|
|
||||||
- Tests cover valid, expired, and non-expiring token behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment D: Authorization Policy Model
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Define an authorization model for token-scoped access.
|
|
||||||
- Support allow and deny rules over:
|
|
||||||
folders/groups,
|
|
||||||
specific entries,
|
|
||||||
entry fields where needed,
|
|
||||||
and operation types.
|
|
||||||
- Keep specific deny rules higher priority than broad allow rules.
|
|
||||||
- Model “not yet decided” separately from “denied”.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A policy evaluator exists for token, resource, and operation tuples.
|
|
||||||
- Explicit deny overrides allow.
|
|
||||||
- Unspecified access is distinguishable from denied access.
|
|
||||||
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment E: gRPC Authentication And Authorization Enforcement
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Replace the current single static bearer-token interceptor with token-backed auth.
|
|
||||||
- Authenticate callers using issued KeePassGO API tokens.
|
|
||||||
- Authorize every gRPC method against token policy.
|
|
||||||
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
|
|
||||||
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
|
|
||||||
- Unauthorized requests return the correct authz/authn gRPC status.
|
|
||||||
- Integration tests cover permitted, denied, expired, and revoked token behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment F: Approval Queue And Pending Access Requests
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- When a token requests access to a resource that is neither explicitly allowed nor denied:
|
|
||||||
create a pending approval request.
|
|
||||||
- Include:
|
|
||||||
token identity,
|
|
||||||
client name,
|
|
||||||
requested operation,
|
|
||||||
requested group/entry scope,
|
|
||||||
requested time,
|
|
||||||
and permanence choice.
|
|
||||||
- Allow the request to be accepted, denied, or canceled by the user.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Unspecified access creates a pending approval instead of silently denying or allowing.
|
|
||||||
- Pending approvals are queryable from the application layer.
|
|
||||||
- Canceling the prompt results in the API request failing without granting access.
|
|
||||||
- Tests cover pending creation, approval, denial, and cancellation.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment G: Approval UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
|
|
||||||
- Provide actions:
|
|
||||||
allow once,
|
|
||||||
deny once,
|
|
||||||
allow permanently,
|
|
||||||
deny permanently,
|
|
||||||
cancel.
|
|
||||||
- Make the requested scope and operation clear to the user.
|
|
||||||
- Ensure the dialog appears only for requests not already decided.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A pending request triggers a visible approval surface in the app.
|
|
||||||
- The user can allow, deny, or cancel from the UI.
|
|
||||||
- Permanent decisions become persisted policy rules.
|
|
||||||
- UI tests cover each approval outcome.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment H: gRPC Request Blocking And Resume Behavior
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Define how an in-flight gRPC call waits for or fails on user approval.
|
|
||||||
- Hold the request while approval is pending within a bounded timeout.
|
|
||||||
- Return unauthenticated or permission-denied when denied/canceled/expired.
|
|
||||||
- Resume the original call automatically when approval is granted.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Pending requests block safely without leaking goroutines.
|
|
||||||
- Allowed requests resume and complete without the client reissuing the call where practical.
|
|
||||||
- Denied and canceled requests return a consistent gRPC status code and message.
|
|
||||||
- Tests cover timeout, allow, deny, and cancel paths.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment I: Token Management UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Add UI for listing API tokens.
|
|
||||||
- Create token flow with one-time secret display.
|
|
||||||
- Edit token display metadata and expiration.
|
|
||||||
- Disable, revoke, and rotate tokens.
|
|
||||||
- Show effective policy summary per token.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Users can manage API tokens from the app UI end to end.
|
|
||||||
- One-time token display is explicit and not re-shown later.
|
|
||||||
- Expiration and disable state are visible.
|
|
||||||
- UI tests cover create, rotate, disable, revoke, and edit flows.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment J: Policy Management UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Let users define folder, entry, and operation scopes for each token.
|
|
||||||
- Show explicit allow and deny rules.
|
|
||||||
- Show inherited implications of a folder-level rule.
|
|
||||||
- Let users review prior permanent decisions created from approval prompts.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Users can inspect and edit token policy from the UI.
|
|
||||||
- Folder-level and entry-level rules are distinguishable and editable.
|
|
||||||
- Permanent prompt decisions are visible as policy.
|
|
||||||
- UI tests cover rule creation, update, and deletion.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment K: Audit And Event History
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
|
|
||||||
- Record authorization failures and expirations without logging secret material.
|
|
||||||
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Security-relevant API token events are captured without secret leakage.
|
|
||||||
- Approval outcomes and policy changes are auditable.
|
|
||||||
- Tests cover audit generation for the main token lifecycle and approval actions.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 1: Application State Ownership
|
### Segment 1: Application State Ownership
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -389,19 +200,6 @@ Exit criteria:
|
|||||||
- Validation and visible error states exist for missing or invalid key material.
|
- Validation and visible error states exist for missing or invalid key material.
|
||||||
- `go test ./...` passes.
|
- `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
|
### Segment 6: Entry CRUD UI
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -607,33 +405,6 @@ Exit criteria:
|
|||||||
- UI tests or controller-integrated tests cover these states.
|
- UI tests or controller-integrated tests cover these states.
|
||||||
- `go test ./...` passes.
|
- `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
|
### Segment 20: Regression And Integration Coverage
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -651,11 +422,10 @@ Exit criteria:
|
|||||||
|
|
||||||
Do not treat the product as complete until all of the following are true:
|
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 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 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 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 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 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.
|
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
||||||
@@ -665,17 +435,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 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.
|
- 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 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.
|
- 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.
|
- 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 test ./...` passes.
|
||||||
- `go tool golangci-lint run ./...` 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.
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
|||||||
}
|
}
|
||||||
|
|
||||||
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
host := &Host{
|
host := &Host{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -19,7 +20,16 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
lifecycle := &session.Manager{}
|
lifecycle := &session.Manager{}
|
||||||
if err := lifecycle.Create(vault.Model{
|
if err := lifecycle.Create(vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{
|
||||||
|
Effect: apitokens.EffectAllow,
|
||||||
|
Operation: apitokens.OperationManageVault,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceGroup,
|
||||||
|
Path: []string{"Root"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
|||||||
+71
-37
@@ -19,7 +19,6 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -89,7 +88,10 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
|||||||
s.dirty = dirty
|
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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
@@ -100,7 +102,10 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
|
|||||||
}, nil
|
}, 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 {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -124,7 +129,10 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
|
|||||||
return &keepassgov1.OpenVaultResponse{}, nil
|
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 {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -153,7 +161,10 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
|
|||||||
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
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 {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -169,7 +180,10 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
|
|||||||
return &keepassgov1.SaveVaultResponse{}, nil
|
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 {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -185,7 +199,10 @@ func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (
|
|||||||
return &keepassgov1.LockVaultResponse{}, nil
|
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 {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -465,7 +482,10 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
|
|||||||
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
@@ -483,10 +503,13 @@ func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRe
|
|||||||
return resp, nil
|
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 {
|
if req.GetTemplate() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing template")
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -502,7 +525,10 @@ func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTempla
|
|||||||
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -521,10 +547,16 @@ func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTempla
|
|||||||
return &keepassgov1.DeleteTemplateResponse{}, nil
|
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 {
|
if req.GetOverrides() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing overrides")
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -685,12 +717,11 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||||
s.mu.RLock()
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil {
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
if _, err := s.authenticateRequest(ctx); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
if s.locked {
|
if s.locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
@@ -784,6 +815,11 @@ func (s *Server) snapshotModel() (vault.Model, bool) {
|
|||||||
|
|
||||||
var timeNow = func() time.Time { return time.Now().UTC() }
|
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) {
|
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
||||||
md, ok := metadata.FromIncomingContext(ctx)
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -826,6 +862,14 @@ func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operatio
|
|||||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
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) {
|
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
||||||
token, err := s.authenticateRequest(ctx)
|
token, err := s.authenticateRequest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -834,6 +878,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})
|
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) {
|
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) {
|
switch apitokens.Evaluate(token, op, resource) {
|
||||||
case apitokens.DecisionAllow:
|
case apitokens.DecisionAllow:
|
||||||
@@ -955,25 +1011,3 @@ func copyOperation(target string) apitokens.Operation {
|
|||||||
return apitokens.OperationCopyPassword
|
return apitokens.OperationCopyPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
|
|
||||||
return func(
|
|
||||||
ctx context.Context,
|
|
||||||
req any,
|
|
||||||
info *grpc.UnaryServerInfo,
|
|
||||||
handler grpc.UnaryHandler,
|
|
||||||
) (any, error) {
|
|
||||||
switch info.FullMethod {
|
|
||||||
case "/keepassgo.v1.VaultService/GetSessionStatus",
|
|
||||||
"/keepassgo.v1.VaultService/OpenVault",
|
|
||||||
"/keepassgo.v1.VaultService/OpenRemoteVault",
|
|
||||||
"/keepassgo.v1.VaultService/SaveVault",
|
|
||||||
"/keepassgo.v1.VaultService/LockVault",
|
|
||||||
"/keepassgo.v1.VaultService/UnlockVault":
|
|
||||||
if _, err := server.authenticateRequest(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return handler(ctx, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||||
t.Parallel()
|
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.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.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.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.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.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.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.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.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"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
@@ -1125,7 +1188,7 @@ func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultS
|
|||||||
listener := bufconn.Listen(1024 * 1024)
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
clipboardWriter := &memoryClipboardWriter{}
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1168,7 +1231,7 @@ func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepas
|
|||||||
))
|
))
|
||||||
lifecycle.model = model
|
lifecycle.model = model
|
||||||
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const (
|
|||||||
EventApprovalDenied EventType = "approval_denied"
|
EventApprovalDenied EventType = "approval_denied"
|
||||||
EventApprovalCanceled EventType = "approval_canceled"
|
EventApprovalCanceled EventType = "approval_canceled"
|
||||||
EventApprovalTimedOut EventType = "approval_timed_out"
|
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"
|
EventAutofillFound EventType = "autofill_found"
|
||||||
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
||||||
EventAutofillBlocked EventType = "autofill_blocked"
|
EventAutofillBlocked EventType = "autofill_blocked"
|
||||||
|
|||||||
@@ -55,15 +55,18 @@ const (
|
|||||||
DecisionDeny Decision = "deny"
|
DecisionDeny Decision = "deny"
|
||||||
DecisionPrompt Decision = "prompt"
|
DecisionPrompt Decision = "prompt"
|
||||||
|
|
||||||
OperationListEntries Operation = "list_entries"
|
OperationListEntries Operation = "list_entries"
|
||||||
OperationListGroups Operation = "list_groups"
|
OperationListGroups Operation = "list_groups"
|
||||||
OperationReadEntry Operation = "read_entry"
|
OperationListTemplates Operation = "list_templates"
|
||||||
OperationCopyPassword Operation = "copy_password"
|
OperationReadEntry Operation = "read_entry"
|
||||||
OperationCopyUsername Operation = "copy_username"
|
OperationCopyPassword Operation = "copy_password"
|
||||||
OperationCopyURL Operation = "copy_url"
|
OperationCopyUsername Operation = "copy_username"
|
||||||
OperationMutateEntry Operation = "mutate_entry"
|
OperationCopyURL Operation = "copy_url"
|
||||||
OperationMutateGroup Operation = "mutate_group"
|
OperationMutateEntry Operation = "mutate_entry"
|
||||||
OperationManageVault Operation = "manage_vault"
|
OperationMutateGroup Operation = "mutate_group"
|
||||||
|
OperationMutateTemplate Operation = "mutate_template"
|
||||||
|
OperationGeneratePassword Operation = "generate_password"
|
||||||
|
OperationManageVault Operation = "manage_vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
@@ -101,6 +102,7 @@ type ApprovalManager interface {
|
|||||||
type State struct {
|
type State struct {
|
||||||
Session CurrentSession
|
Session CurrentSession
|
||||||
Approvals ApprovalManager
|
Approvals ApprovalManager
|
||||||
|
AuditLog *apiaudit.Log
|
||||||
Section Section
|
Section Section
|
||||||
CurrentPath []string
|
CurrentPath []string
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
@@ -195,6 +197,7 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
||||||
return token, secret, nil
|
return token, secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +221,7 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
||||||
return token, secret, nil
|
return token, secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +254,11 @@ func (s *State) DisableAPIToken(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, apitokens.Disable(token))
|
token = apitokens.Disable(token)
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,9 +275,11 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, apitokens.Revoke(token, when))
|
token = apitokens.Revoke(token, when)
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,14 +292,37 @@ func (s *State) DeleteAPIToken(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
token, err := apitokens.Find(model, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := apitokens.Delete(&model, id); err != nil {
|
if err := apitokens.Delete(&model, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
||||||
return nil
|
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) {
|
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
||||||
security, ok := s.Session.(SecurityConfigurableSession)
|
security, ok := s.Session.(SecurityConfigurableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -109,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
session := &mutableStubSession{model: vault.Model{}}
|
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)
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
expiresAt := now.Add(24 * time.Hour)
|
expiresAt := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
@@ -162,6 +164,24 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
if len(tokens) != 0 {
|
if len(tokens) != 0 {
|
||||||
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
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) {
|
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ func Operations() []apitokens.Operation {
|
|||||||
return []apitokens.Operation{
|
return []apitokens.Operation{
|
||||||
apitokens.OperationListEntries,
|
apitokens.OperationListEntries,
|
||||||
apitokens.OperationListGroups,
|
apitokens.OperationListGroups,
|
||||||
|
apitokens.OperationListTemplates,
|
||||||
apitokens.OperationReadEntry,
|
apitokens.OperationReadEntry,
|
||||||
apitokens.OperationCopyPassword,
|
apitokens.OperationCopyPassword,
|
||||||
apitokens.OperationCopyUsername,
|
apitokens.OperationCopyUsername,
|
||||||
apitokens.OperationCopyURL,
|
apitokens.OperationCopyURL,
|
||||||
apitokens.OperationMutateEntry,
|
apitokens.OperationMutateEntry,
|
||||||
apitokens.OperationMutateGroup,
|
apitokens.OperationMutateGroup,
|
||||||
|
apitokens.OperationMutateTemplate,
|
||||||
|
apitokens.OperationGeneratePassword,
|
||||||
apitokens.OperationManageVault,
|
apitokens.OperationManageVault,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-11
@@ -129,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
|
|||||||
return clicks
|
return clicks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
|
||||||
|
if count <= 0 {
|
||||||
|
u.apiPolicyEdits = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(u.apiPolicyEdits) == count {
|
||||||
|
return u.apiPolicyEdits
|
||||||
|
}
|
||||||
|
clicks := make([]widget.Clickable, count)
|
||||||
|
copy(clicks, u.apiPolicyEdits)
|
||||||
|
u.apiPolicyEdits = clicks
|
||||||
|
return clicks
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
||||||
|
u.selectedAPIPolicyIndex = -1
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
if !ok {
|
if !ok {
|
||||||
u.apiTokenSecret = ""
|
u.apiTokenSecret = ""
|
||||||
@@ -143,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
|||||||
u.apiPolicyAllow.Value = true
|
u.apiPolicyAllow.Value = true
|
||||||
u.apiPolicyGroupScope = true
|
u.apiPolicyGroupScope = true
|
||||||
u.apiPolicyGroupScopeW.Value = true
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
|
u.ensureAPIPolicyEditClickables(0)
|
||||||
u.ensureAPIPolicyRemoveClickables(0)
|
u.ensureAPIPolicyRemoveClickables(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
|||||||
u.apiTokenExpiresAt.SetText("")
|
u.apiTokenExpiresAt.SetText("")
|
||||||
}
|
}
|
||||||
u.apiTokenDisabled.Value = token.Disabled
|
u.apiTokenDisabled.Value = token.Disabled
|
||||||
|
u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||||
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
|||||||
return "", fmt.Errorf("unknown API operation %q", text)
|
return "", fmt.Errorf("unknown API operation %q", text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) addAPIPolicyRuleAction() error {
|
func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
|
||||||
token, ok := u.selectedAPIToken()
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("no API token selected")
|
|
||||||
}
|
|
||||||
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return apitokens.PolicyRule{}, err
|
||||||
}
|
}
|
||||||
rule := apitokens.PolicyRule{
|
rule := apitokens.PolicyRule{
|
||||||
Operation: operation,
|
Operation: operation,
|
||||||
@@ -270,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
|||||||
if u.apiPolicyGroupScope {
|
if u.apiPolicyGroupScope {
|
||||||
path := parsePath(u.apiPolicyPath.Text())
|
path := parsePath(u.apiPolicyPath.Text())
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return fmt.Errorf("policy path is required for group scope")
|
return apitokens.PolicyRule{}, fmt.Errorf("policy path is required for group scope")
|
||||||
}
|
}
|
||||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
|
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
|
||||||
} else {
|
} else {
|
||||||
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
||||||
if entryID == "" {
|
if entryID == "" {
|
||||||
return fmt.Errorf("entry id is required for entry scope")
|
return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
|
||||||
}
|
}
|
||||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
||||||
}
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) addAPIPolicyRuleAction() error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
rule, err := u.apiPolicyRuleFromEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !uiHasPolicyRule(token.Policies, rule) {
|
if !uiHasPolicyRule(token.Policies, rule) {
|
||||||
token.Policies = append(token.Policies, rule)
|
token.Policies = append(token.Policies, rule)
|
||||||
}
|
}
|
||||||
@@ -290,6 +315,63 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) editAPIPolicyRuleAction(index int) error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(token.Policies) {
|
||||||
|
return fmt.Errorf("policy index %d out of range", index)
|
||||||
|
}
|
||||||
|
rule := token.Policies[index]
|
||||||
|
u.selectedAPIPolicyIndex = index
|
||||||
|
u.apiPolicyOperation.SetText(string(rule.Operation))
|
||||||
|
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
|
||||||
|
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||||
|
u.apiPolicyGroupScope = false
|
||||||
|
u.apiPolicyGroupScopeW.Value = false
|
||||||
|
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
|
||||||
|
u.apiPolicyPath.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u.apiPolicyGroupScope = true
|
||||||
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
|
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
|
||||||
|
u.apiPolicyEntryID.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAPIPolicyRuleAction() error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
index := u.selectedAPIPolicyIndex
|
||||||
|
if index < 0 || index >= len(token.Policies) {
|
||||||
|
return fmt.Errorf("no API policy rule selected")
|
||||||
|
}
|
||||||
|
rule, err := u.apiPolicyRuleFromEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, existing := range token.Policies {
|
||||||
|
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
|
||||||
|
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
|
||||||
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.Policies[index] = rule
|
||||||
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) apiPolicyGroupPathSummary() string {
|
func (u *ui) apiPolicyGroupPathSummary() string {
|
||||||
path := parsePath(u.apiPolicyPath.Text())
|
path := parsePath(u.apiPolicyPath.Text())
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
@@ -357,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) cancelAPIPolicyEditAction() error {
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) apiAuditEvents() []apiaudit.Event {
|
func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||||
if u.auditLog == nil {
|
if u.auditLog == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -749,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
|
|||||||
|
|
||||||
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
|
editClicks := u.ensureAPIPolicyEditClickables(0)
|
||||||
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
||||||
if ok {
|
if ok {
|
||||||
|
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||||
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||||
}
|
}
|
||||||
rows := []layout.Widget{
|
rows := []layout.Widget{
|
||||||
@@ -918,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
|
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &editClicks[index], "Edit")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
|
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
|
||||||
}),
|
}),
|
||||||
@@ -951,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
rows = append(rows,
|
rows = append(rows,
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
actionLabel := "Add Rule"
|
||||||
|
title := "Policy Composer"
|
||||||
|
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
|
||||||
|
if 0 <= u.selectedAPIPolicyIndex {
|
||||||
|
actionLabel = "Save Rule"
|
||||||
|
title = "Policy Editor"
|
||||||
|
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
|
||||||
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer")
|
lbl := material.Label(u.theme, unit.Sp(14), title)
|
||||||
lbl.Color = accentColor
|
lbl.Color = accentColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
|
lbl := material.Label(u.theme, unit.Sp(12), description)
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
@@ -1014,7 +1115,22 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if 0 <= u.selectedAPIPolicyIndex {
|
||||||
|
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.selectedAPIPolicyIndex < 0 {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+30
-14
@@ -364,12 +364,13 @@ type ui struct {
|
|||||||
showAutofillApprovalAsk widget.Clickable
|
showAutofillApprovalAsk widget.Clickable
|
||||||
showAutofillApprovalAllow widget.Clickable
|
showAutofillApprovalAllow widget.Clickable
|
||||||
showAutofillApprovalBlock widget.Clickable
|
showAutofillApprovalBlock widget.Clickable
|
||||||
allowApproval widget.Clickable
|
allowApprovalOnce widget.Clickable
|
||||||
denyApproval widget.Clickable
|
allowApprovalPermanent widget.Clickable
|
||||||
|
denyApprovalOnce widget.Clickable
|
||||||
|
denyApprovalPermanent widget.Clickable
|
||||||
cancelApproval widget.Clickable
|
cancelApproval widget.Clickable
|
||||||
cancelLifecycleProgress widget.Clickable
|
cancelLifecycleProgress widget.Clickable
|
||||||
retryLifecycleOpen widget.Clickable
|
retryLifecycleOpen widget.Clickable
|
||||||
approvalPermanent widget.Bool
|
|
||||||
syncSetupAutomatic widget.Bool
|
syncSetupAutomatic widget.Bool
|
||||||
apiPolicyAllow widget.Bool
|
apiPolicyAllow widget.Bool
|
||||||
apiPolicyGroupScopeW widget.Bool
|
apiPolicyGroupScopeW widget.Bool
|
||||||
@@ -381,6 +382,7 @@ type ui struct {
|
|||||||
settingsDebugHeaderBounds widget.Bool
|
settingsDebugHeaderBounds widget.Bool
|
||||||
entryClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
apiTokenClicks []widget.Clickable
|
apiTokenClicks []widget.Clickable
|
||||||
|
apiPolicyEdits []widget.Clickable
|
||||||
apiPolicyRemoves []widget.Clickable
|
apiPolicyRemoves []widget.Clickable
|
||||||
apiAuditClicks []widget.Clickable
|
apiAuditClicks []widget.Clickable
|
||||||
apiAuditTokenFilters []widget.Clickable
|
apiAuditTokenFilters []widget.Clickable
|
||||||
@@ -416,6 +418,8 @@ type ui struct {
|
|||||||
useSelectedEntryForPolicy widget.Clickable
|
useSelectedEntryForPolicy widget.Clickable
|
||||||
clearAPIPolicyTarget widget.Clickable
|
clearAPIPolicyTarget widget.Clickable
|
||||||
addAPIPolicyRule widget.Clickable
|
addAPIPolicyRule widget.Clickable
|
||||||
|
saveAPIPolicyRule widget.Clickable
|
||||||
|
cancelAPIPolicyEdit widget.Clickable
|
||||||
phoneSplit widget.Float
|
phoneSplit widget.Float
|
||||||
splitDrag gesture.Drag
|
splitDrag gesture.Drag
|
||||||
splitBase float32
|
splitBase float32
|
||||||
@@ -488,6 +492,7 @@ type ui struct {
|
|||||||
entriesState entriesSectionState
|
entriesState entriesSectionState
|
||||||
deleteGroupPath []string
|
deleteGroupPath []string
|
||||||
apiPolicyGroupScope bool
|
apiPolicyGroupScope bool
|
||||||
|
selectedAPIPolicyIndex int
|
||||||
apiTokenSecret string
|
apiTokenSecret string
|
||||||
phoneSyncMenuOrigin image.Point
|
phoneSyncMenuOrigin image.Point
|
||||||
phoneMainMenuOrigin image.Point
|
phoneMainMenuOrigin image.Point
|
||||||
@@ -665,6 +670,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
||||||
backgroundResults: make(chan backgroundActionResult, 8),
|
backgroundResults: make(chan backgroundActionResult, 8),
|
||||||
phoneGroupBrowserExpanded: true,
|
phoneGroupBrowserExpanded: true,
|
||||||
|
selectedAPIPolicyIndex: -1,
|
||||||
}
|
}
|
||||||
if mode == "phone" {
|
if mode == "phone" {
|
||||||
u.groupControlsHidden = true
|
u.groupControlsHidden = true
|
||||||
@@ -1431,23 +1437,33 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
|
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent")
|
|
||||||
check.Color = accentColor
|
|
||||||
return check.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.allowApproval, "Allow")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.allowApprovalOnce, "Allow Once")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.allowApprovalPermanent, "Allow Permanently")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.denyApproval, "Deny")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.denyApprovalOnce, "Deny Once")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.denyApprovalPermanent, "Deny Permanently")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
|
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
|
||||||
}),
|
}),
|
||||||
|
|||||||
+27
-19
@@ -928,33 +928,29 @@ func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) handleApprovalClicks(gtx layout.Context) {
|
func (u *ui) handleApprovalClicks(gtx layout.Context) {
|
||||||
for u.allowApproval.Clicked(gtx) {
|
for u.allowApprovalOnce.Clicked(gtx) {
|
||||||
u.runAction("allow API request", func() error {
|
u.runAction("allow API request", func() error {
|
||||||
outcome := apiapproval.OutcomeAllowOnce
|
return u.resolvePendingApproval(apiapproval.OutcomeAllowOnce)
|
||||||
if u.approvalPermanent.Value {
|
|
||||||
outcome = apiapproval.OutcomeAllowPermanent
|
|
||||||
}
|
|
||||||
err := u.resolvePendingApproval(outcome)
|
|
||||||
u.approvalPermanent.Value = false
|
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for u.denyApproval.Clicked(gtx) {
|
for u.allowApprovalPermanent.Clicked(gtx) {
|
||||||
|
u.runAction("allow API request permanently", func() error {
|
||||||
|
return u.resolvePendingApproval(apiapproval.OutcomeAllowPermanent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for u.denyApprovalOnce.Clicked(gtx) {
|
||||||
u.runAction("deny API request", func() error {
|
u.runAction("deny API request", func() error {
|
||||||
outcome := apiapproval.OutcomeDenyOnce
|
return u.resolvePendingApproval(apiapproval.OutcomeDenyOnce)
|
||||||
if u.approvalPermanent.Value {
|
})
|
||||||
outcome = apiapproval.OutcomeDenyPermanent
|
}
|
||||||
}
|
for u.denyApprovalPermanent.Clicked(gtx) {
|
||||||
err := u.resolvePendingApproval(outcome)
|
u.runAction("deny API request permanently", func() error {
|
||||||
u.approvalPermanent.Value = false
|
return u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent)
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for u.cancelApproval.Clicked(gtx) {
|
for u.cancelApproval.Clicked(gtx) {
|
||||||
u.runAction("cancel API request", func() error {
|
u.runAction("cancel API request", func() error {
|
||||||
err := u.resolvePendingApproval(apiapproval.OutcomeCancel)
|
return u.resolvePendingApproval(apiapproval.OutcomeCancel)
|
||||||
u.approvalPermanent.Value = false
|
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -996,6 +992,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
|
|||||||
for u.addAPIPolicyRule.Clicked(gtx) {
|
for u.addAPIPolicyRule.Clicked(gtx) {
|
||||||
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
||||||
}
|
}
|
||||||
|
for u.saveAPIPolicyRule.Clicked(gtx) {
|
||||||
|
u.runAction("save API policy rule", u.saveAPIPolicyRuleAction)
|
||||||
|
}
|
||||||
|
for u.cancelAPIPolicyEdit.Clicked(gtx) {
|
||||||
|
u.runAction("cancel API policy edit", u.cancelAPIPolicyEditAction)
|
||||||
|
}
|
||||||
for u.useCurrentGroupForPolicy.Clicked(gtx) {
|
for u.useCurrentGroupForPolicy.Clicked(gtx) {
|
||||||
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
|
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1007,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
|
|||||||
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
||||||
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
||||||
}
|
}
|
||||||
|
for i := range u.apiPolicyEdits {
|
||||||
|
for u.apiPolicyEdits[i].Clicked(gtx) {
|
||||||
|
index := i
|
||||||
|
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := range u.apiPolicyRemoves {
|
for i := range u.apiPolicyRemoves {
|
||||||
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
||||||
index := i
|
index := i
|
||||||
|
|||||||
+159
-1
@@ -754,6 +754,23 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
|
|||||||
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
|
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u.apiTokenName.SetText("Browser Extension Updated")
|
||||||
|
u.apiTokenClientName.SetText("firefox-desktop")
|
||||||
|
u.apiTokenExpiresAt.SetText("2026-05-01T00:00:00Z")
|
||||||
|
if err := u.saveAPITokenAction(); err != nil {
|
||||||
|
t.Fatalf("saveAPITokenAction() error = %v", err)
|
||||||
|
}
|
||||||
|
updated, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedAPIToken() ok = false, want true after save")
|
||||||
|
}
|
||||||
|
if updated.Name != "Browser Extension Updated" || updated.ClientName != "firefox-desktop" {
|
||||||
|
t.Fatalf("updated token = %#v, want renamed/firefox-desktop", updated)
|
||||||
|
}
|
||||||
|
if updated.ExpiresAt == nil || updated.ExpiresAt.UTC().Format(time.RFC3339) != "2026-05-01T00:00:00Z" {
|
||||||
|
t.Fatalf("updated.ExpiresAt = %#v, want 2026-05-01T00:00:00Z", updated.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
if err := u.disableAPITokenAction(); err != nil {
|
if err := u.disableAPITokenAction(); err != nil {
|
||||||
t.Fatalf("disableAPITokenAction() error = %v", err)
|
t.Fatalf("disableAPITokenAction() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -761,9 +778,17 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
|
|||||||
if !ok || !disabled.Disabled {
|
if !ok || !disabled.Disabled {
|
||||||
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
|
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := u.revokeAPITokenAction(); err != nil {
|
||||||
|
t.Fatalf("revokeAPITokenAction() error = %v", err)
|
||||||
|
}
|
||||||
|
revoked, ok := u.selectedAPIToken()
|
||||||
|
if !ok || revoked.RevokedAt == nil {
|
||||||
|
t.Fatalf("selectedAPIToken() = %#v, want revoked token", revoked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
|
func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
@@ -799,6 +824,33 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
|
|||||||
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
|
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
|
||||||
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
|
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
|
||||||
}
|
}
|
||||||
|
if len(u.apiPolicyEdits) != 1 {
|
||||||
|
t.Fatalf("len(apiPolicyEdits) = %d, want 1", len(u.apiPolicyEdits))
|
||||||
|
}
|
||||||
|
|
||||||
|
u.apiPolicyEdits[0].Click()
|
||||||
|
u.handleAPIPolicyClicks(layout.Context{})
|
||||||
|
if u.selectedAPIPolicyIndex != 0 {
|
||||||
|
t.Fatalf("selectedAPIPolicyIndex = %d, want 0 after edit click", u.selectedAPIPolicyIndex)
|
||||||
|
}
|
||||||
|
if got := u.apiPolicyPath.Text(); got != "Root / Internet" {
|
||||||
|
t.Fatalf("apiPolicyPath = %q, want Root / Internet after edit load", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.apiPolicyPath.SetText("Root / Security")
|
||||||
|
u.saveAPIPolicyRule.Click()
|
||||||
|
u.handleAPIPolicyClicks(layout.Context{})
|
||||||
|
|
||||||
|
token, ok = u.selectedAPIToken()
|
||||||
|
if !ok || len(token.Policies) != 1 {
|
||||||
|
t.Fatalf("selectedAPIToken().Policies after save = %#v, want 1 rule", token.Policies)
|
||||||
|
}
|
||||||
|
if got := strings.Join(token.Policies[0].Resource.Path, " / "); got != "Root / Security" {
|
||||||
|
t.Fatalf("updated policy path = %q, want Root / Security", got)
|
||||||
|
}
|
||||||
|
if u.selectedAPIPolicyIndex != -1 {
|
||||||
|
t.Fatalf("selectedAPIPolicyIndex after save = %d, want -1", u.selectedAPIPolicyIndex)
|
||||||
|
}
|
||||||
|
|
||||||
if err := u.removeAPIPolicyRuleAction(0); err != nil {
|
if err := u.removeAPIPolicyRuleAction(0); err != nil {
|
||||||
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
|
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
|
||||||
@@ -4858,6 +4910,112 @@ func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIApprovalDialogVisibleForPendingRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{
|
||||||
|
ID: "approval-1",
|
||||||
|
TokenName: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
Operation: apitokens.OperationReadEntry,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
EntryID: "vault-console",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
dims := u.approvalDialogContent(layout.Context{
|
||||||
|
Ops: new(op.Ops),
|
||||||
|
Constraints: layout.Exact(image.Pt(800, 600)),
|
||||||
|
})
|
||||||
|
if dims.Size.X == 0 || dims.Size.Y == 0 {
|
||||||
|
t.Fatalf("approvalDialogContent() = %v, want visible dimensions for pending approval", dims.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIApprovalButtonsResolveAllOutcomes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
click func(*ui)
|
||||||
|
want apiapproval.Outcome
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "allow once",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.allowApprovalOnce.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeAllowOnce,
|
||||||
|
wantMsg: "allow API request complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow permanently",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.allowApprovalPermanent.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeAllowPermanent,
|
||||||
|
wantMsg: "allow API request permanently complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deny once",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.denyApprovalOnce.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeDenyOnce,
|
||||||
|
wantMsg: "deny API request complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deny permanently",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.denyApprovalPermanent.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeDenyPermanent,
|
||||||
|
wantMsg: "deny API request permanently complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancel",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.cancelApproval.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeCancel,
|
||||||
|
wantMsg: "cancel API request complete",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{
|
||||||
|
ID: "approval-1",
|
||||||
|
TokenName: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
Operation: apitokens.OperationReadEntry,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = manager
|
||||||
|
|
||||||
|
tt.click(u)
|
||||||
|
u.handleApprovalClicks(layout.Context{})
|
||||||
|
|
||||||
|
if manager.lastID != "approval-1" || manager.lastOutcome != tt.want {
|
||||||
|
t.Fatalf("handleApprovalClicks() delegated (%q, %q), want (approval-1, %q)", manager.lastID, manager.lastOutcome, tt.want)
|
||||||
|
}
|
||||||
|
if got := u.state.StatusMessage; got != tt.wantMsg {
|
||||||
|
t.Fatalf("state.StatusMessage = %q, want %q", got, tt.wantMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
} else if host != nil {
|
} else if host != nil {
|
||||||
ui.apiHost = host
|
ui.apiHost = host
|
||||||
ui.auditLog = host.Server().AuditLog()
|
ui.auditLog = host.Server().AuditLog()
|
||||||
|
ui.state.AuditLog = ui.auditLog
|
||||||
ui.grpcAddress = host.Address()
|
ui.grpcAddress = host.Address()
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
|
|||||||
Reference in New Issue
Block a user