Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,11 @@ go vet ./...
```

Licensed under Apache-2.0.

### Permissions provisioned before execution

When a controller has already granted permissions, a new Session automatically
acquires the selected Context's existing authority before a protected Toolbox
operation or native command. This does not expand the Agent's permissions or
open interactive approval. Missing permissions still require `realmroot agent
request`. The Session keeps its own credential binding and DPoP key.
19 changes: 16 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ func (a *App) execCommand() *cobra.Command {
if err != nil {
return err
}
if err := acquireExistingAuthority(command.Context(), service, accessService, server, details, selected); err != nil {
return err
}
runner := execution.NewRunner(service, httpClient, command.InOrStdin(), a.stdout, a.stderr, logger)
err = runner.Run(command.Context(), server, integrations, args, execution.RunOptions{
AuthorizationDetails: selected,
Expand Down Expand Up @@ -350,7 +353,7 @@ func (a *App) toolboxCommand() *cobra.Command {
}
ctx, cancel := context.WithTimeout(command.Context(), 15*time.Minute)
defer cancel()
agentService, catalogClient, _, err := a.services()
agentService, catalogClient, httpClient, err := a.services()
if err != nil {
return err
}
Expand All @@ -375,7 +378,7 @@ func (a *App) toolboxCommand() *cobra.Command {
if a.search != "" || a.all {
return errors.New("--search and --all apply only to a Resource Server overview")
}
return a.runRestish(ctx, agentService, catalogClient, args)
return a.runRestish(ctx, agentService, catalogClient, httpClient, args)
},
}
command.Flags().String("output", "auto", "response format: auto, json, yaml, table, or raw")
Expand Down Expand Up @@ -904,7 +907,7 @@ func scopeList(scopes []string, expanded bool) string {
return fmt.Sprintf("%d available (add --all to list them)", len(scopes))
}

func (a *App) runRestish(ctx context.Context, service *agent.Service, client *catalog.Client, args []string) error {
func (a *App) runRestish(ctx context.Context, service *agent.Service, client *catalog.Client, httpClient *http.Client, args []string) error {
config, servers, err := client.RestishConfig(ctx)
if err != nil {
return err
Expand All @@ -913,6 +916,10 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
if err != nil {
return err
}
accessService, err := access.New(service, httpClient)
if err != nil {
return err
}
var genericOperation *restish.OperationInspection
if server, ok := selectedResourceServer(servers, args); ok {
profile := "default"
Expand All @@ -931,6 +938,9 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
if err != nil {
return err
}
if err := acquireExistingAuthority(ctx, service, accessService, server, details, selected); err != nil {
return err
}
}
binding, bindingErr := resolveOperationCredentialBinding(service, server, inspection, args[1:], selected)
if bindingErr != nil {
Expand Down Expand Up @@ -963,6 +973,9 @@ func (a *App) runRestish(ctx context.Context, service *agent.Service, client *ca
if contextErr != nil {
return contextErr
}
if err := acquireExistingAuthority(ctx, service, accessService, server, details, selected); err != nil {
return err
}
binding, bindingErr := resolveCredentialBindingForOperation(service, server, operation, selected)
if bindingErr != nil {
return bindingErr
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/existing_authority.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cli

import (
"context"
"errors"
"fmt"
"os"
"slices"

"github.com/realmroot/cli/internal/access"
"github.com/realmroot/cli/internal/agent"
"github.com/realmroot/cli/internal/catalog"
)

type existingAuthorityStore interface {
BindingForAuthorizationContextAllAuthority(string, []map[string]any) (agent.CredentialBinding, error)
}
type existingAuthorityRequester interface {
Request(context.Context, catalog.ResourceServer, []string, []map[string]any, string, access.RequestOptions) (access.Receipt, error)
}

func acquireExistingAuthority(ctx context.Context, store existingAuthorityStore, requester existingAuthorityRequester, server catalog.ResourceServer, contexts []catalog.AuthorizationDetail, selected []map[string]any) error {
var scopes []string
for _, detail := range contexts {
if sameDetails(detail.AuthorizationDetail, selected) {
scopes = slices.Clone(detail.AuthorizedScopes)
slices.Sort(scopes)
scopes = slices.Compact(scopes)
break
}
}
if len(scopes) == 0 {
return nil
}
binding, err := store.BindingForAuthorizationContextAllAuthority(server.ResourceURL, selected)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err == nil && allScopesPresent(scopes, binding.Scopes) {
return nil
}
receipt, err := requester.Request(ctx, server, scopes, selected, "Acquire existing Agent permissions for this Session", access.RequestOptions{Handoff: true})
if err != nil {
return err
}
if receipt.Status != "ready" {
return fmt.Errorf("previously granted %s permissions are no longer available; request access explicitly", server.CommandName)
}
return nil
}
func allScopesPresent(required, available []string) bool {
for _, scope := range required {
if !slices.Contains(available, scope) {
return false
}
}
return true
}
59 changes: 59 additions & 0 deletions internal/cli/existing_authority_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cli

import (
"context"
"os"
"reflect"
"testing"

"github.com/realmroot/cli/internal/access"
"github.com/realmroot/cli/internal/agent"
"github.com/realmroot/cli/internal/catalog"
)

type authorityFixture struct {
binding agent.CredentialBinding
err error
requested []string
handoff bool
}

func (f *authorityFixture) BindingForAuthorizationContextAllAuthority(string, []map[string]any) (agent.CredentialBinding, error) {
return f.binding, f.err
}
func (f *authorityFixture) Request(_ context.Context, _ catalog.ResourceServer, scopes []string, _ []map[string]any, _ string, options access.RequestOptions) (access.Receipt, error) {
f.requested = scopes
f.handoff = options.Handoff
return access.Receipt{Status: "ready"}, nil
}
func TestNewSessionAcquiresOnlyExistingContextPermissions(t *testing.T) {
selected := []map[string]any{{"type": "workspace", "identifier": "one"}}
details := []catalog.AuthorizationDetail{{AuthorizationDetail: selected[0], AuthorizedScopes: []string{"contents:read"}, RequestableScopes: []string{"contents:write"}}}
fixture := &authorityFixture{err: os.ErrNotExist}
if err := acquireExistingAuthority(context.Background(), fixture, fixture, catalog.ResourceServer{ResourceURL: "https://example.test"}, details, selected); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fixture.requested, []string{"contents:read"}) || !fixture.handoff {
t.Fatalf("unexpected request: %#v", fixture)
}
}
func TestExistingLocalAuthorityDoesNotRequestAgain(t *testing.T) {
selected := []map[string]any{{"type": "workspace", "identifier": "one"}}
details := []catalog.AuthorizationDetail{{AuthorizationDetail: selected[0], AuthorizedScopes: []string{"contents:read"}}}
fixture := &authorityFixture{binding: agent.CredentialBinding{Scopes: []string{"contents:read"}}}
if err := acquireExistingAuthority(context.Background(), fixture, fixture, catalog.ResourceServer{}, details, selected); err != nil {
t.Fatal(err)
}
if fixture.requested != nil {
t.Fatal("requested existing local authority")
}
}
func TestNoGrantDoesNotStartAnApproval(t *testing.T) {
fixture := &authorityFixture{err: os.ErrNotExist}
if err := acquireExistingAuthority(context.Background(), fixture, fixture, catalog.ResourceServer{}, nil, nil); err != nil {
t.Fatal(err)
}
if fixture.requested != nil {
t.Fatal("requested ungranted authority")
}
}
8 changes: 8 additions & 0 deletions specs/cli.feature
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,11 @@ Feature: Realmroot Toolbox command line
Then CLI submits the request without a permission precheck
And an unsuccessful server response exits with code 1
And JSON output preserves the server error code, message, request ID, and details

Scenario: A new Session acquires previously granted permissions automatically
Given its Agent already has permissions for the selected Resource Context
And the Session has no local credential binding
When it invokes a protected Toolbox operation or native command
Then Toolbox acquires only the existing permissions without interactive approval
And invokes the requested operation
And missing permissions still require an explicit access request