Scopes

Schema visibility and access control in Viaduct.

Scopes

This document provides an overview on how to define scopes on the Viaduct GraphQL schema.

The scoop on scopes

In order to make schema visibility control more explicit and intuitive, we introduced Scopes.

Scopes are a mechanism for encapsulating parts of your schema for information-hiding purposes. Two commonly-used scopes are viaduct, which has a bigger schema available to all of your internal systems, and viaduct:public, a smaller schema available publicly to your frontend clients. Almost all data types will have viaduct scope, which allows them to be queried by backend clients. Some data types, or a subset of fields within data types that can be queried by frontend clients, also have the viaduct:public scope.

Scopes leverage GraphQL type extensions to separate fields within a type that belong to different scopes. This convention avoids the need for annotating each field in the schema with @scope directives, and provides a way to separate different types of fields in the SDL to optimize for human readability.

For example, if we are to expose only field1 from Foo to viaduct:public but not field2 or field3, we will organize our schema like this:

type Foo @scope(to: ["viaduct", "viaduct:public"]) {
  field1: Bar
}

extend type Foo @scope(to: ["viaduct"]) {
  field2: Int
  field3: String
}

type Bar @scope(to: ["viaduct", "viaduct:public"]) {
  field4: Boolean
  field5: String
}

As you will also learn from the later sections, the scope validation system will also enforce explicitness so that each type (including object, input, interface, union, and enum) and their extensions will always have at least one scope value.

[alert]

Either nothing is scoped or everything has a scope applied to it. There is no default scope.

[/alert]

Multiple Schemas

A single instance of the Viaduct framework can expose multiple schemas. Within Airbnb, for instance, the Viaduct service itself, exposes a different, more complete schema to internal clients than it exposes to external Web and mobile clients. Scopes also provide encapsulation that allows us to hide implementation details present in your central schema.

Every schema exported by an instance of the Viaduct framework is called a scope set. A scope set is identified by a schema ID. The particular scope set seen by a given request to the Viaduct framework is controlled by the schemaId field of ExecutionInput . In the above example, viaduct and viaduct:public are both schema IDs. You can use as many schema IDs as you like with whatever naming scheme fits your use case.

Guidelines for annotating types with @scope

Always append @scope to the main type

Whenever you are creating a new type (including object type, input type, interface, union, and enum), always append @scope(to: ["scope1", "scope2", ...]) to the type itself. (Replace "scope1", "scope2", etc. with your desired scopes)

All the attributes within the main type will share and be exposed to ALL the scopes defined in the @scope values.

# field1 and field2 will be visible to both Viaduct data API and public API
type Foo @scope(to: ["viaduct", "viaduct:public"]) {
  field1: Int
  field2: String
}

# CONSTANT1 and CONSTANT2 will be visible to Viaduct data API, as well as both foo and bar.
enum Bar @scope(to: ["viaduct", "foo", "bar"]) {
  CONSTANT1
  CONSTANT2
}

# field from Baz is only visible to Viaduct data API
input Baz @scope(to: ["viaduct"]) {
  field: Boolean
}

Create type extensions and move fields with narrower scopes to this extension

If you need to limit the visible scopes for certain fields, create a type extension, move those fields over, and append @scope with proper scopes.

For example, the following definition will expose field3 to private only.

type Foo @scope(to: ["public", "private"]) {
  field1: Int
  field2: String
}

extend type Foo @scope(to: ["private"]) {
  field3: Boolean
}

Validation

In GraphQL SDL, scopes are referenced by their string value, and in Kotlin are referenced by the strongly typed enum member. The SDL will be validated at build time to ensure invalid scope names were not referenced.

In addition to detecting invalid scope names, the Viaduct Bazel validators will perform other static analysis on the schema in order to detect invalid or confusing scope usage.

Detecting inaccessible fields when referencing another type

Static analysis tooling will detect @scope usages that cause inaccessible fields. Take the following schema excerpt:

type User @scope(to: ["scope1","scope2"]) {
  id: ID!
  firstName: String
  lastName: String
}

type Listing @scope(to: ["scope3"]) {
  user: User # this field would never be accessible
}

In the above example, the User type is only available in the scope1 and scope2 scopes, and the Listing type is only available in the scope3 scope. The user field in Listing is of type User, but User is not visible to the scopes associated with the Listing type. Thus, filtering the schema to any of the three scopes used in this schema excerpt would result in the user field within Listing being filtered out of the schema.

This invariant can be corrected in two ways:

  • The Listing type’s scope should be modified to include scope1 or scope2, OR
  • The User type’s scope should be modified to include the scope3 scope.

Auto prune a type when all of its fields are out of scope

When all the fields of a type are out of scope, this type will not be accessible, even if it is within the scope. Therefore, Viaduct prunes such empty types recursively in the generated schema. For example:

type StaySpace @scope(to: ["viaduct", "listing-block", "viaduct:private"]) {
  spaceId: Long
  metadata: SpaceMetadata
}

type SpaceMetadata @scope(to: ["viaduct", "listing-block"]) {
  bathroom: StayBathroomMetadata
  bedroom: StayBedroomMetadata
}

type StayBathroomMetadata @scope(to: ["viaduct", "viaduct:private"]) {
  spaceName: String
}

type StayBedroomMetadata @scope(to: ["viaduct", "viaduct:private"]) {
  spaceName: String
}

Filtering the above schema excerpt to listing-block will make SpaceMetadata an empty type, since both StayBathroomMetadata and StayBedroomMetadata will not be in the scope. Thus, SpaceMetadata attribute will be pruned from StaySpace in the listing-block scope, as it will not be reachable, despite that this type has annotated with listing-block scope.

Detect invalid scope usage within a type

When leveraging a type extension to define fields that are available in a specific scope, it’s essential that the scope specified in the directive on the type extension be one of the scopes specified in the original definition of the type.

Take for example the following schema excerpt:

type User @scope(to: ["viaduct","user-block"]) {
  id: ID!
  firstName: String
  lastName: String
}

extend type User @scope(to: ["viaduct:internal-tools"]) {
  aSpecialInternalField: String
}

This example shows an incorrect usage of the scope directive on the type extension, as the viaduct:internal-tools scope is not specified as a scope on the original User type definition.

The correct version of the above example would be:

type User @scope(to: ["viaduct","viaduct:internal-tools", "user-block"]) {
  id: ID!
  firstName: String
  lastName: String
}

extend type User @scope(to: ["viaduct:internal-tools"]) {
  aSpecialInternalField: String
}

This validation rule enforces the convention that common fields be defined in the original type definition, and fields that are defined in specific scopes be specified using type extensions.

Troubleshooting

If you get an error like Unable to find concrete type ... for interface ... in the type map or Unable to find concrete type ... for union ... in the type map, it’s because the interface or union type is defined in schema module A, and the concrete type that implements the interface or extends the union is defined in schema module B, and B does not depend on A.

For example, this is not allowed because UnionA is defined in the data module and its member TypeA is defined in the entity module. The entity module does not depend on the data module.

# modules/entity
type TypeA {
  fieldA: String
}

# modules/data
type TypeB {
  fieldB: String
}

union UnionA = TypeB

extend UnionA = TypeA # <-- not ok

To fix it, make sure you define the concrete type of interface or union in a schema module that is dependent on the schema module where the interface or union type is defined.

For example, you can “lift” up the type definition for UnionA to the entity module:

# modules/entity
type TypeA {
  fieldA: String
}

union UnionA = TypeA

# modules/data
type TypeB {
  fieldB: String
}

extend type UnionA = TypeB # <-- ok