From f9e7e48a4a4d27906b972f76f7725413bdba3023 Mon Sep 17 00:00:00 2001 From: Magnus Ma Date: Fri, 26 Aug 2022 12:36:37 -0700 Subject: [PATCH] refactoring (#9) Signed-off-by: Johan Fylling --- README.md | 36 +++- package-lock.json | 4 +- package.json | 4 +- src/api-client.js | 23 +-- src/aws.js | 7 +- src/constants.js | 6 + src/errors.js | 12 -- src/helpers.js | 74 ++++---- src/rbac-management.js | 70 ++++--- src/run-sdk.js | 179 ++++++++---------- .../api-client.test.js | 0 spec/aws.spec.js => tests/aws.test.js | 0 {spec => tests}/helpers.js | 0 spec/rbac.spec.js => tests/rbac.test.js | 19 +- spec/run-sdk.spec.js => tests/run-sdk.test.js | 44 ++--- {spec => tests}/support/jasmine.json | 7 +- 16 files changed, 226 insertions(+), 259 deletions(-) create mode 100644 src/constants.js rename spec/api-client.spec.js => tests/api-client.test.js (100%) rename spec/aws.spec.js => tests/aws.test.js (100%) rename {spec => tests}/helpers.js (100%) rename spec/rbac.spec.js => tests/rbac.test.js (97%) rename spec/run-sdk.spec.js => tests/run-sdk.test.js (96%) rename {spec => tests}/support/jasmine.json (52%) diff --git a/README.md b/README.md index 231f031..7eb829b 100644 --- a/README.md +++ b/README.md @@ -25,16 +25,12 @@ or in your package.json: // Options are pulled from the environment import StyraRun from "styra-run" -const options = { - https: process.env.RUN_URL - token: process.env.RUN_TOKEN -} -const client = StyraRun.New(options) +const client = StyraRun(process.env.RUN_URL, process.env.RUN_TOKEN) ``` ### Query -Makes a policy rule query, returning the result dictionary: `{"result": any}` +Makes a policy rule query, returning the result object: `{"result": any}` ```javascript const input = {...} @@ -56,7 +52,7 @@ client.query('foo/bar/allowed', input) ### Check -Makes a policy rule query, returning `true` if the result dictionary equals `{"result": true}`, `false` otherwise. +Makes a policy rule query, returning `true` if the result object equals `{"result": true}`, `false` otherwise. ```javascript const input = {...} @@ -98,7 +94,7 @@ However, the default predicate can be overridden: ```javascript const input = {...} -// Predicate that requires the policy rule to return a dictionary containing a `{"role": "admin"}` entry. +// Predicate that requires the policy rule to return a object containing a `{"role": "admin"}` entry. const myPredicate = (response) => { return response?.result?.role === 'admin' } @@ -203,7 +199,7 @@ import {Router} from 'express' const router = Router() -router.post('/authz', client.proxy(async (req, res, path, input) => { +router.post('/authz', client.proxy(onProxy: async (req, res, path, input) => { return { ...input, subject: req.subject, // Add subject from session @@ -215,7 +211,7 @@ export default { } ``` -The `proxy(onProxy)` function takes a callback function as argument, and returns a request handling function. The provided callback function takes as arguments the incoming HTTP `Request`, the outgoing HTTP `Response`, the `path` of the queried policy, and the, possibly incomplete, `input` document for the query. The callback must return an updated version of the provided `input` document. +The `proxy()` function takes a callback function as argument, and returns a request handling function. The provided callback function takes as arguments the incoming HTTP `Request`, the outgoing HTTP `Response`, the `path` of the queried policy, and the, possibly incomplete, `input` document for the query. The callback must return an updated version of the provided `input` document. ### RBAC Management API @@ -245,3 +241,23 @@ The RBAC API exposes the following endpoints: | `/roles` | `GET` | Get a list of available roles. Returns a json list of strings; e.g. `["ADMIN","VIEWER"]`. | | `/user_bindings` | `GET` | Get user to role bindings. Returns a list of dictionaries, where each entry has two attributes: the `id` of the user; and their `roles`, as a list of role string identifiers; e.g. `[{"id": "alice", "roles": ["ADMIN"]}, {"id": "bob", "roles": ["VIEWER"]}]`. `GET` requests to this endpoint can include the `page` query attribute; an `integer` indicating what page of bindings to enumerate. The page size is defined when creating the API request handler on the server by calling `manageRbac`. | | `/user_bindings/` | `PUT` | Sets the role bindings of a user, where the `` path component is the ID of the user. The request body must be a json list string role identifiers; e.g. `['ADMIN', 'VIEWER']`. + +/* + +it would be nice to provide an example of a middleware to authorize protected endpoints +middlewares are Express specific I believe +example: + +router.use(async function hasManagePermissions (req, res, next) { + const isAllowed = await client.check(...); + + if (isAllowed) { + next() + } else { + res.sendStatus(401) + } +}); + +should also provide the min version of Node.js this SDK supports? +Node 18 is current and will be active LTS https://nodejs.org/en/about/releases/ when we probably release this SDK +*/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1c4a507..e85475b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "styra-run-sdk-node", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "styra-run-sdk-node", - "version": "0.0.1", + "version": "0.0.2", "license": "Apache-2.0", "devDependencies": { "jasmine": "^4.2.1", diff --git a/package.json b/package.json index 5bc175a..bbcfc6c 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "styra-run-sdk-node", "version": "0.0.2", - "description": "The Styra Run SDK for node.js", + "description": "Styra Run Node.js SDK", "author": "Styra Inc.", "license": "Apache-2.0", "type": "module", "main": "./src/run-sdk.js", "scripts": { - "test": "jasmine" + "test": "jasmine --config=tests/support/jasmine.json" }, "homepage": "https://github.com/StyraInc/styra-run-sdk-node#readme", "repository": { diff --git a/src/api-client.js b/src/api-client.js index 6b1aac3..1f4dd82 100644 --- a/src/api-client.js +++ b/src/api-client.js @@ -2,6 +2,7 @@ import Url from "url" import { AwsClient } from "./aws.js" import { StyraRunError } from "./errors.js" import { httpRequest, urlToRequestOptions } from "./helpers.js" +import { API_CLIENT_MAX_RETRIES, AWS_IMDSV2_URL } from "./constants.js" // TODO: Re-fetch gateway list after some time (?) // TODO: Make it configurable to cap retry limit at gateway list size (?) @@ -9,7 +10,7 @@ import { httpRequest, urlToRequestOptions } from "./helpers.js" export class ApiClient { constructor(url, token, { organizeGateways = makeOrganizeGatewaysCallback(), - maxRetries = 3 + maxRetries = API_CLIENT_MAX_RETRIES } = {}) { this.url = Url.parse(url) this.token = token @@ -19,7 +20,7 @@ export class ApiClient { async get(path) { return await this.requestWithRetry({ - path: path, + path, method: 'GET', headers: { 'authorization': `bearer ${this.token}` @@ -29,7 +30,7 @@ export class ApiClient { async put(path, data) { return await this.requestWithRetry({ - path: path, + path, method: 'PUT', headers: { 'content-type': 'application/json', @@ -40,7 +41,7 @@ export class ApiClient { async post(path, data) { return await this.requestWithRetry({ - path: path, + path, method: 'POST', headers: { 'content-type': 'application/json', @@ -51,7 +52,7 @@ export class ApiClient { async delete(path) { return await this.requestWithRetry({ - path: path, + path, method: 'DELETE', headers: { 'authorization': `bearer ${this.token}` @@ -120,7 +121,7 @@ export class ApiClient { return undefined } }) - .filter((entry) => entry !== undefined) + .filter((entry) => !!entry) @@ -133,24 +134,24 @@ export class ApiClient { } } -export function makeOrganizeGatewaysCallback(metadataServiceUrl = 'http://169.254.169.254:80') { +export function makeOrganizeGatewaysCallback(metadataServiceUrl = AWS_IMDSV2_URL) { const awsClient = new AwsClient(metadataServiceUrl) return async (gateways) => { // NOTE: We assume zone-id:s are unique across regions const {region, zoneId} = await awsClient.getMetadata() - if (region === undefined && zoneId === undefined) { + if (!region && !zoneId) { return gateways } const copy = [...gateways] return copy.sort((a, b) => { - if (zoneId !== undefined && a.aws?.zone_id === zoneId) { + if (zoneId && a.aws?.zone_id === zoneId) { // always sort matching zone-id higher return -1 } - if (region !== undefined && a.aws?.region === region) { + if (region && a.aws?.region === region) { // only sort a higher if b doesn't have a matching zone-id - return (zoneId !== undefined && b.aws?.zone_id === zoneId) ? 1 : -1 + return (zoneId && b.aws?.zone_id === zoneId) ? 1 : -1 } return 0 }) diff --git a/src/aws.js b/src/aws.js index f26c5f7..145027e 100644 --- a/src/aws.js +++ b/src/aws.js @@ -1,6 +1,7 @@ import Url from "url" import { StyraRunHttpError } from "./errors.js" import { httpRequest, joinPath, urlToRequestOptions } from "./helpers.js" +import { AWS_IMDSV2_URL, AWS_IMDSV2_TOKEN_TTL } from "./constants.js" const TOKEN_PATH = '/latest/api/token' const METADATA_PATH = '/latest/meta-data' @@ -10,7 +11,8 @@ function is401Error(err) { } export class AwsClient { - constructor(url = 'http://169.254.169.254:80', tokenTtl = 21600) { + // separate file for all constant configs? + constructor(url = AWS_IMDSV2_URL, tokenTtl = AWS_IMDSV2_TOKEN_TTL) { this.reqOpts = urlToRequestOptions(Url.parse(url)) this.tokenTtl = tokenTtl } @@ -39,7 +41,7 @@ export class AwsClient { async getToken() { if (this.tokenIsUnsupported) { - return undefined + return } if (this.token) { @@ -60,7 +62,6 @@ export class AwsClient { return this.token } catch (err) { this.tokenIsUnsupported = true - return undefined } } diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..bcf4fb5 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,6 @@ +// TODO: Organize into enums? +export const BATCH_MAX_ITEMS = 20 +export const AWS_IMDSV2_URL = 'http://169.254.169.254:80' +export const AWS_IMDSV2_TOKEN_TTL = 21600 +export const API_CLIENT_MAX_RETRIES = 3 + diff --git a/src/errors.js b/src/errors.js index e224907..5d71377 100644 --- a/src/errors.js +++ b/src/errors.js @@ -9,10 +9,6 @@ export class StyraRunError extends Error { this.name = "StyraRunError" this.cause = cause } - - isStyraRunError() { - return true - } } /** @@ -23,10 +19,6 @@ export class StyraRunAssertionError extends StyraRunError { super(NOT_ALLOWED) this.name = "StyraRunAssertionError" } - - isStyraRunAssertionError() { - return true - } } /** @@ -40,10 +32,6 @@ export class StyraRunHttpError extends StyraRunError { this.body = body } - isStyraRunHttpError() { - return true - } - isNotFoundStatus() { return this.statusCode === 404 } diff --git a/src/helpers.js b/src/helpers.js index 77baaa5..7598b8d 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -9,8 +9,9 @@ export async function httpRequest(options, data = undefined) { return new Promise((resolve, reject) => { try { const client = options.https === false ? Http : Https - const req = client.request(options, async (response) => { - let body = await getBody(response); + + const request = client.request(options, async (response) => { + const body = await getBody(response); switch (response.statusCode) { case OK: resolve(body); @@ -22,10 +23,11 @@ export async function httpRequest(options, data = undefined) { }).on('error', (err) => { reject(new StyraRunError('Failed to send request', err)) }) + if (data) { - req.write(data); + request.write(data); } - req.end() + request.end() } catch (err) { reject(new StyraRunError('Failed to send request', err)) } @@ -33,15 +35,17 @@ export async function httpRequest(options, data = undefined) { } export function getBody(stream) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { if (stream.body) { // express compatibility resolve(stream.body) } else { - var body = '' + let body = '' + stream.on('data', (data) => { body += data }) + stream.on('end', () => { resolve(body) }) @@ -51,6 +55,7 @@ export function getBody(stream) { export function toJson(data) { const json = JSON.stringify(data); + if (json) { return json } else { @@ -58,61 +63,54 @@ export function toJson(data) { } } -export function fromJson(val) { - if (typeof val === 'object') { - return val +export function fromJson(value) { + if (typeof value === 'object') { + return value } + try { - return JSON.parse(val) + return JSON.parse(value) } catch (err) { throw new Error('Invalid JSON', {cause: err}) } } -export function pathEndsWith(url, requiredTail) { - const components = url.pathname.split('/') +export function pathEndsWith(url, tail) { + const segments = url.pathname.split('/') .filter((e) => e.length > 0) - if (requiredTail.length > components.length) { + + if (tail.length > segments.length) { return false } - const tailStart = components.length - requiredTail.length - const pathTail = components.slice(tailStart) - - for (let i = 0; i < requiredTail.length; i++) { - const required = requiredTail[i] - if (required !== '*' && required !== pathTail[i]) { - return false - } - } + const tailStart = segments.length - tail.length + const pathTail = segments.slice(tailStart) - return true + return !tail.some((required, index) => required !== '*' && required !== pathTail[index]) } -export function parsePathParameters(url, expectedTail) { - const components = url.pathname.split('/') - if (expectedTail.length > components.length) { +export function parsePathParameters(url, tail) { + const segments = url.pathname.split('/') + if (tail.length > segments.length) { return {} } - const tailStart = components.length - expectedTail.length - const pathTail = components.slice(tailStart) - const parameters = {} - - for (let i = 0; i < expectedTail.length; i++) { - const expected = expectedTail[i] + const tailStart = segments.length - tail.length + const pathTail = segments.slice(tailStart) + + return tail.reduce((parameters, expected, index) => { if (expected.startsWith(':')) { - parameters[expected.slice(1)] = pathTail[i] + parameters[expected.slice(1)] = pathTail[index] } - } - return parameters + return parameters + }, {}) } -export function joinPath(...components) { - const filtered = components.filter((comp) => comp !== undefined) +export function joinPath(...args) { + const filtered = args.filter((arg) => arg !== undefined) const path = Path.join(...filtered) - return path.startsWith('/') ? path : '/' + path + return path.startsWith('/') ? path : `/${path}` } export function urlToRequestOptions(url, path = undefined) { diff --git a/src/rbac-management.js b/src/rbac-management.js index 842c1a0..8addf80 100644 --- a/src/rbac-management.js +++ b/src/rbac-management.js @@ -1,11 +1,22 @@ import Url from "url" -import {getBody, toJson, fromJson, pathEndsWith, parsePathParameters} from "./helpers.js" -import {StyraRunError, StyraRunHttpError} from "./errors.js" -import path from "path" +import { getBody, toJson, fromJson, pathEndsWith, parsePathParameters } from "./helpers.js" +import { StyraRunError } from "./errors.js" + +const EventType = { + RBAC: 'rbac', + GET_ROLES: 'rbac-get-roles', + GET_BINDINGS: 'rbac-get-bindings', + SET_BINDING: 'rbac-set-binding' +} + +const RbacPath = { + AUTHZ: 'rbac/manage/allow', + ROLES: 'rbac/roles', + BINDINGS_PREFIX: 'rbac/user_bindings' +} -const AUTHZ_PATH = 'rbac/manage/allow' -const ROLES_PATH = 'rbac/roles' -const BINDINGS_PATH_PREFIX = 'rbac/user_bindings' +const JSON_CONTENT_TYPE = {'Content-Type': 'application/json'} +const TEXT_CONTENT_TYPE = {'Content-Type': 'text/plain'} export class Manager { constructor(styraRunClient, createInput, getUsers, onSetBinding, pageSize) { @@ -17,45 +28,41 @@ export class Manager { } async getRoles(input) { - await this.styraRunClient.assert(AUTHZ_PATH, input) + await this.styraRunClient.assert(RbacPath.AUTHZ, input) - const roles = await this.styraRunClient.query(ROLES_PATH, input) + const roles = await this.styraRunClient.query(RbacPath.ROLES, input) .then(resp => resp.result) - this.styraRunClient.signalEvent('rbac-get-roles', {input, roles}) + this.styraRunClient.signalEvent(EventType.GET_ROLES, {input, roles}) return roles } async getBindings(input, page) { - await this.styraRunClient.assert(AUTHZ_PATH, input) + await this.styraRunClient.assert(RbacPath.AUTHZ, input) - let offset = 0 - let limit = this.pageSize - if (page) { - offset = Math.max(page - 1, 0) * this.pageSize - } - const users = this.getUsers(offset, limit) + const offset = Math.max((page ?? 0) - 1, 0) * this.pageSize + const users = this.getUsers(offset, this.pageSize) const bindings = await Promise.all(users.map(async (id) => { - const roles = await this.styraRunClient.getData(`${BINDINGS_PATH_PREFIX}/${input.tenant}/${id}`, []) + const roles = await this.styraRunClient.getData(`${RbacPath.BINDINGS_PREFIX}/${input.tenant}/${id}`, []) .then(resp => resp.result) return {id, roles} })) - this.styraRunClient.signalEvent('rbac-get-bindings', {input, bindings}) + this.styraRunClient.signalEvent(EventType.GET_BINDINGS, {input, bindings}) return bindings } async setBinding(binding, input) { - await this.styraRunClient.assert(AUTHZ_PATH, input) + await this.styraRunClient.assert(RbacPath.AUTHZ, input) try { - await this.styraRunClient.putData(`${BINDINGS_PATH_PREFIX}/${input.tenant}/${binding.id}`, binding.roles ?? []) - this.styraRunClient.signalEvent('rbac-set-binding', {binding, input}) + await this.styraRunClient.putData(`${RbacPath.BINDINGS_PREFIX}/${input.tenant}/${binding.id}`, binding.roles ?? []) + this.styraRunClient.signalEvent(EventType.SET_BINDING, {binding, input}) } catch (err) { - this.styraRunClient.signalEvent('rbac-set-binding', {binding, input, err}) + this.styraRunClient.signalEvent(EventType.SET_BINDING, {binding, input, err}) throw new BackendError('Bunding update failed', cause) } } @@ -70,40 +77,44 @@ export class Manager { responseBody = await this.getRoles(input) } else if (request.method === 'GET' && pathEndsWith(url, ['user_bindings'])) { let page + if (url.query) { const searchParams = new URLSearchParams(url.query) const pageStr = searchParams.get('page') + page = pageStr ? parseInt(pageStr) : undefined } + responseBody = await this.getBindings(input, page) } else if (request.method === 'PUT' && pathEndsWith(url, ['user_bindings', '*'])) { const params = parsePathParameters(url, ['user_bindings', ':id']) const body = await getBody(request) const binding = await sanitizeBinding(params.id, fromJson(body), this.onSetBinding) + responseBody = await this.setBinding(binding, input) } else { - response.writeHead(404, {'Content-Type': 'text/plain'}) + response.writeHead(404, TEXT_CONTENT_TYPE) response.end('Not Found') return } if (responseBody) { - response.writeHead(200, {'Content-Type': 'application/json'}) + response.writeHead(200, JSON_CONTENT_TYPE) response.end(toJson(responseBody)) } else { - response.writeHead(200, {'Content-Type': 'application/json'}) + response.writeHead(200, JSON_CONTENT_TYPE) response.end() } } catch (err) { - this.styraRunClient.signalEvent('rbac', {err}) + this.styraRunClient.signalEvent(EventType.RBAC, {err}) if (err instanceof StyraRunError) { - response.writeHead(403, {'Content-Type': 'text/plain'}) + response.writeHead(403, TEXT_CONTENT_TYPE) response.end('Forbidden') } else if (err instanceof InvalidInputError) { - response.writeHead(400, {'Content-Type': 'text/plain'}) + response.writeHead(400, TEXT_CONTENT_TYPE) response.end('Invalid request') } else { - response.writeHead(500, {'Content-Type': 'text/plain'}) + response.writeHead(500, TEXT_CONTENT_TYPE) response.end('Error') } } @@ -122,7 +133,6 @@ class BackendError extends Error { } } - async function sanitizeBinding(id, data, onSetBinding) { if (!Array.isArray(data)) { throw new InvalidInputError('Binding data is not an array') diff --git a/src/run-sdk.js b/src/run-sdk.js index f964ba9..f1218a2 100644 --- a/src/run-sdk.js +++ b/src/run-sdk.js @@ -1,8 +1,9 @@ import Path from "path" -import {ApiClient} from "./api-client.js" -import {StyraRunError, StyraRunAssertionError, StyraRunHttpError} from "./errors.js" -import {getBody, toJson, fromJson} from "./helpers.js" -import {Manager as RbacManager} from "./rbac-management.js" +import { ApiClient} from "./api-client.js" +import { StyraRunError, StyraRunAssertionError, StyraRunHttpError } from "./errors.js" +import { getBody, toJson, fromJson } from "./helpers.js" +import { Manager as RbacManager } from "./rbac-management.js" +import { BATCH_MAX_ITEMS } from "./constants.js" // TODO: Add support for versioning/ETags for data API requests // TODO: Add support for fail-over/retry when server connection is broken @@ -11,39 +12,41 @@ import {Manager as RbacManager} from "./rbac-management.js" * @module StyraRun */ + const EventType = { + ASSERT: 'assert', + BATCH_QUERY: 'batch-query', + CHECK: 'check', + FILTER: 'filter', + PROXY: 'proxy', + QUERY: 'query' +} + /** * A client for communicating with the Styra Run API. * @class */ export class Client { - constructor({ - url = "https://api-test.styra.com", - token, - batchMaxItems = 20, - inputTransformers = {}, + constructor(url, token, { + batchMaxItems = BATCH_MAX_ITEMS, organizeGateways, eventListeners = [] }) { this.batchMaxItems = batchMaxItems - this.inputTransformers = inputTransformers this.apiClient = new ApiClient(url, token, {organizeGateways}) - this.eventListeners = eventListeners + this.eventListeners = eventListeners // currently no README example on this usage? } - async signalEvent(type, info) { + signalEvent(type, info) { this.eventListeners.forEach((listener) => listener(type, info)) } - setInputTransformer(path, transformer) { - this.inputTransformers[path] = transformer - } - /** * @typedef {{result: *}|{}} CheckResult */ /** * Makes an authorization query against a policy rule specified by `path`. - * Where `path` is the trailing component(s) of the full request path `"/v1/projects///envs//data/"` + * Where `path` is the trailing segment of the full request path + * `"/v1/projects///envs//data/"` * * Returns a `Promise` that on a successful Styra Run API response resolves to the response body dictionary, e.g.: `{"result": ...}`. * On error, the returned `Promise` is rejected with a {@link StyraRunError}. @@ -59,11 +62,11 @@ export class Client { try { const json = toJson(query) - const decission = await this.apiClient.post(Path.join('data', path), json) - this.signalEvent('query', {path, query, decission}) - return fromJson(decission) + const decision = await this.apiClient.post(Path.join('data', path), json) + this.signalEvent(EventType.QUERY, {path, query, decision}) + return fromJson(decision) } catch (err) { - this.signalEvent('query', {path, query, err}) + this.signalEvent(EventType.QUERY, {path, query, err}) throw new StyraRunError('Query failed', err) } } @@ -75,7 +78,7 @@ export class Client { */ /** * Makes an authorization check against a policy rule specified by `path`. - * Where `path` is the trailing component(s) of the full request path + * Where `path` is the trailing segment of the full request path * `"/v1/projects///envs//data/"` * * The optional `predicate` is a callback that takes the Styra Run check response @@ -98,21 +101,21 @@ export class Client { * @param {DecisionPredicate|undefined} predicate a callback function, taking a query response dictionary as arg, returning true/false (optional) * @returns {Promise} */ - async check(path, input = undefined, predicate = DEFAULT_PREDICATE) { + async check(path, input = undefined, predicate = defaultPredicate) { try { - const decission = await this.query(path, input) - const allowed = await predicate(decission) - this.signalEvent('check', {allowed, path, input}) + const decision = await this.query(path, input) + const allowed = await predicate(decision) + this.signalEvent(EventType.CHECK, {allowed, path, input}) return allowed } catch (err) { - this.signalEvent('check', {path, input, err}) + this.signalEvent(EventType.CHECK, {path, input, err}) throw new StyraRunError('Check failed', err) } } /** * Makes an authorization check against a policy rule specified by `path`. - * Where `path` is the trailing component(s) of the full request path + * Where `path` is the trailing segment of the full request path * `"/v1/projects///envs//data/"` * * The optional `predicate` is a callback that takes the Styra Run check response @@ -137,19 +140,19 @@ export class Client { * @returns {Promise} * @see {@link check} */ - async assert(path, input = undefined, predicate = DEFAULT_PREDICATE) { - let asserted = false + async assert(path, input = undefined, predicate = defaultPredicate) { try { - asserted = await this.check(path, input, predicate) - this.signalEvent('assert', {asserted, path, input}) + const asserted = await this.check(path, input, predicate) + this.signalEvent(EventType.ASSERT, {asserted, path, input}) + if (asserted) { + return + } } catch (err) { - this.signalEvent('assert', {asserted, path, input, err}) + this.signalEvent(EventType.ASSERT, {asserted: false, path, input, err}) throw new StyraRunError('Assert failed', err) } - if (!asserted) { - throw new StyraRunAssertionError() - } + throw new StyraRunAssertionError() } /** @@ -170,7 +173,7 @@ export class Client { * @returns {Promise} * @see {@link assert} */ - async assertAndReturn(data, path, input = undefined, predicate = DEFAULT_PREDICATE) { + async assertAndReturn(data, path, input = undefined, predicate = defaultPredicate) { await this.assert(path, input, predicate) return data } @@ -189,20 +192,20 @@ export class Client { */ /** * Makes a batched request of policy rule queries. - * The provided `items` is a list of dictionaries with the properties: + * The provided `items` is a list of objects with the properties: * * * `path`: the path to the policy rule to query for this entry * * `input`: (optional) the input document for this entry * * If, `input` is provided, it will be applied across all query items. * - * Returns a `Promise` that is resolved to a list of result dictionaries, where each entry corresponds to an entry + * Returns a `Promise` that is resolved to a list of result objects, where each entry corresponds to an entry * with the same index in `items`. * On error, the returned `Promise` is rejected with a {@link StyraRunError}. * * @param {BatchQuery[]} items the list of queries to batch * @param {*} input the input document to apply to the entire batch request, or `undefined` - * @returns {Promise} a list of result dictionaries + * @returns {Promise} a list of result objects */ async batchQuery(items, input = undefined) { // Split the items over multiple batch requests, if necessary; @@ -225,7 +228,7 @@ export class Client { const {result} = fromJson(jsonResponse) return result } catch (err) { - this.signalEvent('batch-query', {items, input, err}) + this.signalEvent(EventType.BATCH_QUERY, {items, input, err}) throw new StyraRunError('Batched check failed', err) } }) @@ -234,13 +237,14 @@ export class Client { const decisions = decisionChunks .map((result) => (result !== undefined ? result : [])) .flat(1) - this.signalEvent('batch-query', {items, input, decisions}) + this.signalEvent(EventType.BATCH_QUERY, {items, input, decisions}) return decisions } /** * For each entry in the provided `list`, an authorization check against a policy rule specified by `path` is made. - * Where `path` is the trailing component(s) of the full request path `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"` + * Where `path` is the trailing segment of the full request path + * `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"` * * Returns a `Promise` that resolves to a filtered version of the provided `list`. * On error, the returned `Promise` is rejected with a {@link StyraRunError}. @@ -249,12 +253,12 @@ export class Client { * @param {FilterPredicateCallback} predicate the predicate callback to filter each list entry by given a policy decision * @param {string|undefined} path the path to the policy rule to query * @param {FilterInputCallback} toInput optional, a callback that, given a list entry and an index, should return an `input` document - * @param {FilterPathCallback} toPath optional, a callback that, given a list entry and an index, should return a `path` string. If provided, overrides the global `'path'` argument + * @param {FilterPathCallback} toPath optional, a callback that, given a list entry and an index, should return a `path` string. If provided, overrides the global `path` argument. May return a falsy value to default to the global `path` * @returns {Promise<*[], StyraRunError>} */ async filter(list, predicate, path = undefined, toInput = undefined, toPath = undefined) { if (list.length === 0) { - return Promise.resolve([]) + return [] } const transformer = (entry, i) => { @@ -266,7 +270,7 @@ export class Client { } const itemPath = toPath ? toPath(entry, i) : undefined - item.path = itemPath ?? path + item.path = itemPath || path if (item.path === undefined) { throw new StyraRunError(`No 'path' provided for list entry at ${i}`) } @@ -279,14 +283,14 @@ export class Client { const items = list.map(transformer) decisionList = await this.batchQuery(items) } catch (err) { - const err2 = new StyraRunError('Filtering failed', err) - this.signalEvent('filter', {list, decisionList, path, err: err2}) - throw err2 + const error = new StyraRunError('Filtering failed', err) + this.signalEvent(EventType.FILTER, {list, decisionList, path, err: error}) + throw error } if (decisionList === undefined || decisionList.length !== list.length) { - const err = new StyraRunError(`Returned decision list size (${decisionList?.length}) not equal to provided list size (${list.length})`) - this.signalEvent('filter', {list, decisionList, path, err}) + const err = new StyraRunError(`Returned decision list size (${decisionList?.length || 0}) not equal to provided list size (${list.length})`) + this.signalEvent(EventType.FILTER, {list, decisionList, path, err}) throw err } @@ -297,10 +301,10 @@ export class Client { filteredList.push(v) } }) - this.signalEvent('filter', {list, decisionList, filteredList, path}) + this.signalEvent(EventType.FILTER, {list, decisionList, filteredList, path}) return filteredList } catch (err) { - this.signalEvent('filter', {list, decisionList, path}) + this.signalEvent(EventType.FILTER, {list, decisionList, path, err}) throw new StyraRunError('Allow filtering failed', err) } } @@ -310,7 +314,8 @@ export class Client { */ /** * Fetch data from the `Styra Run` data API. - * Where `path` is the trailing component(s) of the full request path `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"` + * Where `path` is the trailing segment of the full request path + * `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"` * * Returns a `Promise` that on a successful response resolves to the {@link DataResult response body dictionary}: `{"result": ...}`. * On error, the returned `Promise` is rejected with a {@link StyraRunError}. @@ -338,7 +343,8 @@ export class Client { */ /** * Upload data to the `Styra Run` data API. - * Where `path` is the trailing component(s) of the full request path `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"`. + * Where `path` is the trailing segment of the full request path + * `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"`. * * Returns a `Promise` that on a successful response resolves to the Styra Run API {@link DataUpdateResult response body dictionary}: `{"version": ...}`. * On error, the returned `Promise` is rejected with a {@link StyraRunError}. @@ -359,7 +365,8 @@ export class Client { /** * Remove data from the `Styra Run` data API. - * Where `path` is the trailing component(s) of the full request path `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"` + * Where `path` is the trailing segment of the full request path + * `"/v1/projects/${UID}/${PID}/envs/${EID}/data/${path}"` * * Returns a `Promise` that on a successful response resolves to the Styra Run API {@link DataUpdateResult response body dictionary}: `{"version": ...}`. * On error, the returned `Promise` is rejected with a {@link StyraRunError}. @@ -384,27 +391,13 @@ export class Client { * @param {*} input the input document/value for the policy query * @returns the input document/value that should be used for the proxied policy query */ - /** - * @callback OnProxyDoneCallback - * @param {http.IncomingMessage} request the incoming HTTP request - * @param {http.OutgoingMessage} response the outgoing HTTP response - * @param {BatchCheckItemResult[]} result the result of the proxied policy query, that should be serialized and returned to the caller - */ - /** - * @callback OnProxyErrorCallback - * @param {http.IncomingMessage} request the incoming HTTP request - * @param {http.OutgoingMessage} response the outgoing HTTP response - * @param {StyraRunError} error the error generated when proxying the policy query - */ /** * Returns an HTTP proxy function * * @param {OnProxyCallback} onProxy callback called for every proxied policy query - * @param {OnProxyDoneCallback} onDone - * @param {OnProxyErrorCallback} onError * @returns {(Function(*, *): Promise)} */ - proxy(onProxy = DEFAULT_ON_PROXY_HANDLER, onDone = DEFAULT_PROXY_DONE_HANDLER, onError = DEFAULT_PROXY_ERROR_HANDLER) { + proxy(onProxy = defaultOnProxyHandler) { return async (request, response) => { try { if (request.method !== 'POST') { @@ -431,10 +424,6 @@ export class Client { try { let input = await onProxy(request, response, path, query.input) - const inputTransformer = this.inputTransformers[path] - if (inputTransformer) { - input = await inputTransformer(path, input) - } resolve({path, input}) } catch (err) { reject(new StyraRunError('Error transforming input', path, err)) @@ -446,11 +435,13 @@ export class Client { const batchResult = await this.batchQuery(batchItems) const result = (batchResult ?? []).map((item) => item.check ?? {}) - this.signalEvent('proxy', {queries, result}) - onDone(request, response, result) + this.signalEvent(EventType.PROXY, {queries, result}) + response.writeHead(200, {'Content-Type': 'application/json'}) + response.end(toJson(result)) } catch (err) { - this.signalEvent('proxy', {err}) - onError(request, response, err) + this.signalEvent(EventType.PROXY, {err}) + response.writeHead(500, {'Content-Type': 'text/html'}) + response.end('policy check failed') } } } @@ -498,7 +489,7 @@ export class Client { * @param {number} pageSize `integer` representing the size of each page of enumerated user bindings * @returns {(Function(*, *): Promise)} */ - manageRbac(createInput = DEFAULT_RBAC_INPUT_CALLBACK, getUsers = DEFAULT_RBAC_USERS_CALLBACK, onSetBinding = DEFAULT_RBAC_ON_SET_BINDING_CALLBACK, pageSize = 0) { + manageRbac(createInput = defaultRbacInputCallback, getUsers = defaultRbacUsersCallback, onSetBinding = defaultRbacOnSetBindingCallback, pageSize = 0) { const manager = new RbacManager(this, createInput, getUsers, onSetBinding, pageSize) return (request, response) => { manager.handle(request, response) @@ -506,36 +497,26 @@ export class Client { } } -export function DEFAULT_PREDICATE(decision) { +export function defaultPredicate(decision) { return decision?.result === true } -function DEFAULT_RBAC_USERS_CALLBACK(offset, limit) { +function defaultRbacUsersCallback(_, __) { return [] } -function DEFAULT_RBAC_ON_SET_BINDING_CALLBACK(id, roles) { +function defaultRbacOnSetBindingCallback(_, __) { return true } -function DEFAULT_RBAC_INPUT_CALLBACK(request) { +function defaultRbacInputCallback(_) { return {} } -function DEFAULT_ON_PROXY_HANDLER(request, response, path, input) { +function defaultOnProxyHandler(_, __, ___, input) { return input } -function DEFAULT_PROXY_DONE_HANDLER(request, response, result) { - response.writeHead(200, {'Content-Type': 'application/json'}) - .end(toJson(result)) -} - -function DEFAULT_PROXY_ERROR_HANDLER(request, response, error) { - response.writeHead(500, {'Content-Type': 'text/html'}) - response.end('policy check failed') -} - /** * Construct a new `Styra Run` Client from the passed `options` dictionary. * Valid options are: @@ -547,10 +528,6 @@ function DEFAULT_PROXY_ERROR_HANDLER(request, response, error) { * @returns {Client} * @constructor */ -function New(options) { - return new Client(options); +export default function New(url, token, options = {}) { + return new Client(url, token, options); } - -export default { - New -} \ No newline at end of file diff --git a/spec/api-client.spec.js b/tests/api-client.test.js similarity index 100% rename from spec/api-client.spec.js rename to tests/api-client.test.js diff --git a/spec/aws.spec.js b/tests/aws.test.js similarity index 100% rename from spec/aws.spec.js rename to tests/aws.test.js diff --git a/spec/helpers.js b/tests/helpers.js similarity index 100% rename from spec/helpers.js rename to tests/helpers.js diff --git a/spec/rbac.spec.js b/tests/rbac.test.js similarity index 97% rename from spec/rbac.spec.js rename to tests/rbac.test.js index d099db0..4d74398 100644 --- a/spec/rbac.spec.js +++ b/tests/rbac.test.js @@ -1,7 +1,7 @@ import http from "node:http" import serverSpy from "jasmine-http-server-spy" import Url from "url" -import sdk, { DEFAULT_PREDICATE } from "../src/run-sdk.js" +import StyraRun from "../src/run-sdk.js" import { clientRequest, withServer } from "./helpers.js" describe("Roles can be fetched", () => { @@ -9,10 +9,7 @@ describe("Roles can be fetched", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' - const sdkClient = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const sdkClient = StyraRun('http://placeholder', 'foobar') sdkClient.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -117,10 +114,7 @@ describe("Bindings can be fetched", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' - const sdkClient = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const sdkClient = StyraRun('http://placeholder', 'foobar') sdkClient.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -344,10 +338,7 @@ describe("Bindings can be upserted", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' - const sdkClient = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const sdkClient = StyraRun('http://placeholder', 'foobar') sdkClient.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -497,4 +488,4 @@ describe("Bindings can be upserted", () => { expect(httpSpy.putAliceBindingUrl).toHaveBeenCalledTimes(0) }) }) -}) \ No newline at end of file +}) diff --git a/spec/run-sdk.spec.js b/tests/run-sdk.test.js similarity index 96% rename from spec/run-sdk.spec.js rename to tests/run-sdk.test.js index 3ffc08b..eac9ffa 100644 --- a/spec/run-sdk.spec.js +++ b/tests/run-sdk.test.js @@ -1,7 +1,7 @@ import http from "node:http" import Url from "url" import serverSpy from "jasmine-http-server-spy" -import sdk, { DEFAULT_PREDICATE } from "../src/run-sdk.js" +import StyraRun, { defaultPredicate } from "../src/run-sdk.js" import { StyraRunAssertionError } from "../src/errors.js" import { clientRequest, withServer } from "./helpers.js" @@ -11,10 +11,7 @@ describe("Query", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' const path = 'foo/allowed' - const client = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const client = StyraRun('http://placeholder', 'foobar') client.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -139,10 +136,7 @@ describe("Batched Query", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' - const client = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const client = StyraRun('http://placeholder', 'foobar') client.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -188,7 +182,7 @@ describe("Batched Query", () => { }) it("Successful, max allowed items reached", async () => { - const client = sdk.New({ + const client = StyraRun('http://placeholder', 'foobar', { batchMaxItems: 3 }) client.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] @@ -328,10 +322,7 @@ describe("Check", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' const path = 'foo/allowed' - const client = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const client = StyraRun('http://placeholder', 'foobar') client.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -426,10 +417,7 @@ describe("Assert", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' const path = 'foo/allowed' - const client = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const client = StyraRun('http://placeholder', 'foobar') client.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -543,10 +531,7 @@ describe("Filter allowed", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' const path = 'foo/allowed' - const client = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const client = StyraRun('http://placeholder', 'foobar') client.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] const toInput = (v, i) => { @@ -580,7 +565,7 @@ describe("Filter allowed", () => { const list = [] const expectedList = [] - const result = await client.filter(list, DEFAULT_PREDICATE, path) + const result = await client.filter(list, defaultPredicate, path) expect(result).toEqual(expectedList) }) @@ -612,7 +597,7 @@ describe("Filter allowed", () => { const list = ['do', 're', 'mi', 'fa', 'so', 'la'] const expectedList = ['do', 'so', 'la'] - const result = await client.filter(list, DEFAULT_PREDICATE, path, toInput) + const result = await client.filter(list, defaultPredicate, path, toInput) expect(result).toEqual(expectedList) expect(httpSpy.getMockedUrl).toHaveBeenCalledWith(jasmine.objectContaining({ body: expectedQuery @@ -650,7 +635,7 @@ describe("Filter allowed", () => { const list = ['do', 're', 'mi', 'fa', 'so', 'la'] - const result = await client.filter(list, DEFAULT_PREDICATE, path, undefined, toPath) + const result = await client.filter(list, defaultPredicate, path, undefined, toPath) expect(result).toEqual(list) expect(httpSpy.getMockedUrl).toHaveBeenCalledWith(jasmine.objectContaining({ body: expectedQuery @@ -665,7 +650,7 @@ describe("Filter allowed", () => { const list = ['do', 're', 'mi', 'fa', 'so', 'la'] try { - const result = await client.filter(list, DEFAULT_PREDICATE, undefined, undefined, toPath) + const result = await client.filter(list, defaultPredicate, undefined, undefined, toPath) fail(`Expected error, got: ${result}`) } catch (err) { expect(err.name).toBe('StyraRunError') @@ -685,10 +670,7 @@ describe("Proxy", () => { const port = 8082 const basePath = 'v1/projects/user1/proj1/envs/env1' const path = 'foo/allowed' - const sdkClient = sdk.New({ - url: 'http://placeholder', - token: 'foobar' - }) + const sdkClient = StyraRun('http://placeholder', 'foobar') sdkClient.apiClient.gateways = [Url.parse(`http://localhost:${port}/${basePath}`)] beforeAll(function(done) { @@ -851,4 +833,4 @@ function toApiBatchResponseBody(result) { } ] } -} \ No newline at end of file +} diff --git a/spec/support/jasmine.json b/tests/support/jasmine.json similarity index 52% rename from spec/support/jasmine.json rename to tests/support/jasmine.json index 6afe603..8a52bc8 100644 --- a/spec/support/jasmine.json +++ b/tests/support/jasmine.json @@ -1,10 +1,7 @@ { - "spec_dir": "spec", + "spec_dir": "tests", "spec_files": [ - "**/*[sS]pec.?(m)js" - ], - "helpers": [ - "helpers/**/*.?(m)js" + "*.test.js" ], "env": { "stopSpecOnExpectationFailure": false,