#
Authorization
By default we use client scopes for authorizing access to certain parts of the API. While this is a good and quick solution for a start when using a client credentials flow, it might not be suitable for your environment and can be easily customized.
#
Default Privileges
We will use the term privilege when access to an API resource in Tilores is
required. The default privileges are defined in the cognito
module in
deployment/tilores/main.tf
in the available_scopes
property.
These default privileges are then assigned within the schema to certain API
fields. E.g. the privilege mutation.submit
is used in
schema/mutation.graphls
using the @hasPrivilege
directive.
Each client that is defined in the cognito
module then can be assigned a list
of the allowed requestable privileges. When the client then makes a token
request, it can narrow the requested privileges down even further.
#
Add New Privilege
Let's assume we want to allow only certain clients to request the
myCustomField
from the Record
. First we need to introduce this new
permission by extending the available_scopes
and assigning it to a client in
deployment/tilores/main.tf
:
module "cognito" {
# other properties
available_scopes = [
# other scopes
{
"name": "record.myCustomField"
"description": "allows querying the myCustomField on a record"
}
]
clients = {
# other clients
special_client = {
allowed_scopes = [
"tilores/query.search",
"tilores/record.myCustomField"
]
}
}
}
The name of your newly defined scope can be anything, but we advise you to follow a schema that fits for you. Furthermore, every scope must have a non-empty description.
In the default implementation the allowed_scopes
must be prefixed with
tilores/
.
Now we only need to assign that new privilege to the Record
in
schema/record.graphqls
:
type Record {
id: ID!
myCustomField: String! @hasPrivilege(privilege: "tilores/record.myCustomField")
}
After redeploying, the client can request a new token like this:
curl -u <client_id>:<client_secret> \
--url <token_url> \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials \
--data 'scope=tilores/query.search tilores/tilores/record.myCustomField'
The tilores/query.search
privilege still needs to be requested, because the
search itself is already protected.
It is also possible to assign a privilege to more than one field or even assign it to an object, ensuring that wherever that object is used in your API, that it is properly secured.
type CustomObject @hasPrivilege(privilege: "tilores/customObject") {
someField: String!
}
type Record {
customObjectA: CustomObject!
customObjectB: CustomObject!
}
In that example the client must have the tilores/customObject
privilege for
accessing customObjectA
or customObjectB
.
#
Custom Privileges
We are fully aware, that this is not how scopes were intended to be used. But we believe, that for a machine to machine communication this approach is totally fine. However, if you want to implement your own logic, that is not dependent on scopes, you can change that.
Let's assume we want to load the privileges from somewhere else. In that case
all you have to do is to modify the implementation for the hasPrivilege
directive. The default implementation for that can be found in
graph/directive/hasprivilege.go
.
The PreparePrivilegeContext
function is responsible for enriching the context
with the information that is needed to perform the actual privilege check. That
can be enriching user information or even already enriching the list of
privileges a user has. This function will be called exactly once per GraphQL
request.
The HasPrivilege
function is responsible for verifying that a client has the
required privilege. This function is called multiple times, perhaps even in
parallel, for each object or field that needs to be accessed. If the client has
the required privilege, then this function must invoke the next
resolver.
A very basic implementation might looks something like this:
type contextKey string
var privilegesContext = contextKey("privileges")
func PreparePrivilegeContext(ctx context.Context, request *events.APIGatewayProxyRequest) (context.Context, error) {
// load privileges e.g. for a user extracted from the request
privileges := map[string]bool{
"privilege.a": true,
"privilege.b": false,
}
return context.WithValue(ctx, privilegesContext, privileges), nil
}
func HasPrivilege(ctx context.Context, obj interface{}, next graphql.Resolver, privilege string) (interface{}, error) {
privileges := ctx.Value(privilegesContext).(map[string]bool)
hasPrivilege, ok := privileges[privilege]
if ok && hasPrivilege {
return next(ctx)
}
return nil, fmt.Errorf("access denied, required privilege: %s", privilege)
}
When a user tries to access a resource annotated with
@hasPrivilege(privilege: "privilege.a")
the value for that resource will be
returned. For resources with @hasPrivilege(privilege: "privilege.a")
directives access will be denied.
Obviously, we skipped the part where we extract the user information from the
request
and make a lookup for the actual privileges with an external service
or database.