package commands import ( "bufio" "context" "errors" "fmt" "io" "os" "strings" "time" "unicode" "github.com/jzelinskie/cobrautil/v2" "github.com/jzelinskie/stringz" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" "github.com/authzed/spicedb/pkg/tuple" "github.com/authzed/zed/internal/client" "github.com/authzed/zed/internal/console" ) func RegisterRelationshipCmd(rootCmd *cobra.Command) *cobra.Command { rootCmd.AddCommand(relationshipCmd) relationshipCmd.AddCommand(createCmd) createCmd.Flags().Bool("json", false, "output as JSON") createCmd.Flags().String("caveat", "", `the caveat for the relationship, with format: 'caveat_name:{"some":"context"}'`) createCmd.Flags().String("expiration-time", "", `the expiration time of the relationship in RFC 3339 format`) createCmd.Flags().IntP("batch-size", "b", 100, "batch size when writing streams of relationships from stdin") relationshipCmd.AddCommand(touchCmd) touchCmd.Flags().Bool("json", false, "output as JSON") touchCmd.Flags().String("caveat", "", `the caveat for the relationship, with format: 'caveat_name:{"some":"context"}'`) touchCmd.Flags().String("expiration-time", "", `the expiration time for the relationship in RFC 3339 format`) touchCmd.Flags().IntP("batch-size", "b", 100, "batch size when writing streams of relationships from stdin") relationshipCmd.AddCommand(deleteCmd) deleteCmd.Flags().Bool("json", false, "output as JSON") deleteCmd.Flags().IntP("batch-size", "b", 100, "batch size when deleting streams of relationships from stdin") relationshipCmd.AddCommand(readCmd) readCmd.Flags().Bool("json", false, "output as JSON") readCmd.Flags().String("revision", "", "optional revision at which to check") _ = readCmd.Flags().MarkHidden("revision") readCmd.Flags().String("subject-filter", "", "optional subject filter") readCmd.Flags().Uint32("page-limit", 100, "limit of relations returned per page") registerConsistencyFlags(readCmd.Flags()) relationshipCmd.AddCommand(bulkDeleteCmd) bulkDeleteCmd.Flags().Bool("force", false, "force deletion of all elements in batches defined by ") bulkDeleteCmd.Flags().String("subject-filter", "", "optional subject filter") bulkDeleteCmd.Flags().Uint32("optional-limit", 1000, "the max amount of elements to delete. If you want to delete all in batches of size , set --force to true") bulkDeleteCmd.Flags().Bool("estimate-count", true, "estimate the count of relationships to be deleted") _ = bulkDeleteCmd.Flags().MarkDeprecated("estimate-count", "no longer used, make use of --optional-limit instead") return relationshipCmd } var relationshipCmd = &cobra.Command{ Use: "relationship ", Short: "Query and mutate the relationships in a permissions system", } var createCmd = &cobra.Command{ Use: "create ", Short: "Create a relationship for a subject", Args: ValidationWrapper(StdinOrExactArgs(3)), ValidArgsFunction: GetArgs(ResourceID, Permission, SubjectTypeWithOptionalRelation), RunE: writeRelationshipCmdFunc(v1.RelationshipUpdate_OPERATION_CREATE, os.Stdin), } var touchCmd = &cobra.Command{ Use: "touch ", Short: "Idempotently updates a relationship for a subject", Args: ValidationWrapper(StdinOrExactArgs(3)), ValidArgsFunction: GetArgs(ResourceID, Permission, SubjectTypeWithOptionalRelation), RunE: writeRelationshipCmdFunc(v1.RelationshipUpdate_OPERATION_TOUCH, os.Stdin), } var deleteCmd = &cobra.Command{ Use: "delete ", Short: "Deletes a relationship", Args: ValidationWrapper(StdinOrExactArgs(3)), ValidArgsFunction: GetArgs(ResourceID, Permission, SubjectTypeWithOptionalRelation), RunE: writeRelationshipCmdFunc(v1.RelationshipUpdate_OPERATION_DELETE, os.Stdin), } const readCmdHelpLong = `Enumerates relationships matching the provided pattern. To filter returned relationships using a resource ID prefix, append a '%' to the resource ID: zed relationship read some-type:some-prefix-% ` var readCmd = &cobra.Command{ Use: "read ", Short: "Enumerates relationships matching the provided pattern", Long: readCmdHelpLong, Args: ValidationWrapper(cobra.RangeArgs(1, 3)), ValidArgsFunction: GetArgs(ResourceID, Permission, SubjectTypeWithOptionalRelation), RunE: readRelationships, } var bulkDeleteCmd = &cobra.Command{ Use: "bulk-delete ", Short: "Deletes relationships matching the provided pattern en masse", Args: ValidationWrapper(cobra.RangeArgs(1, 3)), ValidArgsFunction: GetArgs(ResourceID, Permission, SubjectTypeWithOptionalRelation), RunE: bulkDeleteRelationships, } func StdinOrExactArgs(n int) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if ok := isArgsViaFile(os.Stdin) && len(args) == 0; ok { return nil } return cobra.ExactArgs(n)(cmd, args) } } func isArgsViaFile(file *os.File) bool { return !isFileTerminal(file) } func bulkDeleteRelationships(cmd *cobra.Command, args []string) error { spicedbClient, err := client.NewClient(cmd) if err != nil { return err } filter, err := buildRelationshipsFilter(cmd, args) if err != nil { return err } bar := console.CreateProgressBar("deleting relationships") defer func() { _ = bar.Finish() }() allowPartialDeletions := cobrautil.MustGetBool(cmd, "force") optionalLimit := cobrautil.MustGetUint32(cmd, "optional-limit") var resp *v1.DeleteRelationshipsResponse for { delRequest := &v1.DeleteRelationshipsRequest{ RelationshipFilter: filter, OptionalLimit: optionalLimit, OptionalAllowPartialDeletions: allowPartialDeletions, } log.Trace().Interface("request", delRequest).Msg("deleting relationships") resp, err = spicedbClient.DeleteRelationships(cmd.Context(), delRequest) if errorInfo, ok := grpcErrorInfoFrom(err); ok { if errorInfo.GetReason() == v1.ErrorReason_ERROR_REASON_TOO_MANY_RELATIONSHIPS_FOR_TRANSACTIONAL_DELETE.String() { resourceType := "relationships" if returnedResourceType, ok := errorInfo.GetMetadata()["filter_resource_type"]; ok { resourceType = returnedResourceType } return fmt.Errorf("could not delete %s, as more than %s relationships were found. Consider increasing --optional-limit or deleting all relationships using --force", resourceType, errorInfo.GetMetadata()["limit"]) } } if err != nil { return err } if resp.DeletionProgress == v1.DeleteRelationshipsResponse_DELETION_PROGRESS_COMPLETE { break } if err := bar.Add(int(optionalLimit)); err != nil { return err } } _ = bar.Finish() console.Println(resp.DeletedAt.GetToken()) return nil } func grpcErrorInfoFrom(err error) (*errdetails.ErrorInfo, bool) { if err == nil { return nil, false } if s, ok := status.FromError(err); ok { for _, d := range s.Details() { if errInfo, ok := d.(*errdetails.ErrorInfo); ok { return errInfo, true } } } return nil, false } func buildRelationshipsFilter(cmd *cobra.Command, args []string) (*v1.RelationshipFilter, error) { filter := &v1.RelationshipFilter{ResourceType: args[0]} if strings.Contains(args[0], ":") { var resourceID string err := stringz.SplitExact(args[0], ":", &filter.ResourceType, &resourceID) if err != nil { return nil, err } if strings.HasSuffix(resourceID, "%") { filter.OptionalResourceIdPrefix = strings.TrimSuffix(resourceID, "%") } else { filter.OptionalResourceId = resourceID } } if len(args) > 1 { filter.OptionalRelation = args[1] } subjectFilter := cobrautil.MustGetString(cmd, "subject-filter") if len(args) == 3 { if subjectFilter != "" { return nil, errors.New("cannot specify subject filter both positionally and via --subject-filter") } subjectFilter = args[2] } if subjectFilter != "" { if strings.Contains(subjectFilter, ":") { subjectNS, subjectID, subjectRel, err := ParseSubject(subjectFilter) if err != nil { return nil, err } filter.OptionalSubjectFilter = &v1.SubjectFilter{ SubjectType: subjectNS, OptionalSubjectId: subjectID, OptionalRelation: &v1.SubjectFilter_RelationFilter{ Relation: subjectRel, }, } } else { filter.OptionalSubjectFilter = &v1.SubjectFilter{ SubjectType: subjectFilter, } } } return filter, nil } func readRelationships(cmd *cobra.Command, args []string) error { spicedbClient, err := client.NewClient(cmd) if err != nil { return err } filter, err := buildRelationshipsFilter(cmd, args) if err != nil { return err } request := &v1.ReadRelationshipsRequest{RelationshipFilter: filter} limit := cobrautil.MustGetUint32(cmd, "page-limit") request.OptionalLimit = limit request.Consistency, err = consistencyFromCmd(cmd) if err != nil { return err } lastCursor := request.OptionalCursor for { request.OptionalCursor = lastCursor var cursorToken string if lastCursor != nil { cursorToken = lastCursor.Token } log.Trace().Interface("request", request).Str("cursor", cursorToken).Msg("reading relationships page") readRelClient, err := spicedbClient.ReadRelationships(cmd.Context(), request) if err != nil { return err } var relCount uint32 for { if err := cmd.Context().Err(); err != nil { return err } msg, err := readRelClient.Recv() if errors.Is(err, io.EOF) { break } if err != nil { return err } lastCursor = msg.AfterResultCursor relCount++ if err := printRelationship(cmd, msg); err != nil { return err } } if relCount < limit || limit == 0 { return nil } if relCount > limit { log.Warn().Uint32("limit-specified", limit).Uint32("relationships-received", relCount).Msg("page limit ignored, pagination may not be supported by the server, consider updating SpiceDB") return nil } } } func printRelationship(cmd *cobra.Command, msg *v1.ReadRelationshipsResponse) error { if cobrautil.MustGetBool(cmd, "json") { prettyProto, err := PrettyProto(msg) if err != nil { return err } console.Println(string(prettyProto)) } else { relString, err := relationshipToString(msg.Relationship) if err != nil { return err } console.Println(relString) } return nil } func argsToRelationship(args []string) (*v1.Relationship, error) { if len(args) != 3 { return nil, fmt.Errorf("expected 3 arguments, but got %d", len(args)) } rel, err := tupleToRel(args[0], args[1], args[2]) if err != nil { return nil, errors.New("failed to parse input arguments") } return rel, nil } func relationshipToString(rel *v1.Relationship) (string, error) { relString, err := tuple.V1StringRelationship(rel) if err != nil { return "", err } relString = strings.Replace(relString, "@", " ", 1) relString = strings.Replace(relString, "#", " ", 1) return relString, nil } // parseRelationshipLine splits a line of update input that comes from stdin // and returns the fields representing the 3 arguments. This is to handle // the fact that relationships specified via stdin can't escape spaces like // shell arguments. func parseRelationshipLine(line string) (string, string, string, error) { line = strings.TrimSpace(line) resourceIdx := strings.IndexFunc(line, unicode.IsSpace) if resourceIdx == -1 { args := 0 if line != "" { args = 1 } return "", "", "", fmt.Errorf("expected %s to have 3 arguments, but got %v", line, args) } resource := line[:resourceIdx] rest := strings.TrimSpace(line[resourceIdx+1:]) relationIdx := strings.IndexFunc(rest, unicode.IsSpace) if relationIdx == -1 { args := 1 if strings.TrimSpace(rest) != "" { args = 2 } return "", "", "", fmt.Errorf("expected %s to have 3 arguments, but got %v", line, args) } relation := rest[:relationIdx] rest = strings.TrimSpace(rest[relationIdx+1:]) if rest == "" { return "", "", "", fmt.Errorf("expected %s to have 3 arguments, but got 2", line) } return resource, relation, rest, nil } func FileRelationshipParser(f *os.File) RelationshipParser { scanner := bufio.NewScanner(f) return func() (*v1.Relationship, error) { if scanner.Scan() { res, rel, subj, err := parseRelationshipLine(scanner.Text()) if err != nil { return nil, err } return tupleToRel(res, rel, subj) } if err := scanner.Err(); err != nil { return nil, err } return nil, ErrExhaustedRelationships } } func tupleToRel(resource, relation, subject string) (*v1.Relationship, error) { return tuple.ParseV1Rel(resource + "#" + relation + "@" + subject) } func SliceRelationshipParser(args []string) RelationshipParser { ran := false return func() (*v1.Relationship, error) { if ran { return nil, ErrExhaustedRelationships } ran = true return tupleToRel(args[0], args[1], args[2]) } } func writeUpdates(ctx context.Context, spicedbClient client.Client, updates []*v1.RelationshipUpdate, json bool) error { if len(updates) == 0 { return nil } request := &v1.WriteRelationshipsRequest{ Updates: updates, OptionalPreconditions: nil, } log.Trace().Interface("request", request).Msg("writing relationships") resp, err := spicedbClient.WriteRelationships(ctx, request) if err != nil { return err } if json { prettyProto, err := PrettyProto(resp) if err != nil { return err } console.Println(string(prettyProto)) } else { console.Println(resp.WrittenAt.GetToken()) } return nil } // RelationshipParser is a closure that can produce relationships. // When there are no more relationships, it will return ErrExhaustedRelationships. type RelationshipParser func() (*v1.Relationship, error) // ErrExhaustedRelationships signals that the last producible value of a RelationshipParser // has already been consumed. // Functions should return this error to signal a graceful end of input. var ErrExhaustedRelationships = errors.New("exhausted all relationships") func writeRelationshipCmdFunc(operation v1.RelationshipUpdate_Operation, input *os.File) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { parser := SliceRelationshipParser(args) if isArgsViaFile(input) && len(args) == 0 { parser = FileRelationshipParser(input) } spicedbClient, err := client.NewClient(cmd) if err != nil { return err } batchSize := cobrautil.MustGetInt(cmd, "batch-size") updateBatch := make([]*v1.RelationshipUpdate, 0) doJSON := cobrautil.MustGetBool(cmd, "json") for { rel, err := parser() if errors.Is(err, ErrExhaustedRelationships) { return writeUpdates(cmd.Context(), spicedbClient, updateBatch, doJSON) } else if err != nil { return err } if operation != v1.RelationshipUpdate_OPERATION_DELETE { if err := handleCaveatFlag(cmd, rel); err != nil { return err } if err := handleExpirationFlag(cmd, rel); err != nil { return err } } updateBatch = append(updateBatch, &v1.RelationshipUpdate{ Operation: operation, Relationship: rel, }) if len(updateBatch) == batchSize { if err := writeUpdates(cmd.Context(), spicedbClient, updateBatch, doJSON); err != nil { return err } updateBatch = nil } } } } func handleCaveatFlag(cmd *cobra.Command, rel *v1.Relationship) error { caveatString := cobrautil.MustGetString(cmd, "caveat") if caveatString != "" { if rel.OptionalCaveat != nil { return errors.New("cannot specify a caveat in both the relationship and the --caveat flag") } parts := strings.SplitN(caveatString, ":", 2) if len(parts) == 0 { return fmt.Errorf("invalid --caveat argument. Must be in format `caveat_name:context`, but found `%s`", caveatString) } rel.OptionalCaveat = &v1.ContextualizedCaveat{ CaveatName: parts[0], } if len(parts) == 2 { caveatCtx, err := ParseCaveatContext(parts[1]) if err != nil { return err } rel.OptionalCaveat.Context = caveatCtx } } return nil } func handleExpirationFlag(cmd *cobra.Command, rel *v1.Relationship) error { expirationTime := cobrautil.MustGetString(cmd, "expiration-time") if expirationTime != "" { t, err := time.Parse(time.RFC3339, expirationTime) if err != nil { return fmt.Errorf("could not parse RFC 3339 timestamp: %w", err) } rel.OptionalExpiresAt = timestamppb.New(t) } return nil }