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_namespace_config.go b/cmd/cloudx/project/get_namespace_config.go new file mode 100644 index 00000000..1eee4dfa --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config.go @@ -0,0 +1,125 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "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") + +// oplSchemes are the locations `ory get opl` is willing to resolve. Local files +// are deliberately not included: the location comes from the API and must never +// make the CLI read from the developer's disk. +var oplSchemes = []string{"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) { + namespaces, ok := config["namespaces"] + if !ok || namespaces == nil { + return "", errNoOPLConfigured + } + + switch n := namespaces.(type) { + case map[string]interface{}: + location, ok := n["location"].(string) + if !ok || location == "" { + return "", errNoOPLConfigured + } + return location, nil + case []interface{}: + if len(n) == 0 { + // An empty legacy list is not a legacy configuration, there simply + // is nothing configured. + return "", errNoOPLConfigured + } + return "", errLegacyNamespaces + default: + return "", fmt.Errorf("unexpected type %T for the namespaces configuration", namespaces) + } +} + +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 := fetcher.NewFetcher(fetcher.WithAllowedSchemes(oplSchemes...)). + 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) + } + + _, err = cmd.OutOrStdout().Write(opl) + return err + }, + } + + 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..0612669f --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config_live_test.go @@ -0,0 +1,61 @@ +// 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" + + "github.com/ory/cli/cmd/cloudx/testhelpers" +) + +// 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. +// +// It runs against a dedicated project rather than the shared fixtures on +// purpose. Several tests in this package concurrently rewrite the permission +// config of defaultProject and extraProject — patch_permission_config_test.go +// replaces /namespaces with legacy definitions — so any read-after-write +// assertion against those is racy. +func TestGetOPL(t *testing.T) { + if testing.Short() { + // this test needs internet, typically not available when you're on a (german) train + return + } + + t.Parallel() + + content := `class Default implements Namespace {}` + config := writeFile(t, content) + + workspace := testhelpers.CreateWorkspace(ctx, t) + project := testhelpers.CreateProject(ctx, t, workspace) + + _, stderr, err := defaultCmd.Exec(nil, "update", "opl", "--project", project.Id, "--file", config) + require.NoError(t, err, stderr) + + opl, stderr, err := defaultCmd.Exec(nil, "get", "opl", "--project", project.Id) + 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.Id, "--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.Id) + 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..9318bcf2 --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config_test.go @@ -0,0 +1,135 @@ +// 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, + }, + } { + 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 := fetcher.NewFetcher(fetcher.WithAllowedSchemes(oplSchemes...)) + + 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.Error(t, err, "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.Error(t, err) + assert.Empty(t, got) + }) +} 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 }, }