Node.js / Express.js SDK
Topaz authorization SDK for Node.js, as well as Express.js-compatible middleware.
GitHub
This SDK is open source and can be found on GitHub.
This package provides multiple capabilities:
Middleware
- Provides 2 implementations:Authz
andCheck
middlewares that sits on a route, and validates a request to authorize access to that route.Authorizer
- Authorizer Client that provides functions to facilitate comunication with an Authorizer v2 service.DirectoryServiceV3
- Directory Client that provides functions to facilitate comunication with an Directory v3 service.DirectoryServiceV2
- Directory Client that provides functions to facilitate comunication with an Directory v2 service.displayStateMap
: middleware that adds an endpoint for returning the display state map for a service, based on its authorization policy.is
: a function that can be called to make a decision about a user's access to a resource based on a policy.jwtAuthz
(deprecated(replaced byMiddleware.Authz()
)): middleware that sits on a route, and validates a request to authorize access to that route.ds
(deprecated): an object containing theobject
andrelation
functions, which can be called to retrieve an object or relation, respectively, from the directory.
Installation
Using npm:
npm install @aserto/aserto-node
Using yarn:
yarn add @aserto/aserto-node
Usage
Authorizer
Authorizer Client
interface Authorizer {
config: AuthorizerConfig,
};
type AuthorizerConfig = {
authorizerServiceUrl?: string;
tenantId?: string;
authorizerApiKey?: string;
token?: string;
caFile?: string;
insecure?: boolean;
};
Topaz
import { Authorizer } from "@aserto/aserto-node";
const authClient = new Authorizer({
authorizerServiceUrl: "localhost:8282",
caFile: `${process.env.HOME}/.local/share/topaz/certs/grpc-ca.crt`
});
Example:
import {
Authorizer,
identityContext,
policyContext,
policyInstance,
} from "@aserto/aserto-node";
const authClient = new Authorizer(
{
authorizerServiceUrl: "localhost:8282",
caFile: `${process.env.HOME}/.local/share/topaz/certs/grpc-ca.crt`
},
);
authClient
.Is({
identityContext: identityContext(
"rick@the-citadel.com",
"SUB"
),
policyInstance: policyInstance("rebac", "rebac"),
policyContext: policyContext("rebac.check", ["allowed"]),
resourceContext: {
object_type: "group",
object_id: "evil_genius",
relation: "member",
},
})
Methods
// Is
await authClient
.Is({
identityContext: identityContext(
"morty@the-citadel.com",
"SUB"
),
policyInstance: policyInstance("todo", "todo"),
policyContext: policyContext("todoApp.POST.todos", ["allowed"]),
resourceContext: {
ownerID: "fd1614d3-c39a-4781-b7bd-8b96f5a5100d",
},
})
// Query
await authClient
.Is({
identityContext: identityContext(
"morty@the-citadel.com",
"SUB"
),
policyInstance: policyInstance("todo", "todo"),
policyContext: policyContext("todoApp.POST.todos", ["allowed"]),
resourceContext: {
ownerID: "fd1614d3-c39a-4781-b7bd-8b96f5a5100d",
},
query: "x = data",
})
// DecisionTree
await authClient
.DecisionTree({
identityContext: identityContext(
"morty@the-citadel.com",
"SUB"
),
policyInstance: policyInstance("todo", "todo"),
policyContext: policyContext("todoApp.POST.todos", ["allowed"]),
resourceContext: {
ownerID: "fd1614d3-c39a-4781-b7bd-8b96f5a5100d",
},
})
// ListPolicies
await authClient
.ListPolicies({ policyInstance: policyInstance("todo", "todo") })
Middleware
When authorization middleware is configured and attached to a server, it examines incoming requests, extracts authorization parameters like the caller's identity, calls the Aserto authorizers, and rejects messages if their access is denied.
failWithError
: When set to true
, will forward errors to next
instead of ending the response directly.
interface Middleware {
client: Authorizer;
policy: Policy;
resourceMapper?: ResourceMapper;
identityMapper?: IdentityMapper;
policyMapper?: PolicyMapper;
failWithError?: boolean;
}
type Policy = {
root: string;
name?: string;
instanceLabel?: string;
decision?: string;
path?: string;
};
type CheckOptions = {
object?: ObjectMapper;
objectId?: string | StringMapper;
objectType?: string | StringMapper;
relation?: string | StringMapper;
subjectType?: string;
};
type ResourceMapper =
| ResourceContext
| ((req?: Request) => Promise<ResourceContext>);
type IdentityMapper = (req?: Request) => Promise<IdentityContext>;
type PolicyMapper = (req?: Request) => Promise<PolicyContext>;
Methods
function Authz()
function Check(options: CheckOptions)
Examples
const app: express.Application = express();
// Standard REST
const restMw = new Middleware({
client: client,
policy: {
name: 'todo',
instanceLabel: 'todo',
root: 'todoApp',
},
resourceMapper: async (req: express.Request) => {
if (!req.params?.id) {
return {};
}
const todo = await store.get(req.params.id);
return { ownerID: todo.OwnerID };
},
})
app.get("/todos", checkJwt, restMw.Authz(), server.list.bind(server));
app.post("/todos", checkJwt, restMw.Authz(), server.create.bind(server));
app.put("/todos/:id", checkJwt, restMw.Authz(), server.update.bind(server));
// Check
const rebacMw = new Middleware({
client: authClient,
policy: {
name: 'policy-rebac',
instanceLabel: 'policy-rebac',
root: 'rebac',
}
})
// Only users that are in the `evil_genius` group are allowed to delete todos.
app.delete("/todos/:id", checkJwt, rebacMw.Check({
objectType: "group",
objectId: "evil_genius"
relation: "member",
}))
Mappers
Identity
To determine the identity of the user, the middleware can be configured to use a JWT token or a claim using the IdentityMapper
.
// use the identity type sub
import { SubIdentityMapper } from "@aserto/aserto-node";
const restMw = new Middleware({
client: authClient,
policy: policy,
identityMapper: SubIdentityMapper,
})
// use the jwt type sub from a custom header
import { JWTIdentityMapper } from "@aserto/aserto-node";
const restMw = new Middleware({
client: authClient,
policy: policy,
identityMapper: JWTIdentityMapper("my-header");,
})
// use the manual identity type
import { ManualIdentityMapper } from "@aserto/aserto-node";
const restMw = new Middleware({
client: authClient,
policy: policy,
identityMapper: ManualIdentityMapper("my-identity");,
})
The whole identity resolution can be overwritten by providing a custom function.
// needs to return an IdentityContext
import { identityContext } from "@aserto/aserto-node";
const restMw = new Middleware({
client: authClient,
policy: policy,
identityMapper: async () => {
return identityContext('test', 'SUB')
},
})
Policy
The authorization policy's ID and the decision to be evaluated are specified when creating authorization Middleware, but the policy path is often derived from the URL or method being called.
By default, the policy path is derived from the URL path.
To provide custom logic, use a PolicyMapper. For example:
// needs to return an IdentityContext
import { identityContext } from "@aserto/aserto-node";
const restMw = new Middleware({
client: authClient,
policy: policy,
policyMapper: async () => {
return policyContext('path', ['decision'])
}
})
Resource
A resource can be any structured data that the authorization policy uses to evaluate decisions. By default, the request params are included in the ResourceContext.
This behavior can be overwritten by providing a custom function:
const restMw = new Middleware({
client: authClient,
policy: policy,
resourceMapper: async () => {
return { customKey: "customValue" };
},
})
Mappers
Resource
// provides a custom resource context,
type ResourceMapper =
| ResourceContext
| ((req?: Request) => Promise<ResourceContext>);
// examples
async (req: Request) => { return { customKey: req.params.id } };
// or just a plain resource context
{ customKey: "customValue" }
Identity
type IdentityMapper = (req?: Request) => Promise<IdentityContext>;
// You can also use the built-in policyContext function to create a identity context and pass it as the mapper response
const identityContext = (value: string, type: keyof typeof IdentityType) => {
IdentityType {
/**
* Unknown, value not set, requests will fail with identity type not set error.
*
* @generated from enum value: IDENTITY_TYPE_UNKNOWN = 0;
*/
UNKNOWN = 0,
/**
* None, no explicit identity context set, equals anonymous.
*
* @generated from enum value: IDENTITY_TYPE_NONE = 1;
*/
NONE = 1,
/**
* Sub(ject), identity field contains an oAUTH subject.
*
* @generated from enum value: IDENTITY_TYPE_SUB = 2;
*/
SUB = 2,
/**
* JWT, identity field contains a JWT access token.
*
* @generated from enum value: IDENTITY_TYPE_JWT = 3;
*/
JWT = 3,
/**
* Manual, propagates thw identity field as-is, without validation, into the input object.
*
* @generated from enum value: IDENTITY_TYPE_MANUAL = 4;
*/
MANUAL = 4
}
// example
identityContext("morty@the-citadel.com", "SUB")
Policy
type PolicyMapper = (req?: Request) => Promise<PolicyContext>;
// You can also use the built-in policyContext function to create a policy context and pass it as the mapper response
policyContext = (policyPath: string, decisionsList: Array<string> = ["allowed"])
// Example
policyContext("todoApp.POST.todos", ["allowed"])
Directory
The Directory APIs can be used to get, set or delete object instances, relation instances and manifests. They can also be used to check whether a user has a permission or relation on an object instance.
Directory Client
type ServiceConfig = {
url?: string;
tenantId?: string;
apiKey?: string;
caFile?: string;
rejectUnauthorized?: boolean;
};
type DirectoryV3Config = ServiceConfig & {
reader?: ServiceConfig;
writer?: ServiceConfig;
importer?: ServiceConfig;
exporter?: ServiceConfig;
model?: ServiceConfig;
};
You can initialize a directory client as follows:
import { DirectoryServiceV3 } from "@aserto/aserto-node";
const directoryClient = DirectoryServiceV3({
url: 'localhost:9292',
caFile: `${process.env.HOME}/.local/share/topaz/certs/grpc-ca.crt`
});
- `url`: hostname:port of directory service (_required_)
- `apiKey`: API key for directory service (_required_ if using hosted directory)
- `tenantId`: Aserto tenant ID (_required_ if using hosted directory)
- `caFile`: Path to the directory CA file. (optional)
- `rejectUnauthorized`: reject clients with invalid certificates. Defaults to `true`.
- `reader`: ServiceConfig for the reader client(optional)
- `writer`: ServiceConfig for the writer client(option)
- `importer`: ServiceConfig for the importer client(option)
- `exporter`: ServiceConfig for the exporter client(option)
- `model`: ServiceConfig for the model client(option)
Example
Define a writer client that uses the same credentials but connects to localhost:9393. All other services will have the default configuration
import { DirectoryServiceV3 } from "@aserto/aserto-node";
const directoryClient = DirectoryServiceV3({
url: 'localhost:9292',
writer: {
url: 'localhost:9393'
}
});
Getting objects and relations
'object' function
object({ objectType: "type-name", objectId: "object-id" })
:
Get an object instance with the type type-name
and the id object-id
. For example:
const user = await directoryClient.object({ objectType: 'user', objectId: 'euang@acmecorp.com' });
// Handle a specific Directory Error
import { NotFoundError } from "@aserto/aserto-node"
try {
directoryClient.object({
objectType: "user",
objectId: "euang@acmecorp.com",
});
} catch (error) {
if (error instanceof NotFoundError) {
// handle the error
}
throw error;
}
'relation' function
relation({
subjectType: 'subject-type',
subjectId: 'subject-id',
relation: 'relation-name',
objectType: 'object-type',
objectId: 'object-id',
})
Get a relation of a certain type between as subject and an object. For example:
const identity = 'euang@acmecorp.com';
const relation = await directoryClient.relation({
subjectType: 'user',
subjectId: 'euang@acmecorp.com',
relation: 'identifier',
objectType: 'identity'
objectId: identity
});
'relations' function
relations({
subjectType: 'subject-type',
relation: 'relation-name',
objectType: 'object-type',
objectId: 'object-id',
})
Get an array of relations of a certain type for an object instance. For example:
const identity = 'euang@acmecorp.com';
const relations = await directoryClient.relation({
subjectType: 'user',
relation: 'identifier',
objectType: 'identity'
objectId: identity
});
Setting objects and relations
'setObject' function
setObject({ object: $Object })
:
Create an object instance with the specified fields. For example:
const user = await directoryClient.setObject(
{
object: {
type: "user",
id: "test-object",
properties: {
displayName: "test object"
}
}
}
);
'setRelation' function
setRelation({ relation: Relation })
:
Create a relation with a specified name between two objects. For example:
const relation = await directoryClient.setRelation({
subjectId: 'subjectId',
subjectType: 'subjectType',
relation: 'relationName',
objectType: 'objectType',
objectId: 'objectId',
});
'deleteObject' function
deleteObject({ objectType: "type-name", objectId: "object-id", withRelations: false })
:
Deletes an object instance with the specified type and key. For example:
await directoryClient.deleteObject({ objectType: 'user', objectId: 'euang@acmecorp.com' });
'deleteRelation' function
deleteRelation({ objectType: string, objectId: string, relation: string, subjectType: string, subjectId: string, subjectRelation: string })
:
Delete a relation:
await directoryClient.deleteRelation({
subjectType: 'subjectType',
subjectId: 'subjectId',
relation: 'relationName',
objectType: 'objectType',
objectId: 'objectId',
});
Checking permissions and relations
You can evaluate graph queries over the directory, to determine whether a subject (e.g. user) has a permission or a relation to an object instance.
'checkPermission' function
checkPermission({ objectType: string, objectId: string, permission: string, subjectType: string, subjectId: string, trace: boolean })
:
Check that an user
object with the key euang@acmecorp.com
has the read
permission in the admin
group:
const check = await directoryClient.checkPermission({
subjectId: 'euang@acmecorp.com',
subjectType: 'user',
permission: 'read',
objectType: 'group',
objectId: 'admin',
});
'checkRelation' function
checkRelation({ objectType: string, objectId: string, relation: string, subjectType: string, subjectId: string, trace: boolean })
:
Check that euang@acmecorp.com
has an identifier
relation to an object with key euang@acmecorp.com
and type identity
:
const check = directoryClient.checkRelation({
subjectId: 'euang@acmecorp.com',
subjectType: 'user',
name: 'identifier',
objectType: 'identity',
objectId: 'euang@acmecorp.com',
});
Example
const identity = 'euang@acmecorp.com';
const relation = await directoryClient.relation(
{
subjectType: 'user',
objectType: 'identity',
objectId: identity,
relation: 'identifier',
subjectId: 'euang@acmecorp.com'
}
);
if (!relation) {
throw new Error(`No relations found for identity ${identity}`)
};
const user = await directoryClient.object(
{ objectId: relation.subjectId, objectType: relation.subjectType }
);
Manifest
You can get, set, or delete the manifest
'getManifest' function
await directoryClient.getManifest();
'setManifest' function
await directoryClient.setManifest(`
# yaml-language-server: $schema=https://www.topaz.sh/schema/manifest.json
---
### model ###
model:
version: 3
### object type definitions ###
types:
### display_name: User ###
user:
relations:
### display_name: user#manager ###
manager: user
### display_name: Identity ###
identity:
relations:
### display_name: identity#identifier ###
identifier: user
### display_name: Group ###
group:
relations:
### display_name: group#member ###
member: user
permissions:
read: member
`);
'deleteManifest' function
await directoryClient.deleteManifest();
Import
import { ImportMsgCase, ImportOpCode, objectPropertiesAsStruct } from "@aserto/aserto-node"
const importRequest = createAsyncIterable([
{
opCode: ImportOpCode.SET,
msg: {
case: ImportMsgCase.OBJECT,
value: {
id: "import-user",
type: "user",
properties: objectPropertiesAsStruct({ foo: "bar" }),
displayName: "name1",
},
},
},
{
opCode: ImportOpCode.SET,
msg: {
case: ImportMsgCase.OBJECT,
value: {
id: "import-group",
type: "group",
properties: {},
displayName: "name2",
},
},
},
{
opCode: ImportOpCode.SET,
msg: {
case: ImportMsgCase.RELATION,
value: {
subjectId: "import-user",
subjectType: "user",
objectId: "import-group",
objectType: "group",
relation: "member",
},
},
},
]);
const resp = await directoryClient.import(importRequest);
await (readAsyncIterable(resp))
Export
const response = await readAsyncIterable(
await directoryClient.export({ options: "all" })
)
displayStateMap middleware
Use the displayStateMap middleware to set up an endpoint that returns the display state map to a caller. The endpoint is named __displaystatemap
by default, but can be overridden in options
.
const { displayStateMap } = require('@aserto/aserto-node');
const options = {
authorizerServiceUrl: 'localhost:8282', // required - must pass a valid host:port
policyRoot: 'policy' // required - must be a string representing the policy root (the first component of the policy module name)
};
app.use(displayStateMap(options));
arguments
displayStateMap(options)
options argument
authorizerServiceUrl
: hostname:port of authorizer service (required)policyRoot
: Policy root (required)instanceName
: instance name (required if using hosted authorizer)instanceLabel
: instance label (required if using hosted authorizer)authorizerApiKey
: API key for authorizer service (required if using hosted authorizer)tenantId
: Aserto tenant ID (required if using hosted authorizer)caFile
: location on the filesystem of the CA certificate that signed the Aserto authorizer self-signed certificate. See the "Certificates" section for more information.disableTlsValidation
: ignore TLS certificate validation when creating a TLS connection to the authorizer. Defaults to false.endpointPath
: display state map endpoint path, defaults to/__displaystatemap
.failWithError
: When set totrue
, will forward errors tonext
instead of ending the response directly. Defaults tofalse
.useAuthorizationHeader
: When set totrue
, will forward the Authorization header to the authorizer. The authorizer will crack open the JWT and use that as the identity context. Defaults totrue
.identityHeader
: the name of the header from which to extract theidentity
field to pass into the displayStateMap call. This only happens ifuseAuthorizationHeader
is false. Defaults to 'identity'.customUserKey
: The property name to check for the subject key. By default, permissions are checked againstreq.user
, but you can change it to bereq.myCustomUserKey
with this option. Defaults touser
.customSubjectKey
: The property name to check for the subject. By default, permissions are checked againstuser.sub
, but you can change it to beuser.myCustomSubjectKey
with this option. Defaults tosub
.
'is' function
is
provides an explicit mechanism for calling the Aserto authorizer.
Use the is
function to call the authorizer with a decision
, policy, and resource, and get a boolean true
or false
response. The decision
is a named value in the policy: the string allowed
is used by convention. Examples: is('allowed')
, is('enabled')
, is('visible')
, etc.
const { is } = require('@aserto/aserto-node');
const options = {
authorizerServiceUrl: 'localhost:8282', // required - must pass a valid host:port
policyRoot: 'policy' // required - must be a string representing the policy root (the first component of the policy module name)
};
app.get('/users/:id', async function(req, res) {
try {
const allowed = await is('allowed', req, options);
if (allowed) {
...
} else {
res.status(403).send("Unauthorized");
}
} catch (e) {
res.status(500).send(e.message);
}
});
arguments
is(decision, req, options[, packageName[, resourceMap]])
:
decision
: a string representing the name of the decision - typicallyallowed
(required)req
: Express request object (required)options
: a javascript map containing at least{ authorizerServiceUrl, policyRoot }
as well asauthorizerApiKey
andtenantId
for the hosted authorizer (required)packageName
: a string representing the package name for the the policy (optional)resourceMap
: a map of key/value pairs to use as the resource context for evaluation (optional)
decision argument
This is simply a string that is correlates to a decision referenced in the policy: for example, allowed
, enabled
, etc.
req argument
The Express request object.
options argument
authorizerServiceUrl
: hostname:port of authorizer service (required)policyRoot
: Policy root (required)instanceName
: instance name (required if using hosted authorizer)instanceLabel
: instance label (required if using hosted authorizer)authorizerApiKey
: API key for authorizer service (required if using hosted authorizer)tenantId
: Aserto tenant ID (required if using hosted authorizer)caFile
: location on the filesystem of the CA certificate that signed the Aserto authorizer self-signed certificate. See the "Certificates" section for more information.disableTlsValidation
: ignore TLS certificate validation when creating a TLS connection to the authorizer. Defaults to false.useAuthorizationHeader
: When set totrue
, will forward the Authorization header to the authorizer. The authorizer will crack open the JWT and use that as the identity context. Defaults totrue
.identityHeader
: the name of the header from which to extract theidentity
field to pass into theauthorize
call. This only happens ifuseAuthorizationHeader
is false. Defaults to 'identity'.customUserKey
: The property name to check for the subject key. By default, permissions are checked againstreq.user
, but you can change it to bereq.myCustomUserKey
with this option. Defaults touser
.customSubjectKey
: The property name to check for the subject. By default, permissions are checked againstuser.sub
, but you can change it to beuser.myCustomSubjectKey
with this option. Defaults tosub
.
packageName argument
By convention, Aserto policy package names are of the form policyRoot.METHOD.path
. By default, the package name will be inferred from the policy name, HTTP method, and route path:
GET /api/users
-->policyRoot.GET.api.users
POST /api/users/:id
-->policyRoot.POST.api.users.__id
Passing in the packageName
parameter into the is()
function will override this behavior.
resourceMapper argument
By default, the resource map will be req.params. For example, if the route path is /api/users/:id
, the resource will be { 'id': 'value-of-id' }
.
Passing in the resourceMapper
parameter into the is()
function will override this behavior.
Certificates
The Topaz authorizer exposes SSL-only endpoints. In order for a Node.js policy to properly communicate with the authorizer, TLS certificates must be verified.
In order for the aserto-node
package to perform the TLS handshake, it needs to verify the TLS certificate of the Topaz authorizer using the certificate of the CA that signed it - which was placed in $HOME/.local/share/topaz/certs/grpc-ca.crt
. Therefore, in order for this middleware to work successfully, either the caFile
must be set to the correct path for the CA cert file, or the disableTlsValidation
flag must be set to true
.
Furthermore, when packaging a policy for deployment (e.g. in a Docker container) which uses aserto-node
to communicate with an authorizer that has a self-signed TLS certificate, you must copy this CA certificate into the container as part of the Docker build (typically performed in the Dockerfile). When you do that, you'll need to override the caFile
option that is passed into any of the API calls defined above with the location of this cert file.
Alternately, to ignore TLS certificate validation when creating a TLS connection to the authorizer, you can set the disableTlsValidation
option to true
and avoid TLS certificate validation. This option is not recommended for production.