Merge pull request 'Complete API token gRPC authorization' (#3) from feature/api-token-grpc-authz into main
ci / lint-test (push) Successful in 1m14s
ci / build (push) Successful in 2m39s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-04-11 07:11:01 +00:00
15 changed files with 568 additions and 287 deletions
-189
View File
@@ -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:
+1 -1
View File
@@ -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{
+11 -1
View File
@@ -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
View File
@@ -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)
}
}
+65 -2
View File
@@ -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() {
+6
View File
@@ -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"
+12 -9
View File
@@ -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 {
+34 -2
View File
@@ -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 {
+21 -1
View File
@@ -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) {
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -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() }()