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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ node_modules

pkg/development/wasm/main.wasm
pkg/development/wasm/play.wasm
/permify
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ test: ### run tests and gather coverage
integration-test: ### run integration-test
go clean -testcache && go test -v ./integration-test/...

.PHONY: vet
vet: ## Run go vet
go vet ./...

.PHONY: build
build: ## Build/compile the Permify service
go build -o ./permify ./cmd/permify
Expand Down
6 changes: 6 additions & 0 deletions cmd/permify/permify.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ func main() {
repair := cmd.NewRepairCommand()
root.AddCommand(repair)

// Remote management (gRPC client) commands
root.AddCommand(cmd.NewCheckCommand())
root.AddCommand(cmd.NewSchemaCommand())
root.AddCommand(cmd.NewDataCommand())
root.AddCommand(cmd.NewTenantCommand())

if err := root.Execute(); err != nil {
os.Exit(1)
}
Expand Down
110 changes: 110 additions & 0 deletions pkg/cmd/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package cmd

import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/spf13/cobra"
"google.golang.org/grpc"

basev1 "github.com/Permify/permify/pkg/pb/base/v1"
)

// NewCheckCommand runs a permission Check against a remote Permify gRPC server.
func NewCheckCommand() *cobra.Command {
var (
credentialsPath string
tenantID string
subjectStr string
resourceStr string
permission string
)

cmd := &cobra.Command{
Use: "check",
Short: "Check whether a subject has a permission on a resource",
Long: `Calls the Permify Permission.Check RPC.

Subject is --entity (e.g. user:1). Resource is --resource (e.g. document:1).`,
RunE: func(cmd *cobra.Command, _ []string) error {
if strings.TrimSpace(tenantID) == "" {
return fmt.Errorf("--tenant-id is required")
}
if strings.TrimSpace(subjectStr) == "" {
return fmt.Errorf("--entity is required")
}
if strings.TrimSpace(resourceStr) == "" {
return fmt.Errorf("--resource is required")
}
if strings.TrimSpace(permission) == "" {
return fmt.Errorf("--permission is required")
}

subject, err := ParseSubjectRef(subjectStr)
if err != nil {
return fmt.Errorf("parse subject: %w", err)
}
entity, err := ParseEntityRef(resourceStr)
if err != nil {
return fmt.Errorf("parse resource: %w", err)
}

conn, err := DialGRPC(credentialsPath)
if err != nil {
return fmt.Errorf("connect to permify: %w", err)
}
defer func() { _ = conn.Close() }()

rpcCtx, cancel := newGRPCCallContext(cmd.Context())
defer cancel()

client := basev1.NewPermissionClient(conn)
return runPermissionCheck(rpcCtx, os.Stdout, client, tenantID, entity, subject, permission)
},
}

fs := cmd.Flags()
fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)")
fs.StringVar(&tenantID, "tenant-id", "", "tenant identifier (required)")
fs.StringVar(&subjectStr, "entity", "", "subject as type:id (e.g. user:1)")
fs.StringVar(&resourceStr, "resource", "", "resource entity as type:id (e.g. document:1)")
fs.StringVar(&permission, "permission", "", "permission name to evaluate (e.g. view)")
_ = cmd.MarkFlagRequired("tenant-id")
_ = cmd.MarkFlagRequired("entity")
_ = cmd.MarkFlagRequired("resource")
_ = cmd.MarkFlagRequired("permission")

return cmd
}

type permissionCheckClient interface {
Check(ctx context.Context, in *basev1.PermissionCheckRequest, opts ...grpc.CallOption) (*basev1.PermissionCheckResponse, error)
}

func runPermissionCheck(
ctx context.Context,
w io.Writer,
client permissionCheckClient,
tenantID string,
entity *basev1.Entity,
subject *basev1.Subject,
permission string,
) error {
req := &basev1.PermissionCheckRequest{
TenantId: tenantID,
Metadata: &basev1.PermissionCheckRequestMetadata{},
Entity: entity,
Subject: subject,
Permission: permission,
}

resp, err := client.Check(ctx, req)
if err != nil {
return GRPCStatusError(err)
}
formatCheckResult(w, resp)
return nil
}
78 changes: 78 additions & 0 deletions pkg/cmd/check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package cmd

import (
"bytes"
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

basev1 "github.com/Permify/permify/pkg/pb/base/v1"
)

func TestRunPermissionCheck_Allowed(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
stub := &stubPermissionClient{
checkFn: func(_ context.Context, in *basev1.PermissionCheckRequest, _ ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) {
assert.Equal(t, "t1", in.GetTenantId())
assert.Equal(t, "view", in.GetPermission())
return &basev1.PermissionCheckResponse{Can: basev1.CheckResult_CHECK_RESULT_ALLOWED}, nil
},
}
ent := &basev1.Entity{Type: "document", Id: "1"}
sub := &basev1.Subject{Type: "user", Id: "1"}
rpcCtx, cancel := newGRPCCallContext(context.Background())
defer cancel()
err := runPermissionCheck(rpcCtx, &buf, stub, "t1", ent, sub, "view")
require.NoError(t, err)
assert.Contains(t, buf.String(), "allowed")
}

