Skip to content
Open
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
125 changes: 125 additions & 0 deletions cmd/cloudx/project/get_namespace_config.go
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 {}
`,
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 := 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
}
61 changes: 61 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,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")
}
135 changes: 135 additions & 0 deletions cmd/cloudx/project/get_namespace_config_test.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/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)
})
}
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