Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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)
}
137 changes: 137 additions & 0 deletions pkg/cmd/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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.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