diff --git a/module/REST_SURFACE.md b/module/REST_SURFACE.md index 5d027e3bc..8c4edd051 100644 --- a/module/REST_SURFACE.md +++ b/module/REST_SURFACE.md @@ -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 diff --git a/module/services/accounts/code/pkg/permissionsplugin/plugin.go b/module/services/accounts/code/pkg/permissionsplugin/plugin.go index 69eef6003..4ca8b1be8 100644 --- a/module/services/accounts/code/pkg/permissionsplugin/plugin.go +++ b/module/services/accounts/code/pkg/permissionsplugin/plugin.go @@ -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 @@ -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: // @@ -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 @@ -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 } diff --git a/module/services/accounts/code/pkg/permissionsplugin/plugin_test.go b/module/services/accounts/code/pkg/permissionsplugin/plugin_test.go new file mode 100644 index 000000000..49d74ea39 --- /dev/null +++ b/module/services/accounts/code/pkg/permissionsplugin/plugin_test.go @@ -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) + }) + } +}