Integrates implements a
GraphQL API that allows our products and customers to build external integrations to query data and perform mutations exposed in its schema.
All queries are directed to a single endpoint, which is exposed at
/api.
Structure
The API is built using a
schema-first approach enabled by the
Ariadne library, structured as follows:
โถ ๐ api
โถ ๐ enums
โถ ๐ explorer
โถ ๐ inputs
โถ ๐ interfaces
โถ ๐ mutations
โถ ๐ resolvers
โถ ๐ object_name
โถ ๐ query
โถ ๐ scalars
โถ ๐ subscriptions
โถ ๐ unions
โถ ๐ validations
Explorer
The API provides a web-based GUI that allows performing queries and exploring schema definitions graphically and interactively. You can access it on:
- https://app.fluidattacks.com/api, which is production.
https://<branch>.app.fluidattacks.com/api
, which are the ephemeral environments (where <branch>
is the name of your Git branch).- https://localhost:8001/api, which is local (useful for development).
Types
There are two approaches to defining a GraphQL
schema:
- Code-first
- Schema-first
We use the latter, which implies defining the structure using GraphQL SDL
(Schema definition language) and binding it to Python functions.
e.g:
api/resolvers/stakeholder/schema.graphql
type Stakeholder {
"Stakeholder email"
email: String!
}
api/resolvers/stakeholder/schema.py
from ariadne import (
ObjectType,
)
STAKEHOLDER = ObjectType('Stakeholder')
Enums
enum AuthProvider {
"Bitbucket auth"
BITBUCKET
"Google auth"
GOOGLE
"Microsoft auth"
MICROSOFT
)
Note
By default, enum values passed to resolver functions will match their name.
To map the value to something else, you can specify it in the enums binding index, e.g:
from ariadne import EnumType
ENUMS: Tuple [EnumType, ...] = (
...,
EnumType(
' AuthProvider',
{
'BITBUCKET': ' bitbucket-oauth2',
'GOOGLE': ' google-oauth2',
' MICROSOFT': ' azuread-tenant-oauth2'
}
),
...
)
Scalars
GraphQL provides some primitive scalars, such as String, Int, and Boolean, but in some cases, it is required to define custom ones that arenโt included by default due to not (yet) being part of the spec, like Datetime, JSON, and Upload.
Resolvers
A resolver is a function that receives two arguments:
- Parent: The value returned by the parent resolver, usually a dictionary. If itโs a root resolver, this argument will be None
- Info: An object whose attributes provide details about the execution AST and the HTTP request.
It will also receive keyword arguments if the GraphQL field defines any.
api/resolvers/stakeholder/schema.graphql
type Stakeholder {
...
"User email"
email: String!
...
}
api/resolvers/stakeholder/email.py
from graphql.type.definition import (
GraphQLResolveInfo
)
from typing import (
TypedDict,
Unpack,
)
class ResolverArgs(TypedDict):
email: str
@STAKEHOLDER.field("email")
def resolve(parent: Item, info: GraphQLResolveInfo, **kwargs: Unpack[ResolverArgs]):
The function must return a value whose structure matches the type defined in the GraphQL schema.
Caution
Avoid reusing the resolver function. Other than the binding, it should never be called in other parts of the code
Mutations
Mutations are a kind of GraphQL operation explicitly meant to change data.
Note
Mutations are also resolvers, just named differently for the sake of separating concerns, and just like a resolver function, they receive the parent argument (always None), the info object, and their defined arguments.
Most mutations only return {'success': bool}
also known as
SimplePayload
, but they arenโt limited to that. If you need your mutation to return other data, just look for it or define a new type in api/mutations/payloads/schema.graphql
and use it.
api/mutations/schema.graphql
type Mutation {
...
"Adds a new Stakeholder"
addStakeholder(
"Stakeholder email"
email: String!
"stakeholder role"
role: StakeholderRole!
): AddStakeholderPayload!
...
}
api/mutations/add_stakeholder.py
from
graphql.type.definition import
GraphQLResolveInfo
from
typing import
TypedDict, Unpack
from
.schema import
SECURE_MUTATION
from
integrates.api.auth import
AuthzUserAccess
class
AddStakeholderArgs(TypedDict):
email: str
role: str
@SECURE_MUTATION.field("
addStakeholder", authz=[AuthzUserAccess()])
async def mutate(
_parent: None,
info: GraphQLResolveInfo,
**kwargs: Unpack[AddStakeholderArgs],
):
user_domain.create(
kwargs["email
"],
kwargs["role
"],
)
return AddStakeholderPayload(success=True)
Note
We use Secure ObjectTypes to enforce authorization. They have the authz
attribute that receives a list of authorization classes like: AuthzUserAccess, AuthzGroupAccess, AuthzGroupAttribute, and AuthzOrganizationAccess.
Subscriptions
Subscriptions are long-lasting operations designed to provide real-time data updates through bidirectional WebSocket communication. They are commonly used to query AI models and suggest solutions for identified risks.
They can maintain an active connection to your GraphQL server. Typical resolvers are used to start a connection.
Note
These subscriptions consist of two key components: the generator, which progressively provides values as they become available, and the resolver, which processes these values, potentially returning them unchanged or with modifications as needed.
api/subscriptions/schema.graphql
type Subscription {
...
"Suggested fix for the vulnerability"
getCustomFix(
"The id off the vulnerability"
vulnerabilityId: String!
): String!
...
}
api/subscriptions/get_custom_fix.py
from graphql.type.definition import (
GraphQLResolveInfo
)
from
typing import (
AsyncGenerator,
cast,
)
@S
UBSCRIPTION.source
("
getCustomFix")
def generator(
_parent: None, info: GraphQLResolveInfo,
AsyncGenerator[str,
None]):
return "test"
@S
UBSCRIPTION.field
("
getCustomFix")
def resolve(
count: str, _info: GraphQLResolveInfo,
**_kwarg: str):
return count
Errors
All exceptions raised will be reported in the โerrorsโ field of the response.
Raising exceptions can be useful to enforce business rules and report back to the client in cases where the operation could not be completed successfully.
Further reading:
- https://spec.graphql.org/June2018/#sec-Errors
Authentication
The Integrates API enforces authentication by checking for the presence and validity of a JWT in the request cookies or headers.
Authorization
The Integrates API enforces authorization by implementing an ABAC model with a simple grouping for defining roles. You can find the model
in the GitLab project.
Levels and roles
A user can have one role for each of the three levels of authorization:
- User
- Organization
- Group
Also, Service level exists, and it checks the covered features according to the group plan, like
Advanced or Essential.
Enforcer
An enforcer is an authorization function that checks if the user can act on the context.
Boundary
The general methods for listing and getting the user permissions (and the permissions that the user can grant) are within the
boundary.
The whole application must use these methods for implementing controls.
Policy
The general methods for getting the user role, granting permissions, or revoking them are in the
policy.
Decorators
For resolvers or mutations that require authorized users, decorate the function with the appropriate decorator from
decorators
:
- @enforce_user_level_auth_async
- @enforce_organization_level_auth_async
- @enforce_group_level_auth_async
Guides
Adding new fields or mutations
- Declare the field or mutation in the schema using SDL.
- Write the resolver or mutation function.
- Bind the resolver or mutation function to the schema.
Editing and removing fields or mutations
When dealing with fields or mutations that are already in use by clients, itโs crucial to ensure backward compatibility to prevent breaking changes. To achieve this, we implement a deprecation policy, providing users with advance notice of any planned edits or removals.
This involves informing API users about which fields or mutations will be edited/deleted in the future, granting them adequate time to adapt to these changes.
We use field and mutation deprecation for this. Our current policy mandates removal 6 months after marking fields and mutations as deprecated.
Deprecating fields
type ExampleType {
oldField: String @deprecated(reason: "reason text")
}
The reason should follow something similar to:
This {field|mutation} is deprecated and will be removed after {date}.
If it was replaced or there is an alternative, it should include:
Use the {alternative} {field|mutation} instead.
Dates follow the
AAAA/MM/DD
convention.
Additionally, we offer the option to assume the risk of using deprecated fields or mutations by including a flag in the commit message. This allows developers to make informed decisions when incorporating changes that may affect their implementations.
Removing fields or mutations
When deprecating fields or mutations for removal, these are the common steps to follow:
- Mark the field or mutation as deprecated.
- Wait six months so clients have a considerable window to stop using the field or mutation.
- Delete the field or mutation.
e.g:
Letโs remove the color
field from type Car
:
- Mark the
color
field as deprecated:
type Car {
color: String
@deprecated(
reason: "This field is deprecated and will be removed after 2022/11/13"
)
}
- Wait until one day after the given deprecation date, and remove the field:
Editing fields or mutations
When renaming fields, mutations, or already-existing types within the API, these are the common steps to follow:
- Mark the field or mutation you want to rename as deprecated.
- Add a new field or mutation using the new name you want.
- Wait until one day after the given deprecation date.
- Remove the field or mutation that was marked as deprecated.
e.g:
Letโs make the color
field from type Car
to return a color
Color instead of a String
:
- create a
NewColor
newColor field that returns the Color
type:
type Car {
color: String
newColor: Color
}
- Mark the
color
field as deprecated and set newColor
as the alternative:
type Car {
color: String
@deprecated(
reason: "This field is deprecated and will be removed after 2022/11/13. Use the newColor field instead."
)
newColor: Color
}
- Wait until one day after the given deprecation date and remove the
color
field:
type Car {
newColor: Color
}
- Add a new
color
field that uses the Color
type:
type Car {
color: String
newColor: Color
}
- Mark the
newColor
newColor field as deprecated and set color
as the alternative:
type Car {
color: Color
newColor: Color
@deprecated(
reason: "This field is deprecated and will be removed after 2022/11/13. Use the color field instead."
)
}
- Wait until one day after the given deprecation date and remove the
newColor
field:
Note
These steps may change depending on what you want to do, just keep in mind that keeping backwards compatibility is what really matters.
Tip
Have an idea to simplify our architecture or noticed docs that could use some love? Don't hesitate to
open an issue or submit improvements.