Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -28,6 +28,7 @@ vendor
.minio.sys

# Local test files
.local/
contrib/local-test/dql-data.json
contrib/local-test/dql-data.rdf
contrib/local-test/gql-data.json
Expand Down
99 changes: 99 additions & 0 deletions NESTED_AUTH_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Nested Insert @auth Fix

This branch fixes a **false accept** security gap in Dgraph GraphQL mutations: nested inserts and `@hasInverse` edge linking could modify protected nodes without running the parent type's update authorization rules.

## Problem

When a mutation links a new child to an **existing parent** via `@hasInverse`, Dgraph mutates the parent's inverse predicate but only ran `@auth add` checks on **newly allocated UIDs**. Existing parents were never validated, so non-admin users could run mutations like:

```graphql
mutation {
addFooItem(input: [{ parent: { id: "foo1" } }]) { numUids }
}
```

…even when `ProtectedFoo` requires admin for both add and update.

Deep nested adds could also create leaf nodes whose stricter `@auth add` rules were not enforced consistently.

## Fix

1. **`mutation_rewriter.go`**: track `AffectedNodes` when `asIDReference()` links through an inverse edge to an existing UID.
2. **`mutation.go`**: after `authorizeNewNodes()` (add rules on new UIDs), run `authorizeAffectedNodes()` using **update rules** on affected existing nodes. This runs for **add mutations only** to avoid breaking update mutation flows.

## Verify locally

### Unit tests

```bash
go test ./graphql/resolve -run 'TestAuthQueryRewriting|TestAddChildRecordsAffectedProtectedParent|TestNestedAddRecordsDeepNewNodes' -count=1
```

### E2E integration tests

Build the patched binary and local Docker image, then run auth e2e tests:

```bash
make dgraph
make local-image
docker tag dgraph/dgraph:local dgraph/dgraph:nested-auth-fix

# From graphql/e2e/auth (uses dgraph/dgraph:local via docker-compose.yml)
cd graphql/e2e/auth
docker compose up -d
go test -tags=integration -run TestNestedAdd -count=1 .
docker compose down
```

Or via the repo Makefile:

```bash
make test TAGS=integration PKG=graphql/e2e/auth TEST=TestNestedAdd
```

### Manual repro harness

1. Start Dgraph standalone on port 8080 (see Docker section below).
2. Run:

```bash
chmod +x .local/mint-jwt.sh .local/repro.sh
./.local/repro.sh
```

JWT helper:

```bash
./.local/mint-jwt.sh USER user@example.com false # non-admin token
./.local/mint-jwt.sh ADMIN user@example.com true # admin token
```

Secret lives in `.local/jwt-secret` (gitignored).

## Docker image

Build and tag the patched image:

```bash
make local-image
docker tag dgraph/dgraph:local dgraph/dgraph:nested-auth-fix
```

Use in docker-compose (override snippet):

```yaml
services:
dgraph:
image: dgraph/dgraph:nested-auth-fix
ports:
- "8080:8080"
- "9080:9080"
command: dgraph zero # use standalone or zero+alpha layout for your setup
```

For raggen-style standalone on port 8080, point the `dgraph` service image at `dgraph/dgraph:nested-auth-fix` instead of `dgraph/dgraph:v25.2.0`.

## References

