Skip to content

Commit

Permalink
Add --var and --var-file
Browse files Browse the repository at this point in the history
  • Loading branch information
cfeenstra67 committed Dec 24, 2023
1 parent cd20e43 commit 8f34203
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 15 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

## [0.7.0] - 2023-12-24

### Added

- `--var` and `--var-file` arguments to allow injection of variables into rules scope.

## [0.6.0] - 2023-12-24

### Added
Expand Down
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ The configuration options for `sqlauthz` can be found in the table below. Note t
| `revokeAll`<br/>`--revoke-all`<br/>`SQLAUTHZ_REVOKE_ALL` | No | `false` | Use the `all` user revoke strategy. See [User revoke policy](#user-revoke-policy) for details. Conflicts with `revokeReferenced` and `revokeUsers`. Note that if setting this via environment variable, the value must be `true`. |
| `revokeUsers`<br/>`--revoke-users`<br/>`SQLAUTHZ_REVOKE_USERS` | No | `false` | Use the `users` revoke strategy, revoking permissions from a list of users explicitly. See [User revoke policy](#user-revoke-policy) for details. Conflicts with `revokeReferenced` and `revokeAll`. Note that if setting this via environment variable, only a single value can be passed. |
| `allowAnyActor`<br/>`--allow-any-actor`<br/>`SQLAUTHZ_ALLOW_ANY_ACTOR` | No | `false` | Allow rules that do not put any limitations on the `actor`, so they apply to all users. This is potentially dangerous, particularly when used with `revokeReferenced` (the default), so it is disabled by default. This argument allows these rules (but make sure that you know what you're doing!). |
| `var`<br/>`--var`<br/>`SQLAUTHZ_VAR` | No | <none> | Inject variables into scope that can be utilized by your rules files. The syntax for variables injected via command line is `<name>=<value>`. The CLI will attempt to parse `<value>` a JSON string, and if that fails it will just be interpreted as a string. Within your rules files, variables can be access with `var.<name>`. This can be used to parametrize your rules files, and separate your configuration from your permissions logic. Also see `--var-file` for more flexibility. |
| `varFile`<br/>`--var-file`<br/>`SQLAUTHZ_VAR_FILE` | No | <none> | Specify script(s) or JSON file(s) that will be loaded, and their exports will be used to inject variables into your rules files. Glob paths are supported e.g. `*.js`. The file(s) must have `.js` or `.json` extensions. Within your rules files, variables can be access with `var.<name>`. `--var` will take priority over variables loaded from file(s) loaded with this argument. This can be used to separate your permissions logic from your configuration. For an example, see the [complete example](#a-complete-example) below. |
| `dryRun`<br/>`--dry-run`<br/>`SQLAUTHZ_DRY_RUN` | No | `false` | Print the full SQL query that would be executed instead of executing it. Note that if setting this via environment variable, the value must be `true`. This conflicts with `dryRunShort` |
| `dryRunShort`<br/>`--dry-run-short`<br/>`SQLAUTHZ_DRY_RUN_SHORT` | No | `false` | Print an abbreviated SQL query, only containing the `GRANT` queries that will be run, instead of executing anything. Note that if setting this via environment variable, the value must be `true`. This conflicts with `dryRun` |
| `debug`<br/>`--debug`<br/>`SQLAUTHZ_DEBUG` | No | `false` | Print more detailed error information for debugging compilation failures. Note that if setting this via environment variable, the value must be `true`. |
Expand Down Expand Up @@ -290,7 +292,16 @@ These are a limited set of examples on how you can express certain rule sets in

### A complete example

This example shows how you can effectively segment your permissions into various virtual "roles" that can easily be assigned to new users and/or groups. This is split up into multiple scripts as an example of how you might want to organize your rules into a few different files.
This example shows how you can effectively segment your permissions into various virtual "roles" that can easily be assigned to new users and/or groups. This is split up into multiple scripts as an example of how you might want to organize your rules into a few different files. This also showcases how you can use the `varFile` argument to parametrize your rules and separate your configuration from your permissions logic. For this example, you would add the following configuration to your `package.json` (alternatively, specify `--var-file roles.json -r permissions.polar roles.polar` on the command line):
```json
{
...
"sqlauthz": {
"rules": ["permissions.polar", "roles.polar"],
"varFile": ["roles.json"]
}
}
```

`permissions.polar`
```polar
Expand Down Expand Up @@ -345,25 +356,31 @@ allow(actor, permission, resource)
```polar
# Assign some users as "devs", which will get a certain set of permissions
isDev(actor)
if name in ["bob", "greg", "julie", "marianne"]
if name in var.devUsers
and actor.type == "user"
and actor == name;
# Assign some users to the QA grouop, which will get a certain set of permissions
# Assign some users to the QA group, which will get a certain set of permissions
# This is equivalent to the isDev syntax above except these rules don't check that
# the actor is a user and not a group (which usually shouldn't be an issue, but it
# could depend on how your DB is set up. When using `sqlauthz` using postgresql groups
# shouldn't really be necessary)
isQA("randy");
isQA("john");
isQA("ariel");
isQA(actor) if actor in var.qaUsers;
# Devs should have all of the QA permissions
isQA(actor) if isDev(actor);
# Virtual group for DB roles used by apps
isApp("api_svc");
isApp("worker_svc");
isApp(actor) if actor in var.appUsers;
```

`roles.json`
```json
{
"devUsers": ["bob", "greg", "julie", "marianne"],
"qaUsers": ["randy", "john", "ariel"],
"appUsers": ["api_svc", "worker_svc"]
}
```

### Grant a user or group all permissions on all schemas
Expand Down
111 changes: 104 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
#!/usr/bin/env node
import { fdir } from "fdir";
import fs from "node:fs";
import path from "node:path";
import pg from "pg";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { compileQuery } from "./api.js";
import { OsoError } from "./oso.js";
import { PostgresBackend } from "./pg-backend.js";
import { UserRevokePolicy } from "./sql.js";
import { PathNotFound, strictGlob } from "./utils.js";

function parseVar(value: string): [string, unknown] {
const parts = value.split("=", 2);
if (parts.length !== 2) {
throw new Error(
`Invalid variable value: ${value}. Must use name=value syntax`,
);
}
const key = parts[0]!;
let outValue = parts[1]!;
try {
outValue = JSON.parse(outValue);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
}
return [key, outValue];
}

async function main() {
if (!process.env.NO_DOTENV) {
Expand Down Expand Up @@ -67,6 +88,24 @@ async function main() {
"queries will be allowed",
default: false,
})
.option("var", {
type: "string",
array: true,
description:
"Define variable(s) that can be referenced in your rules files " +
"by specifying a value of `varname=varvalue`. The variables " +
"will be attempted to be parsed as JSON, otherwise they will " +
"be treated as strings. Variables can be access in rules files " +
"via `var.<name>`. For more flexibility, also see --var-file",
})
.option("var-file", {
type: "string",
array: true,
description:
"File paths to .js scripts or JSON files that will be loaded, " +
"and the exports will be available in your rules files as " +
"var.<name>.",
})
.option("dry-run", {
type: "boolean",
description:
Expand Down Expand Up @@ -99,17 +138,69 @@ async function main() {
userRevokePolicy = { type: "referenced" };
}

const rulesPaths = await new fdir()
.glob(...args.rules)
.withBasePath()
.crawl(".")
.withPromise();
let rulesPaths: string[];
try {
rulesPaths = await strictGlob(...args.rules);
} catch (error) {
if (error instanceof PathNotFound) {
console.error("Path not found:", error.path);
process.exit(1);
}
console.error("Unexpected error finding rules files:", error);
process.exit(1);
}

if (rulesPaths.length === 0) {
console.error(`No rules files matched glob(s): ${args.rules.join(", ")}`);
process.exit(1);
}

const vars: Record<string, unknown> = {};
let varFiles: string[];
try {
varFiles = args.varFile ? await strictGlob(...args.varFile) : [];
} catch (error) {
if (error instanceof PathNotFound) {
console.error("Path not found:", error.path);
process.exit(1);
}
console.error("Unexpected error finding variable files:", error);
process.exit(1);
}

for (const varFile of varFiles) {
if (varFile.endsWith(".json")) {
const content = await fs.promises.readFile(varFile, { encoding: "utf8" });
let obj: unknown;
try {
obj = JSON.parse(content);
} catch (_) {
console.error(`Unable to parse JSON in ${varFile}`);
process.exit(1);
}
Object.assign(vars, obj);
} else if (varFile.endsWith(".js")) {
const fullPath = path.resolve(varFile);
const mod = await import(fullPath);
Object.assign(vars, mod);
} else {
console.error(
`Invalid var file: ${varFile}. Extension must be .js or .json`,
);
process.exit(1);
}
}

try {
for (const varString of args.var ?? []) {
const [key, value] = parseVar(varString);
vars[key] = value;
}
} catch (error) {
console.error("Error parsing variables:", error);
process.exit(1);
}

const client = new pg.Client(args.databaseUrl);
try {
await client.connect();
Expand All @@ -129,6 +220,7 @@ async function main() {
includeSetupAndTeardown: !args.dryRunShort,
includeTransaction: !args.dryRunShort,
debug: args.debug,
vars: { var: vars },
});
if (query.type !== "success") {
console.error("Unable to compile permission queries. Errors:");
Expand All @@ -139,7 +231,11 @@ async function main() {
}

if (args.dryRun || args.dryRunShort) {
console.log(query.query);
if (query.query) {
console.log(query.query);
} else {
console.log("No permissions granted to any users");
}
return;
}

Expand All @@ -151,6 +247,7 @@ async function main() {
} else {
console.error("Unexpected error:", error);
}
process.exit(1);
} finally {
await client.end();
}
Expand Down
31 changes: 31 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from "node:fs";
import { fdir } from "fdir";
import { Variable } from "oso";
import { Expression } from "oso/dist/src/Expression.js";
import { Pattern } from "oso/dist/src/Pattern.js";
Expand Down Expand Up @@ -142,3 +144,32 @@ export function* arrayProduct<A extends readonly (readonly any[])[]>(
// yield outItems as ArrayProductItem<A>;
// }
}

export async function strictGlob(...globs: string[]): Promise<string[]> {
const out = new Set<string>();
for (const pattern of globs) {
if (pattern.includes("*")) {
const result = await new fdir()
.glob(pattern)
.withBasePath()
.crawl(".")
.withPromise();
for (const item of result) {
out.add(item);
}
} else {
if (!fs.existsSync(pattern)) {
throw new PathNotFound(pattern);
}
out.add(pattern);
}
}

return Array.from(out);
}

export class PathNotFound extends Error {
constructor(readonly path: string) {
super(`File not found: ${path}`);
}
}

0 comments on commit 8f34203

Please sign in to comment.