diff --git a/docs/language-spec.md b/docs/language-spec.md index 736feca..6f87710 100644 --- a/docs/language-spec.md +++ b/docs/language-spec.md @@ -131,17 +131,16 @@ A rule with `tuple_filters` declares that the rule operates on existing FGA tupl object: 'org:{{ input.org_id }}' ``` -A tuple filter is a partial-key query that maps to the [FGA Read API](https://openfga.dev/docs/api/service#/Relationships/Read). Any combination of `user`, `relation`, and `object` can be set; unset fields act as wildcards. Supported patterns: +A tuple filter is a partial-key query that maps to the [FGA Read API](https://openfga.dev/docs/api/service#/Relationships/Read). `object` is required (at least an object type prefix, since FGA's Read API needs an object type); `user` and `relation` are optional and act as wildcards when unset. Supported patterns: | Filter Fields | Matches | Example | |---------------|---------|---------| | `relation` + `object` | All users with this relation to this object | `relation: member, object: org:123` | -| `user` + `relation` | All objects where this user has this relation | `user: user:alice, relation: member` | | `user` + `object` | All relations between this user and object | `user: user:alice, object: org:123` | +| `user` + `object` type prefix | All of a user's relations to a resource type | `user: user:alice, object: 'org:'` | | `user` + `relation` + `object` type prefix | All of a user's relations to a resource type | `user: user:alice, relation: member, object: 'org:'` | | `object` only | All relations on this object | `object: org:123` | | `object` type prefix only | All tuples for a resource type | `object: 'org:'` | -| `user` only | All relations involving this user | `user: user:alice` | #### Tuple Filter Field Reference @@ -155,12 +154,12 @@ Each tuple filter supports: |-------|------|-------------| | `user` | string | Interpolated string for the user URN. Optional; omit to match all users. | | `relation` | string | Interpolated string for the relation name. Optional; omit to match all relations. | -| `object` | string | Interpolated string for the object URN. Optional; omit to match all objects. Object type prefixes (e.g., `org:`) are valid and match all objects of that type. | +| `object` | string | Interpolated string for the object URN. **Required** — set it to at least an object type prefix (e.g., `org:`), since FGA's Read API needs an object type. A type prefix matches all objects of that type. | | `action` | string | `"patch"` (default) or `"delete"`. `patch` diffs desired-state tuples against FGA and writes the delta. `delete` removes everything matching the filter from FGA. | **Constraints:** - At least one filter required in `tuple_filters`; empty lists are rejected at validation time. -- Each filter must have at least one field set (user, relation, or object). +- Each filter must set `object` to at least an object type prefix (e.g., `org:`). FGA's Read API requires an object type, so a filter with no object can never match — it is rejected at validation time rather than deferred to a failed read. `user` and `relation` are optional. - A filter with all three fields set to specific, concrete values (e.g., `user: user:alice, relation: member, object: org:123`) is rejected — that describes a single tuple, not a range of tuples; use tuple-level `action: write`/`action: delete` instead. A filter is valid when any field uses an object type prefix (e.g., `object: org:`), even if all three fields are set, since a type prefix matches multiple objects. - If all filters in a rule have `action: delete`, the rule must not define any tuple templates — `delete` means "remove everything matching", so defining desired-state tuples is contradictory. This is a structural check: if the rule's YAML contains `tuples` (at rule level or in an iterator), it is rejected with a `ValidationError` at parse time regardless of whether those tuples would produce output at runtime. - If any filter has `action: patch`, the rule must produce at least one tuple. When the rule has no iterator and no rule-level tuple templates, this is a structural guarantee of zero tuples and is rejected at parse time with a `ValidationError`. When tuple count depends on runtime factors (iterator source length, tuple-level when guards), this is an eval-time check — the engine produces the tuple filter operation with zero tuples and the consumer fails before reading from FGA (fail fast). diff --git a/language/README.md b/language/README.md index fdfa611..c768fbb 100644 --- a/language/README.md +++ b/language/README.md @@ -21,7 +21,7 @@ Adding a new version: create `parse_vN.go`, add a case to the version switch in - **Variables YAML format:** YAML mapping (not list) with order preserved via custom `UnmarshalYAML`. - **TupleAction:** `"write"` (default) or `"delete"` — validated at parse time. - **TupleFilterAction:** `"patch"` (default) or `"delete"` — separate type from `TupleAction` since semantics differ (patch/delete operate on FGA read results, not individual tuples). -- **ParsedTupleFilter:** YAML input for a tuple filter. `User`, `Relation`, `Object` are optional interpolated strings; `Action` defaults to `"patch"`. Validated to have at least one field set; all-concrete (non-interpolated, non-type-prefix) three-field filters are rejected. +- **ParsedTupleFilter:** YAML input for a tuple filter. `Object` is a required interpolated string (at least an object type prefix, since FGA's Read API needs an object type); `User` and `Relation` are optional interpolated strings; `Action` defaults to `"patch"`. All-concrete (non-interpolated, non-type-prefix) three-field filters are rejected. - **TupleFilter:** Rendered tuple filter (mapper output). Carries `User`, `Relation`, and `Object` fields (any may be empty to act as a wildcard) plus `Action`. Filter-level `Action` is independent of tuple-level `TupleAction`. - **Rule-level action:** `Rule.Action` (`"write"` or `"delete"`) propagates to all tuples during `validateRule()`, before `validateTupleTemplate()` runs. When set, tuple-level `action` is forbidden. Rule-level `action: delete` with `patch` tuple filters is a `ValidationError`. - **Tuple condition and context:** `ParsedTuple.Condition` (string, literal FGA condition name) and `ParsedTuple.Context` (map of interpolated strings). Validation: `context` requires `condition`; `condition` forbidden on `action: delete` tuples. @@ -65,7 +65,7 @@ All structural checks below run during `Validate()`/parsing (`parser.go`), befor | Iterator missing `as` | `rules[{i}].iterator.as` | `is required` | | Iterator `as` shadows `input`/`variables` | `rules[{i}].iterator.as` | `iterator "as" name "input" shadows the built-in input scope; choose a different name` | | More than 3 tuple filters | `rules[{i}].tuple_filters` | `exceeds maximum of 3 filters` | -| Tuple filter with no fields set | `rules[{i}].tuple_filters[{j}]` | `must have at least one field set (user, relation, or object)` | +| Tuple filter with no `object` | `rules[{i}].tuple_filters[{j}].object` | `must be set to at least an object type prefix (e.g. "document:")` | | Tuple filter with all three fields concrete (no type prefix) | `rules[{i}].tuple_filters[{j}]` | `filter with all three fields set to concrete values describes a single tuple, not a range; use tuple-level action instead` | | Invalid tuple filter `action` | `rules[{i}].tuple_filters[{j}].action` | `invalid action "ignore" (must be "patch" or "delete")` | | `delete` filters combined with rule-level tuple templates | `rules[{i}].tuple_filters` | (rejected — see `validateTupleFilters()`) | diff --git a/language/parser.go b/language/parser.go index b6a13fd..7a184ce 100644 --- a/language/parser.go +++ b/language/parser.go @@ -513,11 +513,13 @@ func validateTupleFilters(rule *Rule, displayPrefix, lookupPrefix string, root a filterDisplay := fmt.Sprintf("%s.tuple_filters[%d]", displayPrefix, j) filterLookup := fmt.Sprintf("%s.tuple_filters[%d]", lookupPrefix, j) - // At least one field set - if f.User == "" && f.Relation == "" && f.Object == "" { + // Object is required. FGA's Read API mandates an object type (the object + // id may be empty, the type may not), so a filter with no object can never + // match at runtime — reject it here instead of deferring to a failed Read. + if f.Object == "" { errs = append(errs, &ValidationError{ - Field: filterDisplay, - Message: "must have at least one field set (user, relation, or object)", + Field: filterDisplay + ".object", + Message: `must be set to at least an object type prefix (e.g. "document:")`, Position: nodePosition(resolveNodePath(root, filterLookup)), }) } diff --git a/language/parser_test.go b/language/parser_test.go index c90d118..c7c6f78 100644 --- a/language/parser_test.go +++ b/language/parser_test.go @@ -1637,20 +1637,34 @@ func TestValidateTupleFiltersMaxExceeded(t *testing.T) { assert.True(t, found, "expected max filter error") } -func TestValidateTupleFiltersAtLeastOneField(t *testing.T) { +func TestValidateTupleFiltersObjectRequired(t *testing.T) { t.Parallel() - rule := &Rule{ - Name: "test", - TupleFilters: []ParsedTupleFilter{{}}, + tests := []struct { + name string + filter ParsedTupleFilter + wantError bool + }{ + {name: "no fields set", filter: ParsedTupleFilter{}, wantError: true}, + {name: "user only", filter: ParsedTupleFilter{User: "user:{{ .input.id }}", Action: FilterActionDelete}, wantError: true}, + {name: "relation only", filter: ParsedTupleFilter{Relation: "member"}, wantError: true}, + {name: "object type prefix", filter: ParsedTupleFilter{Object: "org:"}, wantError: false}, + {name: "object with user", filter: ParsedTupleFilter{User: "user:alice", Object: "org:"}, wantError: false}, + {name: "templated object", filter: ParsedTupleFilter{Object: "org:{{ .variables.id }}"}, wantError: false}, } - errs := validateTupleFilters(rule, `rules["test"]`, "rules[0]", nil) - var found bool - for _, e := range errs { - if strings.Contains(e.Error(), "must have at least one field set") { - found = true - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule := &Rule{Name: "test", TupleFilters: []ParsedTupleFilter{tt.filter}} + errs := validateTupleFilters(rule, `rules["test"]`, "rules[0]", nil) + var found bool + for _, e := range errs { + if strings.Contains(e.Error(), "must be set to at least an object type prefix") { + found = true + assert.Contains(t, e.Error(), `rules["test"].tuple_filters[0].object`) + } + } + assert.Equal(t, tt.wantError, found, "object-required error mismatch") + }) } - assert.True(t, found, "expected at-least-one-field error") } func TestValidateTupleFiltersActionValidation(t *testing.T) { diff --git a/language/types.go b/language/types.go index 36c9d5f..65b96b3 100644 --- a/language/types.go +++ b/language/types.go @@ -37,8 +37,9 @@ const ( FilterActionDelete TupleFilterAction = "delete" ) -// ParsedTupleFilter is a tuple filter parsed from YAML. Fields are optional -// interpolated strings; empty fields act as wildcards when rendered. +// ParsedTupleFilter is a tuple filter parsed from YAML. Object is a required +// interpolated string; User and Relation are optional interpolated strings. +// Empty rendered fields act as wildcards. // // The *Pos fields carry the source position of each interpolated field, stamped // at parse time so the mapper can attach diagnostics without reaching back into diff --git a/rules_test.go b/rules_test.go index 5b6830b..0ee540d 100644 --- a/rules_test.go +++ b/rules_test.go @@ -17,6 +17,7 @@ rules: tuple_filters: - user: "user:{{ input.id }}" relation: "member" + object: "org:{{ input.id }}" iterator: source: input.roles as: role @@ -61,7 +62,7 @@ rules: require.Len(t, r.TupleFilters, 1) assert.Equal(t, "user:{{ input.id }}", r.TupleFilters[0].User) assert.Equal(t, "member", r.TupleFilters[0].Relation) - assert.Empty(t, r.TupleFilters[0].Object) + assert.Equal(t, "org:{{ input.id }}", r.TupleFilters[0].Object) } // TestRulesContextIsDeepCopied asserts the Rules() contract: mutating a returned