Skip to content

feat(composition): add DNF conjunction argument merge strategy#8817

Merged
dariuszkuc merged 3 commits intodevfrom
dnf_conjunction
Jan 30, 2026
Merged

feat(composition): add DNF conjunction argument merge strategy#8817
dariuszkuc merged 3 commits intodevfrom
dnf_conjunction

Conversation

@dariuszkuc
Copy link
Member

Current merge policies for @authenticated, @requiresScopes and @policy were inconsistent.

If single subgraph declared a field with one of the directives then it would restrict access to this supergraph field regardless which subgraph would resolve this field (results in AND rule for any applied auth directive, i.e. @authenticated AND @policy is required to access this field). If the same auth directive (@requiresScopes/@policy) were applied across the subgraphs then the resulting supergraph field could be resolved by fullfilling either one of the subgraph requirements (resulting in OR rule, i.e. either @policy 1 or @policy 2 has to be true to access the field). While arguably this allowed for easier schema evolution, it did result in weakening the security requirements.

Since @policy and @requiresScopes values are represent boolean conditions in Disjunctive Normal Form, we can merge them conjunctively to get the final auth requirements, i.e.

type T @authenticated {
  # requires scopes (A1 AND A2) OR A3
  secret: String @requiresScopes(scopes: [["A1", "A2"], ["A3"]])
}

