oso-cloud
Version:
Oso Cloud Node.js Client SDK
326 lines • 13.9 kB
JavaScript
;
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