Add API approval broker for gRPC authorization prompts
This commit is contained in:
+173
-2
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/passwords"
|
||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||
@@ -85,6 +86,7 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
||||
},
|
||||
testAPITokenEntry(t,
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}}},
|
||||
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "git-server", Path: []string{"Root", "Internet"}}},
|
||||
),
|
||||
},
|
||||
})
|
||||
@@ -96,6 +98,146 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
respCh <- resp
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if pending.Operation != apitokens.OperationListEntries {
|
||||
t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries)
|
||||
}
|
||||
if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
|
||||
t.Fatalf("ResolveApproval(allow) error = %v", err)
|
||||
}
|
||||
|
||||
resp := <-respCh
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("ListEntries() error = %v", err)
|
||||
}
|
||||
if len(resp.Entries) != 1 || resp.Entries[0].Id != "git-server" {
|
||||
t.Fatalf("ListEntries().Entries = %#v, want git-server", resp.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{
|
||||
ID: "git-server",
|
||||
Title: "Git Server",
|
||||
Username: "joejulian",
|
||||
Password: "token-1",
|
||||
URL: "https://git.julianfamily.org",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil {
|
||||
t.Fatalf("ResolveApproval(deny permanent) error = %v", err)
|
||||
}
|
||||
|
||||
if err := <-errCh; status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||
}
|
||||
|
||||
service.mu.RLock()
|
||||
tokens, err := apitokens.Entries(service.model)
|
||||
service.mu.RUnlock()
|
||||
if err != nil {
|
||||
t.Fatalf("Entries() error = %v", err)
|
||||
}
|
||||
decision := apitokens.Evaluate(tokens[0], apitokens.OperationListEntries, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Internet"}})
|
||||
if decision != apitokens.DecisionDeny {
|
||||
t.Fatalf("Evaluate() after permanent deny = %q, want %q", decision, apitokens.DecisionDeny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(time.Minute)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
pending := waitForServerPendingApproval(t, service, 1)[0]
|
||||
if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
|
||||
t.Fatalf("ResolveApproval(cancel) error = %v", err)
|
||||
}
|
||||
|
||||
if err := <-errCh; status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceTimesOutPendingApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{ID: "git-server", Title: "Git Server", Path: []string{"Root", "Internet"}},
|
||||
testAPITokenEntry(t),
|
||||
},
|
||||
}
|
||||
client, _, service, cleanup := newTestHarnessForModel(t, model)
|
||||
defer cleanup()
|
||||
service.approvals = apiapproval.NewBroker(20 * time.Millisecond)
|
||||
|
||||
_, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}})
|
||||
if status.Code(err) != codes.DeadlineExceeded {
|
||||
t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.DeadlineExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -936,6 +1078,11 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
||||
}
|
||||
|
||||
func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
client, clipboardWriter, _, cleanup := newTestHarnessForModel(t, model)
|
||||
return client, clipboardWriter, cleanup
|
||||
}
|
||||
|
||||
func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
|
||||
t.Helper()
|
||||
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
@@ -963,10 +1110,15 @@ func newTestClientForModel(t *testing.T, model vault.Model) (keepassgov1.VaultSe
|
||||
server.Stop()
|
||||
}
|
||||
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
|
||||
}
|
||||
|
||||
func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) {
|
||||
client, clipboardWriter, _, cleanup := newTestHarnessWithLifecycle(t, lifecycle)
|
||||
return client, clipboardWriter, cleanup
|
||||
}
|
||||
|
||||
func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, *Server, func()) {
|
||||
t.Helper()
|
||||
|
||||
listener := bufconn.Listen(1024 * 1024)
|
||||
@@ -1001,7 +1153,7 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass
|
||||
server.Stop()
|
||||
}
|
||||
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup
|
||||
return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, service, cleanup
|
||||
}
|
||||
|
||||
type memoryClipboardWriter struct {
|
||||
@@ -1013,6 +1165,21 @@ func (w *memoryClipboardWriter) WriteText(text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForServerPendingApproval(t *testing.T, server *Server, want int) []apiapproval.Request {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
pending := server.ApprovalBroker().Pending()
|
||||
if len(pending) == want {
|
||||
return pending
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("server pending approvals never reached len %d", want)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubLifecycle struct {
|
||||
model vault.Model
|
||||
openPath string
|
||||
@@ -1087,3 +1254,7 @@ func (s *stubLifecycle) Unlock(key vault.MasterKey) error {
|
||||
s.locked = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubLifecycle) Replace(model vault.Model) {
|
||||
s.model = model
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user