- [Parent update rules bypassed via child + hasInverse](https://discuss.dgraph.io/t/bug-auth-rules-of-parent-not-respected-when-child-with-hasinverse-is-added/12955)
- Branch: `fix/nested-auth-insertions` (based on `v25.3.5`)
5 changes: 5 additions & 0 deletions docker-compose.override.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Example override for projects using Dgraph standalone (e.g. raggen).
# Copy to docker-compose.override.yml or merge into your compose file.
services:
dgraph:
image: dgraph/dgraph:nested-auth-fix
141 changes: 141 additions & 0 deletions graphql/e2e/auth/nested_add_mutation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//go:build integration

/*
* SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package auth

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/dgraph-io/dgraph/v25/graphql/e2e/common"
)

func TestNestedAddHasInverseParentUpdateAuthBlocked(t *testing.T) {
adminHeaders := common.GetJWT(t, "admin", "ADMIN", metaInfo)
userHeaders := common.GetJWT(t, "regular", "USER", metaInfo)

addFooParams := &common.GraphQLParams{
Headers: adminHeaders,
Query: `mutation {
addProtectedFoo(input: [{ id: "nested-auth-foo1", items: [] }]) {
numUids
}
}`,
}
gqlResponse := addFooParams.ExecuteAsPost(t, common.GraphqlURL)
common.RequireNoGQLErrors(t, gqlResponse)

addItemParams := &common.GraphQLParams{
Headers: userHeaders,
Query: `mutation {
addFooItem(input: [{ parent: { id: "nested-auth-foo1" } }]) {
numUids
}
}`,
}
gqlResponse = addItemParams.ExecuteAsPost(t, common.GraphqlURL)
require.NotEmpty(t, gqlResponse.Errors)
require.Contains(t, gqlResponse.Errors[0].Message, "authorization failed")

queryParams := &common.GraphQLParams{
Headers: adminHeaders,
Query: `query {
queryProtectedFoo(filter: { id: { eq: "nested-auth-foo1" } }) {
items { __typename }
}
}`,
}
gqlResponse = queryParams.ExecuteAsPost(t, common.GraphqlURL)
common.RequireNoGQLErrors(t, gqlResponse)
require.JSONEq(t, `{"queryProtectedFoo":[{"items":[]}]}`, string(gqlResponse.Data))

deleteParams := &common.GraphQLParams{
Headers: adminHeaders,
Query: `mutation {
deleteProtectedFoo(filter: { id: { eq: "nested-auth-foo1" } }) {
msg
}
}`,
}
gqlResponse = deleteParams.ExecuteAsPost(t, common.GraphqlURL)
common.RequireNoGQLErrors(t, gqlResponse)
}

func TestNestedAddDeepLeafAuthBlocked(t *testing.T) {
userHeaders := common.GetJWT(t, "member", "USER", metaInfo)

addParams := &common.GraphQLParams{
Headers: userHeaders,
Query: `mutation {
addGuardedBase(input: [{
id: "nested-auth-base1",
files: [{}]
}]) {
numUids
}
}`,
}
gqlResponse := addParams.ExecuteAsPost(t, common.GraphqlURL)
require.NotEmpty(t, gqlResponse.Errors)
require.Contains(t, gqlResponse.Errors[0].Message, "authorization failed")
}

func TestNestedAddDeepLeafAuthAllowedForAdmin(t *testing.T) {
adminHeaders := common.GetJWT(t, "admin", "ADMIN", metaInfo)

addParams := &common.GraphQLParams{
Headers: adminHeaders,
Query: `mutation {
addGuardedBase(input: [{
id: "nested-auth-base2",
files: [{}]
}]) {
guardedBase { id }
}
}`,
}
gqlResponse := addParams.ExecuteAsPost(t, common.GraphqlURL)
common.RequireNoGQLErrors(t, gqlResponse)

deleteParams := &common.GraphQLParams{
Headers: adminHeaders,
Query: `mutation {
deleteGuardedBase(filter: { id: { eq: "nested-auth-base2" } }) {
msg
}
}`,
}
gqlResponse = deleteParams.ExecuteAsPost(t, common.GraphqlURL)
common.RequireNoGQLErrors(t, gqlResponse)
}

func TestNestedAddDeepLeafAuthAllowedForUserWithoutNestedFiles(t *testing.T) {
userHeaders := common.GetJWT(t, "member", "USER", metaInfo)

addParams := &common.GraphQLParams{
Headers: userHeaders,
Query: `mutation {
addGuardedBase(input: [{ id: "nested-auth-base3", files: [] }]) {
guardedBase { id }
}
}`,
}
gqlResponse := addParams.ExecuteAsPost(t, common.GraphqlURL)
common.RequireNoGQLErrors(t, gqlResponse)

deleteParams := &common.GraphQLParams{
Headers: userHeaders,
Query: `mutation {
deleteGuardedBase(filter: { id: { eq: "nested-auth-base3" } }) {
msg
}
}`,
}
gqlResponse = deleteParams.ExecuteAsPost(t, common.GraphqlURL)
common.RequireNoGQLErrors(t, gqlResponse)
}
29 changes: 29 additions & 0 deletions graphql/e2e/auth/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -974,3 +974,32 @@ type Home @auth(
favouriteMember: HomeMember
}
# union testing - end

type ProtectedFoo
@auth(
add: { rule: "{ $ROLE: { eq: \"ADMIN\" } }" }
update: { rule: "{ $ROLE: { eq: \"ADMIN\" } }" }
) {
id: String! @id
items: [FooItem!]! @hasInverse(field: parent)
}

type FooItem {
parent: ProtectedFoo
}

type GuardedBase
@auth(add: {
or: [
{ rule: "{ $ROLE: { eq: \"USER\" } }" }
{ rule: "{ $ROLE: { eq: \"ADMIN\" } }" }
]
}) {
id: String! @id
files: [GuardedFile!]! @hasInverse(field: base)
}

type GuardedFile @auth(add: { rule: "{ $ROLE: { eq: \"ADMIN\" } }" }) {
id: ID!
base: GuardedBase!
}
Loading