-
-
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
Open
alnr
wants to merge
4
commits into
master
Choose a base branch
from
fix/321-get-opl
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
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,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") | ||
| } |
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/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(`<Error><Code>AccessDenied</Code></Error>`)) | ||
| })) | ||
| t.Cleanup(s.Close) | ||
|
|
||
| got, err := f.FetchBytes(t.Context(), s.URL+"/opl.bin") | ||
| require.Error(t, err) | ||
| 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
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.