Skip to content
Closed
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
6 changes: 6 additions & 0 deletions module/REST_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ unknown field, missing/extra service, unsupported binding kind, duplicate
plugin, unsafe template, route collision, internal exposure, or ownership drift
fails generation.

The plugin registers the generated `InstallationService` transcoder as well as
the principal, delegation and Work Context transcoders. Its transport regression
test exercises every installation REST route through gRPC and preserves the
selected RPC's permission refusal; catalog presence alone does not prove a
handler is mounted.

## Runtime boundary

Accounts registers only catalog-selected services and wraps grpc-gateway in a
Expand Down
10 changes: 8 additions & 2 deletions module/services/accounts/code/pkg/permissionsplugin/plugin.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Package permissionsplugin provides a framework.Plugin
// implementation that exposes PrincipalService and DelegationService through
// implementation that exposes the catalog-selected permissions services through
// the selected REST plugin and contributes their migrations.
//
// Native gRPC and Connect registration is generated from the service catalog
Expand Down Expand Up @@ -117,7 +117,7 @@ func (p *Plugin) Name() string { return "permissions" }

// RegisterREST implements framework.Plugin. Wires the
// grpc-gateway REST handlers — auto-derived from the proto
// HTTP annotations on PrincipalService / DelegationService.
// HTTP annotations on the services assigned to this plugin in rest_bindings.yaml.
//
// REST endpoints exposed:
//
Expand All @@ -129,6 +129,9 @@ func (p *Plugin) Name() string { return "permissions" }
// GET /v1/delegations/{id}:wait ← server-streaming
// POST /v1/delegations/{id}:decide
// GET /v1/delegations:pending
//
// InstallationService and WorkContextService also use their generated handlers;
// the descriptor catalog owns their complete route inventory.
func (p *Plugin) RegisterREST(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error {
if err := gen.RegisterPrincipalServiceHandlerFromEndpoint(ctx, mux, endpoint, opts); err != nil {
return err
Expand All @@ -139,6 +142,9 @@ func (p *Plugin) RegisterREST(ctx context.Context, mux *runtime.ServeMux, endpoi
if err := gen.RegisterWorkContextServiceHandlerFromEndpoint(ctx, mux, endpoint, opts); err != nil {
return err
}
if err := gen.RegisterInstallationServiceHandlerFromEndpoint(ctx, mux, endpoint, opts); err != nil {
return err
}
return nil
}

Expand Down
62 changes: 62 additions & 0 deletions module/services/accounts/code/pkg/permissionsplugin/plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package permissionsplugin

import (
"context"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"

gen "accounts/pkg/gen/saas/accounts/v1"

"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
)

func TestInstallationRESTReachesSelectedRPC(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
listener := bufconn.Listen(1024 * 1024)
t.Cleanup(func() { _ = listener.Close() })
server := grpc.NewServer(grpc.UnaryInterceptor(func(_ context.Context, _ any, info *grpc.UnaryServerInfo, _ grpc.UnaryHandler) (any, error) {
// A refusal from the selected RPC proves transcoding reached it. The
// registration must not turn an authorization refusal into a route 404.
return nil, status.Error(codes.PermissionDenied, info.FullMethod)
}))
gen.RegisterInstallationServiceServer(server, &gen.UnimplementedInstallationServiceServer{})
t.Cleanup(server.Stop)
go func() { _ = server.Serve(listener) }()

mux := runtime.NewServeMux()
require.NoError(t, New().RegisterREST(ctx, mux, "passthrough:///installations", []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }),
}))
for _, tc := range []struct{ method, path, rpc string }{
{http.MethodGet, "/v1/installations:listAvailable?org_id=org", "ListAvailableSolutions"},
{http.MethodGet, "/v1/installations?org_id=org", "ListOrganizationInstallations"},
{http.MethodGet, "/v1/installations/installation", "GetInstallation"},
{http.MethodPost, "/v1/installations", "InstallSolution"},
{http.MethodPost, "/v1/installations/installation:uninstall", "UninstallSolution"},
{http.MethodPost, "/v1/installations/installation:narrow", "NarrowInstallation"},
{http.MethodPost, "/v1/installations/installation:retry", "RetryInstallation"},
{http.MethodPost, "/v1/installations/installation:transferOwnership", "TransferInstallationOwnership"},
{http.MethodPost, "/v1/installations/installation/teams", "GrantInstallationToTeam"},
{http.MethodPost, "/v1/installations/installation/teams/team:revoke", "RevokeInstallationFromTeam"},
} {
t.Run(tc.rpc, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tc.method, tc.path, strings.NewReader("{}"))
request.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(recorder, request)
require.Equal(t, http.StatusForbidden, recorder.Code, recorder.Body.String())
require.Contains(t, recorder.Body.String(), "/saas.accounts.v1.InstallationService/"+tc.rpc)
})
}
}
Loading