UNPKG

oso-cloud

Version:

Oso Cloud Node.js Client SDK

326 lines 13.9 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.QueryBuilder = void 0; exports.typedVar = typedVar; const helpers_1 = require("./helpers"); /** * Construct a new query variable of a specific type. * * @param type The actor/resource type of the variable to be created. * Note: This must NOT be the "Actor" or "Resource" abstract types. * To query for many types of results, make one request for each concrete * type. * @returns A new variable that can be used with `oso.buildQuery` APIs */ function typedVar(type) { return new QueryVariable(type); } class QueryVariable { constructor(type) { this.type = type; this.id = `var_${Math.random().toString(36).substring(7)}`; } getId() { return this.id; } getType() { return this.type; } toString() { return this.id; } } function handleWildcard(v) { if (v === null) { // TODO(Sam): we should have a better approach here, // but for now this is consistent with other APIs return "*"; } else { return v; } } /** * Helper class to support building a custom Oso query. * * Initialize this with `oso.buildQuery` and chain calls to `and` and `in` to add additional constraints. * * After building your query, run it and get the results by calling `evaluate`. */ class QueryBuilder { constructor(oso, predicate, calls = [], constraints = new Map(), contextFacts = []) { this.oso = oso; this.predicate = predicate; this.calls = calls; this.constraints = constraints; this.contextFacts = contextFacts; this.asQuery = () => { return { predicate: this.predicate, calls: this.calls, constraints: Object.fromEntries(this.constraints.entries()), context_facts: this.contextFacts, }; }; /** * The query API expects all call args to be variable names. This method helps * in this process by converting concrete arguments to temporary variables and * returning the variable names. */ this.pushArg = (arg) => { if (arg instanceof QueryVariable) { if (!this.constraints.has(arg.getId())) { this.constraints.set(arg.getId(), { type: arg.getType(), ids: null, }); } // nothing to do return arg.getId(); } else { const value = (0, helpers_1.toValue)(arg); const newVar = new QueryVariable(value.type); this.constraints.set(newVar.getId(), { type: value.type, ids: [value.id], }); return newVar.getId(); } }; // Make a deep copy of the query builder. this.clone = () => { return new QueryBuilder(this.oso, [this.predicate[0], [...this.predicate[1]]], this.calls.map(([p, args]) => [p, [...args]]), new Map([...this.constraints.entries()].map(([k, v]) => [ k, Object.assign(Object.assign({}, v), { ids: v.ids ? [...v.ids] : null }), ])), this.contextFacts.map(({ predicate, args }) => ({ predicate, args: [...args], }))); }; } static init(oso, [predicate, ...args]) { // NOTE(gj): Leaving args blank as a quick hack to avoid broader type // surgery. We immediately replace the predicate on the following line. const self = new QueryBuilder(oso, [predicate, []]); self.predicate = [predicate, args.map(self.pushArg)]; return self; } /** * Add another condition that must be true of the query results. * For example: * ```typescript * // Query for all the repos on which the given actor can perform the given action, * // and require the repos to belong to the given folder * const repo = typedVar("Repo"); * const authorizedReposInFolder = await oso * .buildQuery(["allow", actor, action, repo]) * .and(["has_relation", repo, "folder", folder]) * .evaluate(repo); * ``` * @param args The rule or fact constraint to add to the query * @returns {QueryBuilder} */ and([predicate, ...args]) { const clone = this.clone(); clone.calls.push([predicate, args.map(clone.pushArg)]); return clone; } /** * Constrain a query variable to be one of a set of values. * For example: * ```typescript * const repos = ["acme", "anvil"]; * const repo = typedVar("Repo"); * const action = typedVar("String"); * // Get all the actions the actor can perform on the repos that are in the given set * const authorizedActions = await oso * .buildQuery(["allow", actor, action, repo]) * .in(repo, repos) * .evaluate(action); * ``` * @param {TypedVar} v The variable to constrain * @param {string[]} values The set of allowed values for the constrained variable * @returns {QueryBuilder} */ in(v, values) { const self = this.clone(); const name = v.getId(); const bind = self.constraints.get(name); if (bind === undefined) { throw new Error(`can only constrain variables that are used in the query`); } if (bind.ids !== null) { throw new Error(`can only set values on each variable once`); } bind.ids = values; return self; } /** * Add context facts to the query. * @param {IntoFact[]} contextFacts * @returns {QueryBuilder} */ withContextFacts(contextFacts) { const clone = this.clone(); clone.contextFacts.push(...(0, helpers_1.mapParamsToConcreteFacts)(contextFacts)); return clone; } /** * Evaluate the query. The shape of the return value is determined by what you pass in: * - If you pass no arguments, returns a boolean. For example: * ```typescript * // true if the given actor can perform the given action on the given resource * let allowed = await oso.buildQuery(["allow", actor, action, resource]).evaluate(); * ``` * - If you pass a variable, returns a list of values for that variable. For example: * ```typescript * let action = typedVar("String"); * // all the actions the actor can perform on the given resource- eg. ["read", "write"] * let actions = await oso.buildQuery(["allow", actor, action, resource]).evaluate(action); * ``` * - If you pass a tuple of variables, returns a list of tuples of values for those variables. For example: * ```typescript * let action = typedVar("String"); * let repo = typedVar("Repo"); * // an array of pairs of allowed actions and repo IDs- eg. [["read", "acme"], ["read", "anvil"], ["write", "anvil"]] * let pairs = await oso.buildQuery(["allow", actor, action, repo]).evaluate([action, repo]); * ``` * - If you pass a Map mapping one input variable (call it K) to another * (call it V), returns a Map of unique values of K to the unique values of * V for each value of K. For example: * ```typescript * let action = typedVar("String"); * let repo = typedVar("Repo"); * let map = await oso.buildQuery(["allow", actor, action, repo]).evaluate(new Map([[repo, action]])); * // a map of repo IDs to allowed actions- eg. new Map(Object.entries({ "acme": ["read"], "anvil": ["read", "write"]})) * ``` * * @param arg See above * @returns See above */ evaluate(arg) { return __awaiter(this, void 0, void 0, function* () { const query = this.asQuery(); const { results } = yield this.oso.api.postQuery(query); return evaluateResults(arg, results); }); } /** * Fetches a complete SQL query that can be run against your database, selecting * a row for each authorized combination of the query variables in `columnNamesToQueryVars` * (ie. combinations of variables that satisfy the Oso query). * * See https://www.osohq.com/docs/app-integration/client-apis/node for examples and limitations. * * @param {Record<string, TypedVar<string>>?} columnNamesToQueryVars * A mapping of the desired column names to the query variables whose values * will be selected into those columns in the returned SQL query. * * If you pass an empty object or omit this parameter entirely, the returned SQL query * will select a single row with a boolean column called `result`. * * @returns {Promise<string>} SQL query containing the desired columns * @throws {TypeError} when columnNamesToQueryVars is empty or contains duplicate values * @throws {Error} */ evaluateLocalSelect(columnNamesToQueryVars) { return __awaiter(this, void 0, void 0, function* () { // This function accepts a map of column names -> query vars, because we // want to let people pass the mapping in via a plain JS object, and those // may only have strings keys. But we need to invert the mapping for the backend. const queryVarsToColumnNames = columnNamesToQueryVars ? Object.entries(columnNamesToQueryVars).reduce((result, item) => { const [columnName, queryVar] = item; result[queryVar.getId()] = columnName; return result; }, {}) : {}; const numKeys = Object.keys(queryVarsToColumnNames).length; if (columnNamesToQueryVars && numKeys !== Object.keys(columnNamesToQueryVars).length) { const queryVars = Object.values(columnNamesToQueryVars); const firstDupe = queryVars.find((v, i) => queryVars.slice(i + 1).includes(v)); throw new TypeError(`Found a duplicated ${firstDupe === null || firstDupe === void 0 ? void 0 : firstDupe.getType()} variable- you may not select a query variable more than once.`); } const { sql } = yield this.oso.api.postQueryLocal(this.asQuery(), { mode: "select", query_vars_to_output_column_names: queryVarsToColumnNames, }); return sql; }); } /** * Fetches a SQL fragment, which you can embed into the `WHERE` clause of a * SQL query against your database to filter out unauthorized rows (ie. rows * that don't satisfy the Oso query). * * See https://www.osohq.com/docs/app-integration/client-apis/node for examples and limitations. * * @param {string} columnName the name of the SQL column to filter * @param {TypedVar<string>} queryVar the variable corresponding to the column to filter * @returns {Promise<string>} SQL fragment, suitable for embedding in a `WHERE` clause * @throws {Error} */ evaluateLocalFilter(columnName, queryVar) { return __awaiter(this, void 0, void 0, function* () { const { sql } = yield this.oso.api.postQueryLocal(this.asQuery(), { mode: "filter", output_column_name: columnName, query_var: queryVar.getId(), }); return sql; }); } } exports.QueryBuilder = QueryBuilder; function evaluateResults(arg, results) { if (!arg) { return !!results.length; } else if (arg instanceof QueryVariable || Array.isArray(arg)) { const mappedResults = results.map((r) => evaluateResultItem(arg, r)); // Use JSON.stringify for deep equality check in the Set for array results. const uniqueResults = Array.from(new Set(mappedResults.map((item) => JSON.stringify(item)))).map((item) => JSON.parse(item)); return uniqueResults; } else { if (arg.size > 1) { throw TypeError("`evaluate` cannot accept maps with >1 elements"); } const structuredGrouping = new Map(); for (const [v, subArg] of arg.entries()) { const grouping = new Map(); for (const result of results) { const key = handleWildcard(result[v.toString()]); if (!grouping.has(key)) { grouping.set(key, []); } grouping.get(key).push(result); } for (const [key, value] of grouping) { structuredGrouping.set(key, evaluateResults(subArg, value)); } } return structuredGrouping; } } function evaluateResultItem(arg, result) { if (arg instanceof QueryVariable) { return handleWildcard(result[arg.toString()]); } else { return arg.map((subArg) => evaluateResultItem(subArg, result)); } } //# sourceMappingURL=query.js.map