Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/cloudx/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func NewGetCmd() *cobra.Command {
project.NewGetProjectCmd(),
project.NewGetKratosConfigCmd(),
project.NewGetKetoConfigCmd(),
project.NewGetOPLCmd(),
project.NewGetOAuth2ConfigCmd(),
workspace.NewGetCmd(),
identity.NewGetIdentityCmd(),
Expand Down
2 changes: 1 addition & 1 deletion cmd/cloudx/project/get_identity_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down
135 changes: 135 additions & 0 deletions cmd/cloudx/project/get_namespace_config.go
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) {
Comment thread
alnr marked this conversation as resolved.
// 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 {}
`,
Comment thread
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
}
55 changes: 55 additions & 0 deletions cmd/cloudx/project/get_namespace_config_live_test.go
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")
}
142 changes: 142 additions & 0 deletions cmd/cloudx/project/get_namespace_config_test.go
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)
})
}
2 changes: 1 addition & 1 deletion cmd/cloudx/project/get_oauth2_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down
20 changes: 11 additions & 9 deletions cmd/cloudx/project/get_permission_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
},
}
Expand Down
Loading
Loading