diff --git a/cmd/dev-selectors.go b/cmd/dev-selectors.go new file mode 100644 index 00000000..55433dc7 --- /dev/null +++ b/cmd/dev-selectors.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/golang-jwt/jwt" + "github.com/opentdf/otdfctl/pkg/cli" + "github.com/opentdf/otdfctl/pkg/handlers" + "github.com/opentdf/otdfctl/pkg/man" + "github.com/opentdf/platform/protocol/go/policy" + "github.com/spf13/cobra" +) + +var ( + selectors []string + + dev_selectorsCmd *cobra.Command +) + +func dev_selectorsGen(cmd *cobra.Command, args []string) { + h := cli.NewHandler(cmd) + defer h.Close() + + flagHelper := cli.NewFlagHelper(cmd) + subject := flagHelper.GetRequiredString("subject") + contextType := flagHelper.GetRequiredString("type") + + var value any + if contextType == "json" || contextType == "" { + if err := json.Unmarshal([]byte(subject), &value); err != nil { + cli.ExitWithError(fmt.Sprintf("Could not unmarshal JSON subject context input: %s", subject), err) + } + } else if contextType == "jwt" { + // get the payload from the decoded JWT + token, _, err := new(jwt.Parser).ParseUnverified(subject, jwt.MapClaims{}) + if err != nil { + cli.ExitWithError("Failed to parse JWT token", err) + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + value = claims + } else { + cli.ExitWithError("Failed to get claims from JWT token", nil) + } + } else { + cli.ExitWithError("Invalid subject context type. Must be of type: [json, jwt]", nil) + } + + result, err := handlers.ProcessSubjectContext(value, "", []*policy.SubjectProperty{}) + if err != nil { + cli.ExitWithError("Failed to process subject context keys and values", err) + } + + rows := [][]string{} + for _, r := range result { + rows = append(rows, []string{r.ExternalField, r.ExternalValue}) + } + + t := cli.NewTabular().Rows(rows...) + cli.PrintSuccessTable(cmd, "", t) +} + +func dev_selectorsTest(cmd *cobra.Command, args []string) { + h := cli.NewHandler(cmd) + defer h.Close() + + flagHelper := cli.NewFlagHelper(cmd) + subject := flagHelper.GetRequiredString("subject") + contextType := flagHelper.GetRequiredString("type") + selectors := flagHelper.GetStringSlice("selectors", selectors, cli.FlagHelperStringSliceOptions{Min: 1}) + + var value any + if contextType == "json" || contextType == "" { + if err := json.Unmarshal([]byte(subject), &value); err != nil { + cli.ExitWithError(fmt.Sprintf("Could not unmarshal JSON subject context input: %s", subject), err) + } + } else if contextType == "jwt" { + token, _, err := new(jwt.Parser).ParseUnverified(subject, jwt.MapClaims{}) + if err != nil { + cli.ExitWithError("Failed to parse JWT token", err) + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + value = claims + } else { + cli.ExitWithError("Failed to get claims from JWT token", nil) + } + } else { + cli.ExitWithError("Invalid subject context type. Must be of type: [json, jwt]", nil) + } + + result, err := handlers.TestSubjectContext(value, selectors) + if err != nil { + cli.ExitWithError("Failed to process subject context keys and values", err) + } + + rows := [][]string{} + for _, r := range result { + rows = append(rows, []string{r.ExternalField, r.ExternalValue}) + } + + t := cli.NewTabular().Rows(rows...) + cli.PrintSuccessTable(cmd, "", t) +} + +func init() { + genCmd := man.Docs.GetCommand("dev/selectors/gen", + man.WithRun(dev_selectorsGen), + ) + genCmd.Flags().StringP( + genCmd.GetDocFlag("subject").Name, + genCmd.GetDocFlag("subject").Shorthand, + genCmd.GetDocFlag("subject").Default, + genCmd.GetDocFlag("subject").Description, + ) + genCmd.Flags().StringP( + genCmd.GetDocFlag("type").Name, + genCmd.GetDocFlag("type").Shorthand, + genCmd.GetDocFlag("type").Default, + genCmd.GetDocFlag("type").Description, + ) + + testCmd := man.Docs.GetCommand("dev/selectors/test", + man.WithRun(dev_selectorsTest), + ) + testCmd.Flags().StringP( + testCmd.GetDocFlag("subject").Name, + testCmd.GetDocFlag("subject").Shorthand, + testCmd.GetDocFlag("subject").Default, + testCmd.GetDocFlag("subject").Description, + ) + testCmd.Flags().StringP( + testCmd.GetDocFlag("type").Name, + testCmd.GetDocFlag("type").Shorthand, + testCmd.GetDocFlag("type").Default, + testCmd.GetDocFlag("type").Description, + ) + testCmd.Flags().StringArrayVarP( + &selectors, + testCmd.GetDocFlag("selector").Name, + testCmd.GetDocFlag("selector").Shorthand, + []string{}, + testCmd.GetDocFlag("selector").Description, + ) + + doc := man.Docs.GetCommand("dev/selectors", + man.WithSubcommands(genCmd, testCmd), + ) + + dev_selectorsCmd = &doc.Command + devCmd.AddCommand(dev_selectorsCmd) +} diff --git a/cmd/dev.go b/cmd/dev.go index bf52dbd9..99301dc2 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -13,6 +13,9 @@ import ( "github.com/spf13/cobra" ) +// devCmd is the command for playground-style development +var devCmd = man.Docs.GetCommand("dev") + func dev_designSystem(cmd *cobra.Command, args []string) { fmt.Printf("Design system\n") fmt.Printf("=============\n\n") @@ -115,13 +118,9 @@ func injectLabelFlags(cmd *cobra.Command, isUpdate bool) { } func init() { - designCmd := man.Docs.GetCommand("dev/design-system", man.WithRun(dev_designSystem), ) - - cmd := man.Docs.GetCommand("dev", - man.WithSubcommands(designCmd), - ) - rootCmd.AddCommand(&cmd.Command) + devCmd.AddCommand(&designCmd.Command) + rootCmd.AddCommand(&devCmd.Command) } diff --git a/docs/man/dev/selectors/_index.md b/docs/man/dev/selectors/_index.md new file mode 100644 index 00000000..2edeb8ee --- /dev/null +++ b/docs/man/dev/selectors/_index.md @@ -0,0 +1,9 @@ +--- +title: Selectors +command: + name: selectors +--- + +Commands to develop selectors, with [jq syntax](https://jqlang.github.io/jq/manual/) for utilization +within Subject Condition Sets to parse some external Subject Context into mapped Attribute +Values. diff --git a/docs/man/dev/selectors/gen.md b/docs/man/dev/selectors/gen.md new file mode 100644 index 00000000..b9a39b63 --- /dev/null +++ b/docs/man/dev/selectors/gen.md @@ -0,0 +1,20 @@ +--- +title: Generate a set of selector expressions for keys and values of a Subject Context +command: + name: gen + flags: + - name: subject + shorthand: s + description: A Subject Context string (JSON or JWT, default JSON) + default: '' + - name: type + shorthand: t + description: 'The type of the Subject Context: [json, jwt]' + default: json +--- + +Take in a representation of some Subject Context, such as that provided by +an Identity Provider (idP), LDAP, or OIDC Access Token JWT, and generate +sample [jq syntax expressions](https://jqlang.github.io/jq/manual/) to employ +within Subject Condition Sets to parse that external Subject Context into mapped Attribute +Values. diff --git a/docs/man/dev/selectors/test.md b/docs/man/dev/selectors/test.md new file mode 100644 index 00000000..344601c1 --- /dev/null +++ b/docs/man/dev/selectors/test.md @@ -0,0 +1,22 @@ +--- +title: Test resolution of a set of selector expressions for keys and values of a Subject Context. +command: + name: test + flags: + - name: subject + shorthand: s + description: A Subject Context string (JSON or JWT, default JSON) + default: '' + - name: type + shorthand: t + description: 'The type of the Subject Context: [json, jwt]' + default: json + - name: selector + shorthand: x + description: 'Individual selectors to test against the Subject Context (i.e. .key, .example[1].group)' +--- + +Test a given representation of some Subject Context, such as that provided by +an Identity Provider (idP), LDAP, or OIDC Access Token JWT, against provided [jq syntax +'selector' expressions](https://jqlang.github.io/jq/manual/) to validate their resolution +to field values on the Subject Context. diff --git a/go.mod b/go.mod index 8a24559c..32a20c14 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/charmbracelet/huh v0.3.0 github.com/charmbracelet/lipgloss v0.10.0 github.com/creasty/defaults v1.7.0 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/itchyny/gojq v0.12.15 github.com/muesli/reflow v0.3.0 github.com/opentdf/platform/protocol/go v0.0.0-20240328192545-ab689ebe9123 github.com/opentdf/platform/sdk v0.0.0-20240328192545-ab689ebe9123 @@ -44,6 +46,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/imfing/hextra v0.7.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.5 // indirect diff --git a/go.sum b/go.sum index 6c3d0a90..2e45ef65 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -106,6 +108,10 @@ github.com/imfing/hextra v0.7.3 h1:dVGA1NTcWe+FaUMdrawEypPfrrmulq5NoK0we3nC330= github.com/imfing/hextra v0.7.3/go.mod h1:cEfel3lU/bSx7lTE/+uuR4GJaphyOyiwNR3PTqFTXpI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.15 h1:WC1Nxbx4Ifw5U2oQWACYz32JK8G9qxNtHzrvW4KEcqI= +github.com/itchyny/gojq v0.12.15/go.mod h1:uWAHCbCIla1jiNxmeT5/B5mOjSdfkCq6p8vxWg+BM10= +github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= +github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/pkg/cli/table.go b/pkg/cli/table.go index 95aa3bdd..e785b20b 100644 --- a/pkg/cli/table.go +++ b/pkg/cli/table.go @@ -3,11 +3,12 @@ package cli import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" + "golang.org/x/term" ) type Table table.Table -var defaultTableWidth = 120 +var defaultTableWidth int func NewTable() *table.Table { t := table.New() @@ -27,3 +28,18 @@ func NewTable() *table.Table { } }) } + +func init() { + // dynamically set the default table width based on terminal size breakpoints + w, _, err := term.GetSize(0) + if err != nil { + w = 80 + } + if w > 180 { + defaultTableWidth = 180 + } else if w > 120 { + defaultTableWidth = 120 + } else { + defaultTableWidth = 80 + } +} diff --git a/pkg/handlers/selectors.go b/pkg/handlers/selectors.go new file mode 100644 index 00000000..df6ad756 --- /dev/null +++ b/pkg/handlers/selectors.go @@ -0,0 +1,145 @@ +package handlers + +import ( + "fmt" + "log" + "reflect" + + "github.com/golang-jwt/jwt" + "github.com/itchyny/gojq" + "github.com/opentdf/platform/protocol/go/policy" +) + +// Recursively process json into a list of jq syntax selectors and their values when applying the jq selector to the input json +func ProcessSubjectContext(subject interface{}, currSelector string, result []*policy.SubjectProperty) ([]*policy.SubjectProperty, error) { + if currSelector == "" { + currSelector = "'" + } + currType := reflect.TypeOf(subject) + + switch currType.Kind() { + // maps (structs not supported): add the key to the selector then call on all values + case reflect.Map: + for _, key := range reflect.ValueOf(subject).MapKeys() { + newSelector := fmt.Sprintf("%s.%s", currSelector, key) + newValue := reflect.ValueOf(subject).MapIndex(key).Interface() + if r, err := ProcessSubjectContext(newValue, newSelector, result); err != nil { + return nil, err + } else { + result = r + } + } + // lists: invoke on all array values with index added to selector + case reflect.Array, reflect.Slice: + for i := 0; i < reflect.ValueOf(subject).Len(); i++ { + // exists at specific index + idxSelector := fmt.Sprintf("%s[%d]", currSelector, i) + newValue := reflect.ValueOf(subject).Index(i).Interface() + if r, err := ProcessSubjectContext(newValue, idxSelector, result); err != nil { + return nil, err + } else { + result = r + } + // if primitive, add selector for if it exists at any index + if isPrimitive(reflect.TypeOf(newValue).Kind()) { + anySelector := currSelector + ` | map(tostring) | any(index("` + fmt.Sprintf("%v", newValue) + `"))` + if r, err := ProcessSubjectContext(true, anySelector, result); err != nil { + return nil, err + } else { + result = r + } + } + } + + // primitives: add the selector and value to the list + case reflect.String, + reflect.Bool, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128: + result = append(result, &policy.SubjectProperty{ExternalField: currSelector + "'", ExternalValue: fmt.Sprintf("%v", subject)}) + + default: + return nil, fmt.Errorf("unsupported type %v", currType.Kind()) + } + + return result, nil +} + +func isPrimitive(t reflect.Kind) bool { + switch t { + case reflect.String, + reflect.Bool, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128: + return true + default: + return false + } +} + +func TestSubjectContext(subject interface{}, selectors []string) ([]*policy.SubjectProperty, error) { + // genericize type to avoid panic parsing jwt.MapClaims in gojq + var sub any + if _, ok := subject.(jwt.MapClaims); ok { + subj := make(map[string]interface{}) + for k, v := range subject.(jwt.MapClaims) { + subj[k] = v + } + sub = subj + } else { + sub = subject + } + + found := []*policy.SubjectProperty{} + + for _, s := range selectors { + query, err := gojq.Parse(s) + if err != nil { + log.Fatalln(err) + } + iter := query.Run(sub) // or query.RunWithContext + for { + v, ok := iter.Next() + if !ok { + break + } + if err, ok := v.(error); ok { + if err, ok := err.(*gojq.HaltError); ok && err.Value() == nil { + break + } + // ignore error: we don't have a match but that is not an error state in this case + } else { + if v != nil { + found = append(found, &policy.SubjectProperty{ExternalField: "'" + s + "'", ExternalValue: fmt.Sprintf("%v", v)}) + } + } + } + } + return found, nil +}