-
Notifications
You must be signed in to change notification settings - Fork 26
feat: add ory get opl
#451
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 {} | ||
| `, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(`<Error><Code>AccessDenied</Code></Error>`)) | ||
| })) | ||
| 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) | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.