type T {
  # requires scopes B1 OR B2
  secret: String @requiresScopes(scopes: [["B1"], ["B2"]]
}

type T @authenticated {
  secret: String @requiresScopes(
    scopes: [
      ["A1", "A2", "B1"],
      ["A1", "A2", "B2"],
      ["A3", "B1"],
      ["A3", "B2"]
    ])
}

This algorithm also deduplicates redundant requirements, e.g.

type T {
  # requires A1 AND A2 scopes to access
  secret: String @requiresScopes(scopes: [["A1", "A2"]])
}

type T {
  # requires only A1 scope to access
  secret: String @requiresScopes(scopes: [["A1"]])
}

type T {
  # requires only A1 scope to access as A2 is redundant
  secret: String @requiresScopes(scopes: [["A1"]])
}

Partial backport of apollographql/federation#3321 and apollographql/federation#3343

@dariuszkuc dariuszkuc requested review from a team as code owners January 21, 2026 00:05
@apollo-librarian
Copy link

apollo-librarian bot commented Jan 21, 2026

✅ Docs preview has no changes

The preview was not built because there were no changes.

Build ID: 281f14853ea318e70a2317f4
Build Logs: View logs

@github-actions
Copy link
Contributor

@dariuszkuc, please consider creating a changeset entry in /.changesets/. These instructions describe the process and tooling.

if let Value::List(disjunctions) = value {
disjunctions.iter_mut().for_each(|disjunction| {
if let Value::List(conjunctions) = disjunction.make_mut() {
// TODO should this also filter duplicates? ["A", "A"]?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

arguably this should be extremely rare so probably doesn't matter

@dariuszkuc
Copy link
Member Author

@dariuszkuc dariuszkuc force-pushed the dnf_conjunction branch 2 times, most recently from a89db1b to b824874 Compare January 22, 2026 18:02
@sachindshinde sachindshinde self-requested a review January 23, 2026 18:17
Copy link
Contributor

@sachindshinde sachindshinde left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were a few things below, but I summarized the main bits. Instead of writing out the specifics, I went ahead and filed a PR into this PR addressing the feedback, let me know if that PR looks good/makes sense.

}
}

/// Support for doubly nested non-nullable list types of any non-nullable type `[[Foo!]!]!`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine if you want to fix the code here, but it may be worth leaving a port note that it diverges from JS behavior.

Comment on lines +143 to +148
if ty.is_non_null()
&& ty.is_list()
&& ty.item_type().is_non_null()
&& ty.item_type().is_list()
&& ty.item_type().item_type().is_non_null()
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified to

Suggested change
if ty.is_non_null()
&& ty.is_list()
&& ty.item_type().is_non_null()
&& ty.item_type().is_list()
&& ty.item_type().item_type().is_non_null()
{
if matches!(ty, Type::NonNullList(_))
&& matches!(ty.item_type(), Type::NonNullList(_))
&& ty.item_type().item_type().is_non_null()
{

/// * calculate cartesian product of the arrays to find all possible combinations
/// * simplify combinations by dropping duplicate conditions (i.e. p ^ p = p, p ^ q = q ^ p)
/// * eliminate entries that are subsumed by others (i.e. (p ^ q) subsumes (p ^ q ^ r))
fn dnf_conjunction(values: &[Value]) -> Value {
Copy link
Contributor

@sachindshinde sachindshinde Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this function, the JS version had to jump through hoops because JS's support for sets is bad. In Rust, we can just use actual sets, e.g. a IndexSet<BTreeSet<BTreeSet<_>>> (with some new type around Node<Value> that appropriately implements Ord). This representation also allows the code to avoid having the if let Value::List(_) = value unwrapping and Value::List rewrapping everywhere.

// initialize with first entry
let mut result = filtered
.next()
.expect("At least a single DNF conjunction value should exist");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can avoid this expect() if we move the // should never be the case block down here.

Comment on lines +261 to +262
let mut accumulator: Vec<Node<Value>> = Vec::new();
let mut seen: HashSet<Value> = HashSet::default();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This particular representation (a vector and a set that deduplicates it) is a holdover from limitations in JS; we can just use a set around Node<Value> instead (it'll avoid a vector clone down below).

dariuszkuc pushed a commit that referenced this pull request Jan 30, 2026
dariuszkuc and others added 3 commits January 30, 2026 16:15
Current merge policies for `@authenticated`, `@requiresScopes` and `@policy` were inconsistent.

If single subgraph declared a field with one of the directives then it would restrict access to this supergraph field regardless which subgraph would resolve this field (results in AND rule for any applied auth directive, i.e. `@authenticated` AND `@policy` is required to access this field). If the same auth directive (`@requiresScopes`/`@policy`) were applied across the subgraphs then the resulting supergraph field could be resolved by fullfilling either one of the subgraph requirements (resulting in OR rule, i.e. either `@policy` 1 or `@policy` 2 has to be true to access the field). While arguably this allowed for easier schema evolution, it did result in weakening the security requirements.

Since `@policy` and `@requiresScopes` values are represent boolean conditions in Disjunctive Normal Form, we can merge them conjunctively to get the final auth requirements, i.e.

```graphql
type T @authenticated {
  # requires scopes (A1 AND A2) OR A3
  secret: String @requiresScopes(scopes: [["A1", "A2"], ["A3"]])
}

type T {
  # requires scopes B1 OR B2
  secret: String @requiresScopes(scopes: [["B1"], ["B2"]]
}

type T @authenticated {
  secret: String @requiresScopes(
    scopes: [
      ["A1", "A2", "B1"],
      ["A1", "A2", "B2"],
      ["A3", "B1"],
      ["A3", "B2"]
    ])
}
```

This algorithm also deduplicates redundant requirements, e.g.

```graphql
type T {
  # requires A1 AND A2 scopes to access
  secret: String @requiresScopes(scopes: [["A1", "A2"]])
}

type T {
  # requires only A1 scope to access
  secret: String @requiresScopes(scopes: [["A1"]])
}

type T {
  # requires only A1 scope to access as A2 is redundant
  secret: String @requiresScopes(scopes: [["A1"]])
}
```

<!-- FED-853 -->

Partial backport of apollographql/federation#3321 and apollographql/federation#3343
@dariuszkuc dariuszkuc enabled auto-merge (squash) January 30, 2026 22:29
@dariuszkuc dariuszkuc disabled auto-merge January 30, 2026 22:29
@dariuszkuc dariuszkuc enabled auto-merge (squash) January 30, 2026 22:30
@dariuszkuc dariuszkuc merged commit 71c9779 into dev Jan 30, 2026
15 checks passed
@dariuszkuc dariuszkuc deleted the dnf_conjunction branch January 30, 2026 22:51
the-gigi-apollo pushed a commit that referenced this pull request Feb 4, 2026
Current merge policies for `@authenticated`, `@requiresScopes` and `@policy` were inconsistent.

If single subgraph declared a field with one of the directives then it would restrict access to this supergraph field regardless which subgraph would resolve this field (results in AND rule for any applied auth directive, i.e. `@authenticated` AND `@policy` is required to access this field). If the same auth directive (`@requiresScopes`/`@policy`) were applied across the subgraphs then the resulting supergraph field could be resolved by fullfilling either one of the subgraph requirements (resulting in OR rule, i.e. either `@policy` 1 or `@policy` 2 has to be true to access the field). While arguably this allowed for easier schema evolution, it did result in weakening the security requirements.

Since `@policy` and `@requiresScopes` values are represent boolean conditions in Disjunctive Normal Form, we can merge them conjunctively to get the final auth requirements, i.e.

```graphql
type T @authenticated {
  # requires scopes (A1 AND A2) OR A3
  secret: String @requiresScopes(scopes: [["A1", "A2"], ["A3"]])
}

type T {
  # requires scopes B1 OR B2
  secret: String @requiresScopes(scopes: [["B1"], ["B2"]]
}

type T @authenticated {
  secret: String @requiresScopes(
    scopes: [
      ["A1", "A2", "B1"],
      ["A1", "A2", "B2"],
      ["A3", "B1"],
      ["A3", "B2"]
    ])
}
```

This algorithm also deduplicates redundant requirements, e.g.

```graphql
type T {
  # requires A1 AND A2 scopes to access
  secret: String @requiresScopes(scopes: [["A1", "A2"]])
}

type T {
  # requires only A1 scope to access
  secret: String @requiresScopes(scopes: [["A1"]])
}

type T {
  # requires only A1 scope to access as A2 is redundant
  secret: String @requiresScopes(scopes: [["A1"]])
}
```

Partial backport of apollographql/federation#3321 and apollographql/federation#3343


Co-authored-by: Sachin D. Shinde <sachin@apollographql.com>
briannafugate408 pushed a commit that referenced this pull request Feb 4, 2026
Current merge policies for `@authenticated`, `@requiresScopes` and `@policy` were inconsistent.

If single subgraph declared a field with one of the directives then it would restrict access to this supergraph field regardless which subgraph would resolve this field (results in AND rule for any applied auth directive, i.e. `@authenticated` AND `@policy` is required to access this field). If the same auth directive (`@requiresScopes`/`@policy`) were applied across the subgraphs then the resulting supergraph field could be resolved by fullfilling either one of the subgraph requirements (resulting in OR rule, i.e. either `@policy` 1 or `@policy` 2 has to be true to access the field). While arguably this allowed for easier schema evolution, it did result in weakening the security requirements.

Since `@policy` and `@requiresScopes` values are represent boolean conditions in Disjunctive Normal Form, we can merge them conjunctively to get the final auth requirements, i.e.

```graphql
type T @authenticated {
  # requires scopes (A1 AND A2) OR A3
  secret: String @requiresScopes(scopes: [["A1", "A2"], ["A3"]])
}

type T {
  # requires scopes B1 OR B2
  secret: String @requiresScopes(scopes: [["B1"], ["B2"]]
}

type T @authenticated {
  secret: String @requiresScopes(
    scopes: [
      ["A1", "A2", "B1"],
      ["A1", "A2", "B2"],
      ["A3", "B1"],
      ["A3", "B2"]
    ])
}
```

This algorithm also deduplicates redundant requirements, e.g.

```graphql
type T {
  # requires A1 AND A2 scopes to access
  secret: String @requiresScopes(scopes: [["A1", "A2"]])
}

type T {
  # requires only A1 scope to access
  secret: String @requiresScopes(scopes: [["A1"]])
}

type T {
  # requires only A1 scope to access as A2 is redundant
  secret: String @requiresScopes(scopes: [["A1"]])
}
```

Partial backport of apollographql/federation#3321 and apollographql/federation#3343


Co-authored-by: Sachin D. Shinde <sachin@apollographql.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants