Visibility๐Ÿ”—

Visibility is used to hide parts of your schema from selected users. Visibility acts on two layers:

  1. Introspection queries omit anything the current request can't see.
  2. Operations that reference a hidden entity fail in the validation phase of the graphql request cycle with the same error shape that graphql-core produces for genuinely nonexistent types and fields.

Use visibility to control availability, not access, for example to gradually roll out a new field or phase out an old one. Visibility is not a security boundary. Treat it as a way to shape what the schema looks like, not as a way to protect data.

Basic usage๐Ÿ”—

Hide a Field from unauthenticated users by decorating a method with the <field_name>.visible decorator:

from undine import Entrypoint, Field, QueryType, RootType
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskType(QueryType[Task]):
    name = Field()

    @name.visible
    def name_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated


class Query(RootType):
    tasks = Entrypoint(TaskType, many=True)

    @Entrypoint
    def hello(self) -> str:
        return "world"

Using the following query:

1
2
3
4
5
query {
  tasks {
    name
  }
}

An unauthenticated request that queries the name field sees:

1
2
3
4
5
6
7
{
  "errors": [
    {
      "message": "Cannot query field 'name' on type 'TaskType'."
    }
  ]
}

Hide an entire QueryType by overriding the __is_visible__ classmethod:

from undine import Entrypoint, Field, QueryType, RootType
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskType(QueryType[Task]):
    name = Field()

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated


class Query(RootType):
    tasks = Entrypoint(TaskType, many=True)

    @Entrypoint
    def hello(self) -> str:
        return "world"

An unauthenticated request that queries the tasks entrypoint sees:

1
2
3
4
5
6
7
{
  "errors": [
    {
      "message": "Cannot query field 'tasks' on type 'Query'."
    }
  ]
}

Notice that hiding the TaskType also hid the tasks entrypoint, even though the entrypoint itself has no visibility hook. Visibility cascades through type references. You only need to hide the root of what you want to remove and the rest follows. The exact cascade rules for each entity are described in the Supported entities section below.

Supported entities๐Ÿ”—

Every Undine class and member exposes the same shape. The paragraph below each snippet describes what happens when that entity is hidden.

RootType๐Ÿ”—

from undine import Entrypoint, RootType
from undine.typing import DjangoRequestProtocol


class Mutation(RootType):
    @Entrypoint
    def testing(self, name: str) -> str:
        return f"Hello, {name}!"

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated
When hidden, the root type resolves to null in introspection and every Entrypoint on that root becomes unreachable.

Entrypoint๐Ÿ”—

from undine import Entrypoint, RootType
from undine.typing import DjangoRequestProtocol


class Query(RootType):
    @Entrypoint
    def testing(self, name: str) -> str:
        return f"Hello, {name}!"

    @testing.visible
    def testing_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the entrypoint from its RootType. No further cascade.

QueryType๐Ÿ”—

from undine import Field, QueryType
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskType(QueryType[Task]):
    name = Field()

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides every Entrypoint, Field, InterfaceField, and FederationField that returns it, every MutationType that has it as its output type, and removes it from any UnionType's member list. Cascade is transitive, so a MutationType hidden this way in turn hides any Input or Entrypoint that references it. FilterSet and OrderSet connected to the QueryType are hidden if not other entrypoint references them.

Field๐Ÿ”—

from undine import Field, QueryType
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskType(QueryType[Task]):
    name = Field()

    @name.visible
    def name_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the field from its QueryType. No further cascade.

MutationType๐Ÿ”—

from undine import Input, MutationType
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskCreateMutation(MutationType[Task]):
    name = Input()

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the Entrypoint that references it. Hiding a related MutationType hides the Input that references it instead.

Input๐Ÿ”—

from undine import Input, MutationType
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskCreateMutation(MutationType[Task]):
    name = Input()

    @name.visible
    def name_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the input from its MutationType. No further cascade.

FilterSet๐Ÿ”—

from undine import Filter, FilterSet
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter()

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides all its Filter members and removes the filter argument from every entrypoint that uses it, including when the entrypoint is wrapped by pagination such as Connection or OffsetPagination.

Filter๐Ÿ”—

from undine import Filter, FilterSet
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskFilterSet(FilterSet[Task]):
    name = Filter()

    @name.visible
    def name_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the filter from its FilterSet. No further cascade.

OrderSet๐Ÿ”—

from undine import Order, OrderSet
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskOrderSet(OrderSet[Task]):
    name = Order()

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides all its Order members and removes the orderBy argument from every entrypoint that uses it, including when the entrypoint is wrapped by pagination such as Connection or OffsetPagination.

Order๐Ÿ”—

from undine import Order, OrderSet
from undine.typing import DjangoRequestProtocol

from .models import Task


class TaskOrderSet(OrderSet[Task]):
    name = Order()

    @name.visible
    def name_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the order from its OrderSet. No further cascade.

InterfaceType๐Ÿ”—

from graphql import GraphQLNonNull, GraphQLString

from undine import InterfaceField, InterfaceType
from undine.typing import DjangoRequestProtocol


class Named(InterfaceType):
    name = InterfaceField(GraphQLNonNull(GraphQLString))

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides every Field, Entrypoint, InterfaceField, and FederationField that returns it. Implementing QueryTypes stay visible and queryable directly, but they no longer list the hidden interface in their interfaces, and any fields they gained purely by inheriting the interface disappear as well. Fields defined directly on the QueryType that happen to share a name with an interface field stay visible.

InterfaceField๐Ÿ”—

from graphql import GraphQLNonNull, GraphQLString

from undine import InterfaceField, InterfaceType
from undine.typing import DjangoRequestProtocol


class Named(InterfaceType):
    name = InterfaceField(GraphQLNonNull(GraphQLString))

    @name.visible
    def name_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the field from its InterfaceType. On every implementing QueryType, the corresponding inherited Field is also hidden.

UnionType๐Ÿ”—

from undine import QueryType, UnionType
from undine.typing import DjangoRequestProtocol

from .models import Project, Task


class TaskType(QueryType[Task]): ...


class ProjectType(QueryType[Project]): ...


class SearchObjects(UnionType[TaskType, ProjectType]):
    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides every Field, Entrypoint, InterfaceField, and FederationField that returns it. Member QueryTypes stay visible on their own. When some (but not all) members are hidden, they disappear from the union's member list, and a single-member union is still valid. If every member is hidden at runtime, the union collapses to empty and is treated as hidden itself, cascading up to every field or entrypoint that returns it.

Directive๐Ÿ”—

from graphql import DirectiveLocation

from undine import Directive
from undine.typing import DjangoRequestProtocol


class NewDirective(Directive, locations=[DirectiveLocation.FIELD_DEFINITION], schema_name="new"):
    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Removes the directive from __schema.directives and from every type system location where it was applied. Queries that apply it in an executable location fail validation. DirectiveArgument members hide with it.

DirectiveArgument๐Ÿ”—

from graphql import DirectiveLocation, GraphQLNonNull, GraphQLString

from undine import Directive, DirectiveArgument
from undine.typing import DjangoRequestProtocol


class VersionDirective(Directive, locations=[DirectiveLocation.FIELD_DEFINITION], schema_name="version"):
    value = DirectiveArgument(GraphQLNonNull(GraphQLString))

    @value.visible
    def value_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the argument from its Directive. No further cascade.

CalculationArgument๐Ÿ”—

from django.db.models import Value

from undine import Calculation, CalculationArgument
from undine.typing import DjangoExpression, DjangoRequestProtocol, GQLInfo


class ExampleCalculation(Calculation[int]):
    value = CalculationArgument(int)

    def __call__(self, info: GQLInfo) -> DjangoExpression:
        return Value(self.value)

    @value.visible
    def value_visible(self, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the argument from the Field whose ref is a Calculation using this argument. No further cascade.

FederationType๐Ÿ”—

from undine.federation import FederationField, FederationType, KeyDirective
from undine.typing import DjangoRequestProtocol


@KeyDirective(fields="id")
class UserExtension(FederationType, schema_name="User"):
    id = FederationField(int)

    @classmethod
    def __is_visible__(cls, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides every Entrypoint, Field, InterfaceField, and FederationField that returns it, and its own FederationField members hide with it.

FederationField๐Ÿ”—

from undine.federation import FederationField, FederationType, KeyDirective
from undine.typing import DjangoRequestProtocol


@KeyDirective(fields="id")
class UserExtension(FederationType, schema_name="User"):
    id = FederationField(int)
    assigned_task_count = FederationField(int)

    @assigned_task_count.visible
    def assigned_task_count_visible(self: FederationField, request: DjangoRequestProtocol) -> bool:
        return request.user.is_authenticated

Hides the field from its FederationType. No further cascade.

Federation๐Ÿ”—

Visibility hooks apply to FederationType and FederationField (see above), and the _entities resolver honors them just like any other field: entries that the current request can't see are filtered out of the response.

The _service { sdl } payload is not filtered, however. It always returns the full subgraph SDL because the router uses it to compose the supergraph, and composition needs a stable, request-independent view of the schema. Per-request visibility on federation types therefore doesn't propagate to clients through the supergraph โ€” the router remains the source of truth for what clients see.

For hiding a federation element from the supergraph entirely, use the InaccessibleDirective instead. It marks the element as @inaccessible, so the router excludes it from the composed schema while it remains available within the subgraph.

Caching๐Ÿ”—

If using response caching, cached is forced as per-user when the traversal encounters any entity that uses visibility. This makes sure hidden data cannot leak between users through cached responses.

Additionally, you can cache the user's introspection response by setting VISIBILITY_CACHE_TIMEOUT. Cache keys are derived from the user's primary key plus any extra context supplied by VISIBILITY_CACHE_EXTRA_CONTEXT.

Caveats๐Ÿ”—

  • Sync only. __is_visible__ and .visible callbacks must be synchronous. graphql-core's introspection resolvers and validation rules are sync, and cannot suspend on await. If your visibility check needs data that is only reachable through an async fetch, resolve it using a lifecycle hook and store the result on the request object.
  • Fail-closed on exception. If a hook raises, the entity is treated as hidden
  • "did you mean" suggestions. Auto-disabled globally when any schema uses visibility so hidden entities never leak through error messages. This is equivalent to setting ALLOW_DID_YOU_MEAN_SUGGESTIONS to False.