Ory Keto is a Permissions & Access Control System based on Zanzibar: Google’s Consistent, Global Authorization System.
It uses a graph to evaluate permissions and is therefore very flexible in its structure.
Allowed access is nothing more than an existing path in the graph.
For checking permissions it uses a structure called Relation Tuple.
The string-representation has the following syntax (BNF):
<relation-tuple> ::= <object>'#'relation'@'<subject>
<object> ::= namespace':'object_id
<subject> ::= subject_id | <subject_set>
<subject_set> ::= <object>'#'relation
A valid example would be namespace:object#relation@subjectId
or namespace:object#relation@subjectNamespace:subjectObject#subjectRelation
.
The latter can be difficult to read, therefore you'll often see the following variant: namespace:object#relation@(subjectNamespace:subjectObject#subjectRelation)
Relation Tuples can be verbalized.
The Relation Tuple sharedFiles:a.txt#access@dirs:b#access
can be verbalized as "Anyone with access
to dirs:b
has access
to sharedFiles:a.txt
".
The SDKs of Ory Keto (REST and gRPC) use an object/class representation of the Relation Tuple. This is fine for most cases but not as convenient to use and understand as a simple string.
Because of this I wrote a small Typescript library to parse the string-representation of a Relation Tuple into the needed object-representation.
The general variant, not tied to Keto, is called @nidomiro/relation-tuple-parser
(npm, github).
It supports converting static strings and strings with placeholders.
The latter is especially useful if you want to substitute e.g. the id of the currently logged-in user in the Relation Tuple.
The variant for Keto @nidomiro/relation-tuple-parser-ory-keto
(npm, GitHub) just contains converters. They convert the given Relation Tuple (with, or without replacements) into a form you can use with the Keto SDK.
Let's look into a usage-example.
Example with NestJS
I assume you already know NestJS and are in a freshly created project. Additionally, I assume that Keto is already accessible.
The full source-code of this example can be found here
What we want to accomplish
- We want an easy way to secure a backend route with Keto.
- The person developing a new route shouldn't be responsible for communication with Keto.
The routes permissions should therefore be configurable via a Decorator. The choice fell to the following syntax:
@GuardedBy(({ currentUserId }) => `groups:users#member@${currentUserId}`)
It is concise and flexible at the same time.
We can use it, to secure our getData
route:
@Controller()
export class AppController {
@GuardedBy(({ currentUserId }) => `groups:users#member@${currentUserId}`)
@Get()
getData() {
return 'Access grated!'
}
}
Implementing the Decorator
How is the decorator shown above implemented?
In fact, it is relatively simple. It accepts a string or a generator function as parameter. Then it parses the given Relation Tuples and stores them inside the Handlers' metadata.
This way, the parsing happens at app-startup and not with every request.
The decorator (full source):
export const GuardedBy = (relationTuple: string | RelationTupleStringGenerator<PossibleReplacements>) => {
let valueToSet: GuardedByMetadataType
if (typeof relationTuple === 'string') {
valueToSet = parseRelationTupleWithReplacements(() => relationTuple).unwrapOrThrow()
} else {
valueToSet = parseRelationTupleWithReplacements(relationTuple).unwrapOrThrow()
}
return SetMetadata(GUARDED_BY_METADATA_KEY, valueToSet)
}
Note that both variants are stored as a RelationTupleWithReplacements
. This way we only have to think about one variant during the evaluation.
To statically type the possible replacements we create a type:
export type PossibleReplacements = {
currentUserId: string | 'Unauthorized'
}
Implementing the (App-) Guard
The actual check of the Relation Tuple against Keto happens inside the Guard - KetoGuard
(full source) in our case.
First we need to add the Keto SDK: npm i @ory/keto-grpc-client
and if you need the REST-client npm i @ory/keto-client
.
We use the gRPC variant for checking, because it is a bit faster than the HTTP variant - at least in theory.
What we need to do in the Guard:
- get the value set by the Guard (the
RelationTupleWithReplacements
) - get the needed replacement values. In our case just
currentUserId
. - replace the values in the
RelationTuple
and evaluate the tuple against Keto - Allow / Deny access according to the result of the check.
For step 1 create another method inside guarded-by.decorator.ts
(full source) with the following definition:
export const getGuardingRelationTuple = (
reflector: Reflector,
handler: Parameters<Reflector['get']>[1],
): RelationTupleWithReplacements<PossibleReplacements> | null => {
return (
reflector.get<GuardedByMetadataType, typeof GUARDED_BY_METADATA_KEY>(GUARDED_BY_METADATA_KEY, handler) ?? null
)
}
Now we can retrieve the RelationTupleWithReplacements
if any is set. Otherwise, we get null instead.
For step 2 I chose the "simple demo approach" and just extract a userId
from the URL parameters. Only use this approach for demos, never for production! (source code)
Step 3 is implemented in keto-read-client.service.ts
(full source).
With RelationTupleWithReplacementsConverter.toKetoGrpcCheckRequest
we replace the values inside the RelationTuple and store the result directly in an CheckRequest
, needed by Keto's gRPC client.
The next step is to call the Keto client with our CheckRequest
and return the result. Note that the error-status NOT_FOUND
is handled as "not allowed" (see here).
The last and final step is to use the Result and allow or deny the Request.
Now we need to load some rules into keto and check the result.
For this demo the necessary rules are set in keto during app-startup (full-source):
async function initKeto(ketoWriteService: KetoWriteClientService) {
await ketoWriteService.addRelationTuple(parseRelationTuple('groups:users#member@user1').unwrapOrThrow())
await ketoWriteService.addRelationTuple(parseRelationTuple('groups:users#member@user2').unwrapOrThrow())
}
With these Relation Tuples set, you can now check the result yourself:
- Allowed access: http://localhost:3333/api?userId=user1
- Allowed access: http://localhost:3333/api?userId=user2
- Forbidden access: http://localhost:3333/api?userId=user3
- Forbidden access: http://localhost:3333/api
Allowed Access returns:
Access grated!
Denied Access returns:
{"statusCode":403,"message":"Forbidden resource","error":"Forbidden"}
And that's it. Now you can add more Controller and/or Handler and just use the @GuardedBy
decorator to secure them.
In a real world-example you would also validate the session and fetch the data of the currentUserId
inside the guard. The fetched data can then be attached to the request and therefore be used by the Handler without the need to fetch the data again.
If you have any questions or improvements feel free to comment below.
Top comments (0)