From 6f12555bc4628d69eea0de70d672784e75760ee8 Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Fri, 14 Aug 2026 13:36:23 +0200 Subject: [PATCH] feat: add `ory get opl` Ory Network does not inline the Ory Permission Language file in the project config, it stores a pointer to it. `ory get permission-config` therefore returns `{"namespaces":{"location":"https://...bin"}}` rather than the namespaces themselves, and there was no way to read the file back: `ory update opl` could write it, but nothing could retrieve it, so the config was writable as code but not retrievable as code. `ory get opl` resolves that location and writes the file to stdout, so it can be redirected to disk and checked into version control. The location is resolved with ory/x/fetcher restricted to http, https and base64: local files are deliberately not allowed, because the location comes from the API and must never make the CLI read from the developer's disk. The fetcher also checks the HTTP status code, so an expired storage link cannot be written to stdout as if it were the OPL file, honours the command context, and redacts base64 payloads from its error messages. Projects still using legacy namespace definitions get a message pointing at `ory get permission-config` instead of an unhelpful type error, while an empty legacy list is reported as "nothing configured". Those paths, and a failing write to stdout, go through cmdx.FailSilently so cobra does not append a usage dump. Project services are read through the generated nil-safe accessors: `ProjectServices.Identity`, `.Permission` and `.Oauth2` are pointers with omitempty, so a project without the service in question panicked. `get opl` needs this, and the surrounding commands dereferencing the same fields are fixed alongside rather than left with a known panic. Also corrects the `get permission-config` example, which advertised inline namespaces that the command never returns for an OPL-based project, and carried a stray character in the sample JSON. The live test covers the round-trip in both directions against a dedicated project: `update opl` output must come back out of `get opl`, and `get opl` output must be accepted by `update opl` again, which is the direction that matters for checking the permission model into version control. Closes #321 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Tg5VWFUb7824qkrooUdvBA --- cmd/cloudx/get.go | 1 + cmd/cloudx/project/get_identity_config.go | 2 +- cmd/cloudx/project/get_namespace_config.go | 135 +++++++++++++++++ .../project/get_namespace_config_live_test.go | 55 +++++++ .../project/get_namespace_config_test.go | 142 ++++++++++++++++++ cmd/cloudx/project/get_oauth2_config.go | 2 +- cmd/cloudx/project/get_permission_config.go | 20 +-- cmd/cloudx/project/update_namespace_config.go | 6 +- cmd/cloudx/project/utils.go | 6 +- 9 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 cmd/cloudx/project/get_namespace_config.go create mode 100644 cmd/cloudx/project/get_namespace_config_live_test.go create mode 100644 cmd/cloudx/project/get_namespace_config_test.go 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)) }