func TestRunPermissionCheck_Denied(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
stub := &stubPermissionClient{
checkFn: func(_ context.Context, _ *basev1.PermissionCheckRequest, _ ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) {
return &basev1.PermissionCheckResponse{Can: basev1.CheckResult_CHECK_RESULT_DENIED}, nil
},
}
rpcCtx, cancel := newGRPCCallContext(context.Background())
defer cancel()
err := runPermissionCheck(rpcCtx, &buf, stub, "t1",
&basev1.Entity{Type: "document", Id: "1"},
&basev1.Subject{Type: "user", Id: "1"}, "edit")
require.NoError(t, err)
assert.Contains(t, buf.String(), "denied")
}

func TestRunPermissionCheck_RPCError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
stub := &stubPermissionClient{
checkFn: func(_ context.Context, _ *basev1.PermissionCheckRequest, _ ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) {
return nil, status.Errorf(codes.FailedPrecondition, "schema missing")
},
}
rpcCtx, cancel := newGRPCCallContext(context.Background())
defer cancel()
err := runPermissionCheck(rpcCtx, &buf, stub, "t1",
&basev1.Entity{Type: "document", Id: "1"},
&basev1.Subject{Type: "user", Id: "1"}, "view")
require.Error(t, err)
assert.Contains(t, err.Error(), "schema missing")
}

func TestNewCheckCommand_RequiredFlags(t *testing.T) {
t.Parallel()
cmd := NewCheckCommand()
cmd.SetArgs([]string{})
cmd.SetOut(bytes.NewBuffer(nil))
cmd.SetErr(bytes.NewBuffer(nil))
err := cmd.Execute()
require.Error(t, err)
}
140 changes: 140 additions & 0 deletions pkg/cmd/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package cmd

import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"path/filepath"
"strings"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"gopkg.in/yaml.v3"
)

// CredentialsFile is the YAML format stored at ~/.permify/credentials (endpoint, optional api_token, tls_ca_path).
type CredentialsFile struct {
Endpoint string `yaml:"endpoint"`
APIToken string `yaml:"api_token"`
TLSCAPath string `yaml:"tls_ca_path"`
}

// DefaultCredentialsPath returns $HOME/.permify/credentials.
func DefaultCredentialsPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".permify", "credentials"), nil
}

// ResolveCredentialsPath returns flagPath if set, otherwise DefaultCredentialsPath.
func ResolveCredentialsPath(flagPath string) (string, error) {
if strings.TrimSpace(flagPath) != "" {
abs, err := filepath.Abs(flagPath)
if err != nil {
return "", fmt.Errorf("resolve credentials path: %w", err)
}
return abs, nil
}
return DefaultCredentialsPath()
}

// LoadCredentials reads and parses a credentials YAML file.
func LoadCredentials(path string) (*CredentialsFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("credentials file not found at %q; create it with endpoint (and optional api_token, tls_ca_path): %w", path, err)
}
return nil, fmt.Errorf("read credentials file: %w", err)
}

var c CredentialsFile
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parse credentials YAML: %w", err)
}

c.Endpoint = strings.TrimSpace(c.Endpoint)
c.APIToken = strings.TrimSpace(c.APIToken)
c.TLSCAPath = strings.TrimSpace(c.TLSCAPath)
if c.TLSCAPath != "" && !filepath.IsAbs(c.TLSCAPath) {
c.TLSCAPath = filepath.Join(filepath.Dir(path), c.TLSCAPath)
}

if c.Endpoint == "" {
return nil, fmt.Errorf("credentials file %q: endpoint is required", path)
}

return &c, nil
}

type bearerTokenCreds struct {
token string
}

func (b bearerTokenCreds) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
return map[string]string{"authorization": "Bearer " + b.token}, nil
}

func (b bearerTokenCreds) RequireTransportSecurity() bool {
return true
}

// GRPCDialOptions builds dial options: TLS + bearer token when api_token is set, otherwise insecure.
func GRPCDialOptions(c *CredentialsFile) ([]grpc.DialOption, error) {
token := strings.TrimSpace(c.APIToken)
if token == "" {
return []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, nil
}

var tlsCfg *tls.Config
if c.TLSCAPath != "" {
pemData, err := os.ReadFile(c.TLSCAPath)
if err != nil {
return nil, fmt.Errorf("read tls_ca_path: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("tls_ca_path: no PEM certificates found")
}
tlsCfg = &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
}
} else {
tlsCfg = &tls.Config{
MinVersion: tls.VersionTLS12,
}
}

tc := credentials.NewTLS(tlsCfg)
return []grpc.DialOption{
grpc.WithTransportCredentials(tc),
grpc.WithPerRPCCredentials(bearerTokenCreds{token: token}),
}, nil
}

// DialGRPC opens a client connection using a credentials file path.
func DialGRPC(credPath string) (*grpc.ClientConn, error) {
path, err := ResolveCredentialsPath(credPath)
if err != nil {
return nil, fmt.Errorf("resolve credentials path: %w", err)
}
creds, err := LoadCredentials(path)
if err != nil {
return nil, fmt.Errorf("load credentials: %w", err)
}
opts, err := GRPCDialOptions(creds)
if err != nil {
return nil, fmt.Errorf("gRPC dial options: %w", err)
}
conn, err := grpc.NewClient(creds.Endpoint, opts...)
if err != nil {
return nil, fmt.Errorf("dial gRPC %q: %w", creds.Endpoint, err)
}
return conn, nil
}
Loading
Loading