diff --git a/cmd/cloudx/get.go b/cmd/cloudx/get.go index 7eded967..74545fdf 100644 --- a/cmd/cloudx/get.go +++ b/cmd/cloudx/get.go @@ -25,6 +25,7 @@ func NewGetCmd() *cobra.Command { project.NewGetProjectCmd(), project.NewGetKratosConfigCmd(), project.NewGetKetoConfigCmd(), + project.NewGetOPLCmd(), project.NewGetOAuth2ConfigCmd(), workspace.NewGetCmd(), identity.NewGetIdentityCmd(), diff --git a/cmd/cloudx/project/get_identity_config.go b/cmd/cloudx/project/get_identity_config.go index 5584dacc..0d0f5876 100644 --- a/cmd/cloudx/project/get_identity_config.go +++ b/cmd/cloudx/project/get_identity_config.go @@ -44,7 +44,7 @@ $ ory get identity-config --format json # uses currently selected project return cmdx.PrintOpenAPIError(cmd, err) } - cmdx.PrintJSONAble(cmd, outputConfig(project.Services.Identity.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(project.Services.GetIdentity().Config)) return nil }, } diff --git a/cmd/cloudx/project/get_namespace_config.go b/cmd/cloudx/project/get_namespace_config.go new file mode 100644 index 00000000..5fdc32fb --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config.go @@ -0,0 +1,135 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/spf13/cobra" + "github.com/tidwall/gjson" + + "github.com/ory/cli/cmd/cloudx/client" + "github.com/ory/x/cmdx" + "github.com/ory/x/fetcher" +) + +// errNoOPLConfigured is returned when the project has no Ory Permission +// Language file, so there is nothing to print. +var errNoOPLConfigured = errors.New("no Ory Permission Language file is configured for this project") + +// errLegacyNamespaces is returned for projects still using the legacy list of +// namespace definitions instead of an Ory Permission Language file. +var errLegacyNamespaces = errors.New("this project uses legacy namespace definitions instead of an Ory Permission Language file, use `ory get permission-config` to read them") + +// newOPLFetcher returns the loader that resolves an Ory Permission Language +// file location. Local files are deliberately not allowed: the location comes +// from the API and must never make the CLI read from the developer's disk. +// +// The command and its tests share this constructor so that the tests pin the +// loader the command actually uses. +func newOPLFetcher() *fetcher.Fetcher { + return fetcher.NewFetcher(fetcher.WithAllowedSchemes("http", "https", "base64")) +} + +// oplLocation extracts the Ory Permission Language file location from an Ory +// Permissions config. +// +// Ory Network does not inline the OPL source in the project config; it stores a +// pointer to it, which is why `ory get permission-config` shows +// `{"namespaces":{"location":"..."}}` rather than the file itself. +func oplLocation(config map[string]interface{}) (string, error) { + // The config is a decoded JSON document, so serializing it back cannot fail. + raw, err := json.Marshal(config) + if err != nil { + return "", fmt.Errorf("unable to read the Ory Permissions configuration: %w", err) + } + namespaces := gjson.GetBytes(raw, "namespaces") + + // Legacy projects list their namespace definitions here instead of pointing + // at an OPL file. An empty list is not a legacy configuration though, there + // simply is nothing configured. + if namespaces.IsArray() && len(namespaces.Array()) > 0 { + return "", errLegacyNamespaces + } + + // Anything that is neither an OPL pointer nor legacy definitions carries no + // file to print, and reads as an empty location here. + location := namespaces.Get("location").String() + if location == "" { + return "", errNoOPLConfigured + } + return location, nil +} + +func NewGetOPLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "opl", + Aliases: []string{ + "namespaces-config", + }, + Args: cobra.NoArgs, + Short: "Get the Ory Permission Language file from Ory Network", + Long: `Get the Ory Permission Language file of an Ory Network project. + +The file is written to stdout as-is, so it can be redirected to disk: + + ory get opl > namespace_config.ts + +This is the counterpart of ` + "`ory update opl`" + `. Use it to check the +configured Ory Permission Language file into version control. + +Note that ` + "`ory get permission-config`" + ` returns the location of this file +rather than its contents, because that is how Ory Network stores it.`, + Example: `$ {{ .CommandPath }} --project ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 + +class Example implements Namespace {} +`, + RunE: func(cmd *cobra.Command, _ []string) error { + h, err := client.NewCobraCommandHelper(cmd) + if err != nil { + return err + } + + pID, err := h.ProjectID() + if err != nil { + return err + } + + project, err := h.GetProject(cmd.Context(), pID, nil) + if err != nil { + return cmdx.PrintOpenAPIError(cmd, err) + } + + location, err := oplLocation(project.Services.GetPermission().Config) + if err != nil { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), err) + return cmdx.FailSilently(cmd) + } + + // The location is either a remote URL or an inlined base64 payload. + // The fetcher verifies the HTTP status code, so an expired storage + // link cannot be mistaken for the file, honors cancellation of the + // command context, and redacts base64 payloads from its errors. + opl, err := newOPLFetcher().FetchBytes(cmd.Context(), location) + if err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "unable to read the Ory Permission Language file: %s\n", err) + return cmdx.FailSilently(cmd) + } + + // Writing can fail on a closed pipe (`ory get opl | head`), which is + // not worth a usage dump. + if _, err := cmd.OutOrStdout().Write(opl); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "unable to write the Ory Permission Language file: %s\n", err) + return cmdx.FailSilently(cmd) + } + return nil + }, + } + + client.RegisterProjectFlag(cmd.Flags()) + client.RegisterWorkspaceFlag(cmd.Flags()) + return cmd +} diff --git a/cmd/cloudx/project/get_namespace_config_live_test.go b/cmd/cloudx/project/get_namespace_config_live_test.go new file mode 100644 index 00000000..17fd6df8 --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config_live_test.go @@ -0,0 +1,55 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetOPL covers the round-trip #321 asks for, in both directions: +// +// ory update opl --file namespace_config.ts # what `get opl` must return +// ory get opl > namespace_config.ts # must be usable as input again +// +// The second direction is the one that matters for checking the permission +// model into version control: if the two commands disagreed on the file format, +// committing `get opl` output and applying it later would corrupt the model +// rather than restore it. +func TestGetOPL(t *testing.T) { + if testing.Short() { + // this test needs internet, typically not available when you're on a (german) train + t.Skip("skipping test that requires internet access") + } + + t.Parallel() + + content := `class Default implements Namespace {}` + config := writeFile(t, content) + + // The read-after-write assertions below would be racy against the shared + // fixtures: other tests in this package rewrite their permission config in + // parallel. + project := newProject(t) + + _, stderr, err := defaultCmd.Exec(nil, "update", "opl", "--project", project, "--file", config) + require.NoError(t, err, stderr) + + opl, stderr, err := defaultCmd.Exec(nil, "get", "opl", "--project", project) + require.NoError(t, err, stderr) + require.Equal(t, content, opl, "`get opl` must return what `update opl` uploaded") + + // Feed `get opl` output straight back in, exactly as a user committing the + // file and re-applying it would, and read it once more. Writing it to disk + // verbatim also catches any stray wrapping or trailing newline that would + // accumulate over repeated round-trips. + _, stderr, err = defaultCmd.Exec(nil, "update", "opl", "--project", project, "--file", writeFile(t, opl)) + require.NoError(t, err, stderr, "`get opl` output must be accepted by `update opl`") + + opl, stderr, err = defaultCmd.Exec(nil, "get", "opl", "--project", project) + require.NoError(t, err, stderr) + assert.Equal(t, content, opl, "the OPL file must survive a full get/update round-trip unchanged") +} diff --git a/cmd/cloudx/project/get_namespace_config_test.go b/cmd/cloudx/project/get_namespace_config_test.go new file mode 100644 index 00000000..6fd3901d --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config_test.go @@ -0,0 +1,142 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/x/fetcher" +) + +// TestOPLLocation covers https://github.com/ory/cli/issues/321: the permission +// config reports where the Ory Permission Language file lives rather than its +// contents, and `ory get opl` has to resolve that pointer. +func TestOPLLocation(t *testing.T) { + for _, tc := range []struct { + name string + config map[string]interface{} + want string + wantErr error + }{ + { + name: "case=reads the location of an OPL file", + config: map[string]interface{}{"namespaces": map[string]interface{}{"location": "https://example.com/opl.bin"}}, + want: "https://example.com/opl.bin", + }, + { + name: "case=legacy namespace definitions are reported as such", + config: map[string]interface{}{"namespaces": []interface{}{map[string]interface{}{"name": "files", "id": 1}}}, + wantErr: errLegacyNamespaces, + }, + { + name: "case=an empty legacy list is not a legacy configuration", + config: map[string]interface{}{"namespaces": []interface{}{}}, + wantErr: errNoOPLConfigured, + }, + { + name: "case=missing namespaces key", + config: map[string]interface{}{"limit": map[string]interface{}{}}, + wantErr: errNoOPLConfigured, + }, + { + name: "case=null namespaces", + config: map[string]interface{}{"namespaces": nil}, + wantErr: errNoOPLConfigured, + }, + { + name: "case=nil config", + config: nil, + wantErr: errNoOPLConfigured, + }, + { + name: "case=namespaces without a location", + config: map[string]interface{}{"namespaces": map[string]interface{}{}}, + wantErr: errNoOPLConfigured, + }, + { + name: "case=empty location", + config: map[string]interface{}{"namespaces": map[string]interface{}{"location": ""}}, + wantErr: errNoOPLConfigured, + }, + { + // Anything that is neither an OPL pointer nor legacy definitions + // carries no file to print, so it is reported as nothing configured. + name: "case=namespaces of an unexpected shape", + config: map[string]interface{}{"namespaces": "https://example.com/opl.bin"}, + wantErr: errNoOPLConfigured, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := oplLocation(tc.config) + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestOPLLocationIsReadable pins the loader contract the command relies on: +// `ory update opl` writes the file as a base64:// payload, and Ory Network +// hands back an https:// location, so both have to resolve — while file:// +// must not, since the location comes from the API. An error response must not +// be mistaken for the file either, or `ory get opl > opl.ts` writes the +// storage provider's error page to disk. +func TestOPLLocationIsReadable(t *testing.T) { + const opl = "class Example implements Namespace {}" + + f := newOPLFetcher() + + t.Run("case=base64 payloads are read", func(t *testing.T) { + location := "base64://" + base64.StdEncoding.EncodeToString([]byte(opl)) + + got, err := f.FetchBytes(t.Context(), location) + require.NoError(t, err) + assert.Equal(t, opl, string(got)) + }) + + t.Run("case=remote files are read", func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(opl)) + })) + t.Cleanup(s.Close) + + got, err := f.FetchBytes(t.Context(), s.URL+"/opl.bin") + require.NoError(t, err) + assert.Equal(t, opl, string(got)) + }) + + t.Run("case=local files are refused", func(t *testing.T) { + // The file has to exist, otherwise the test would pass even if the + // file loader were enabled. + path := filepath.Join(t.TempDir(), "opl.ts") + require.NoError(t, os.WriteFile(path, []byte(opl), 0o600)) + + got, err := f.FetchBytes(t.Context(), "file://"+path) + require.ErrorIs(t, err, fetcher.ErrUnknownScheme, "a location from the API must never read from the local disk") + assert.NotContains(t, string(got), opl) + }) + + t.Run("case=error responses are not mistaken for the file", func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`AccessDenied`)) + })) + t.Cleanup(s.Close) + + got, err := f.FetchBytes(t.Context(), s.URL+"/opl.bin") + require.ErrorContains(t, err, "status code 200 but got 403") + assert.Empty(t, got) + }) +} diff --git a/cmd/cloudx/project/get_oauth2_config.go b/cmd/cloudx/project/get_oauth2_config.go index 5fc88485..83cd8556 100644 --- a/cmd/cloudx/project/get_oauth2_config.go +++ b/cmd/cloudx/project/get_oauth2_config.go @@ -52,7 +52,7 @@ $ ory get oauth2-config --format json # uses currently selected project return cmdx.PrintOpenAPIError(cmd, err) } - cmdx.PrintJSONAble(cmd, outputConfig(project.Services.Oauth2.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(project.Services.GetOauth2().Config)) return nil }, } diff --git a/cmd/cloudx/project/get_permission_config.go b/cmd/cloudx/project/get_permission_config.go index 8635dbac..5edca128 100644 --- a/cmd/cloudx/project/get_permission_config.go +++ b/cmd/cloudx/project/get_permission_config.go @@ -16,19 +16,21 @@ func NewGetKetoConfigCmd() *cobra.Command { Aliases: []string{"pc", "keto-config"}, Args: cobra.NoArgs, Short: "Get Ory Permissions configuration.", - Long: "Get the Ory Permissions configuration for an Ory Network project.", + Long: `Get the Ory Permissions configuration for an Ory Network project. + +Ory Network stores the Ory Permission Language file separately and reports its +location here rather than its contents. To read the file itself, use ` + "`ory get opl`" + `.`, Example: `$ ory get permission-config --project ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 --format yaml > permission-config.yaml $ ory get permission-config --format json # uses currently selected project { - "namespaces": [ - { - "name": "files", - "id": 1 - },1 - // ... - ] + "limit": { + "max_read_depth": 3 + }, + "namespaces": { + "location": "https://storage.googleapis.com/bac-gcs-production/..." + } }`, RunE: func(cmd *cobra.Command, _ []string) error { h, err := client.NewCobraCommandHelper(cmd) @@ -45,7 +47,7 @@ $ ory get permission-config --format json # uses currently selected project return cmdx.PrintOpenAPIError(cmd, err) } - cmdx.PrintJSONAble(cmd, outputConfig(project.Services.Permission.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(project.Services.GetPermission().Config)) return nil }, } diff --git a/cmd/cloudx/project/update_namespace_config.go b/cmd/cloudx/project/update_namespace_config.go index 007c0ebe..94a6708d 100644 --- a/cmd/cloudx/project/update_namespace_config.go +++ b/cmd/cloudx/project/update_namespace_config.go @@ -28,7 +28,9 @@ func NewUpdateNamespaceConfigCmd() *cobra.Command { class Example implements Namespace {} `, - Long: "Update the Ory Permission Language file in Ory Network. Legacy namespace definitions will be overwritten.", + Long: `Update the Ory Permission Language file in Ory Network. Legacy namespace definitions will be overwritten. + +This is the counterpart of ` + "`ory get opl`" + `, which reads the configured file back.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() h, err := client.NewCobraCommandHelper(cmd) @@ -53,7 +55,7 @@ class Example implements Namespace {} return cmdx.PrintOpenAPIError(cmd, err) } - cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.Permission.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.GetPermission().Config)) return h.PrintUpdateProjectWarnings(p) }, diff --git a/cmd/cloudx/project/utils.go b/cmd/cloudx/project/utils.go index 09ca1502..0008a945 100644 --- a/cmd/cloudx/project/utils.go +++ b/cmd/cloudx/project/utils.go @@ -64,13 +64,13 @@ func outputFullProject(cmd *cobra.Command, p *cloud.SuccessfulProjectUpdate) { } func outputIdentityConfig(cmd *cobra.Command, p *cloud.SuccessfulProjectUpdate) { - cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.Identity.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.GetIdentity().Config)) } func outputPermissionConfig(cmd *cobra.Command, p *cloud.SuccessfulProjectUpdate) { - cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.Permission.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.GetPermission().Config)) } func outputOAuth2Config(cmd *cobra.Command, p *cloud.SuccessfulProjectUpdate) { - cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.Oauth2.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(p.Project.Services.GetOauth2().Config)) }