Skip to content
This repository has been archived by the owner on Feb 13, 2025. It is now read-only.

wip: prototype data policies #8

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src/components/interfaces/rego-editor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useDBStore } from "@/stores";
import { Monaco } from "@monaco-editor/react";
import { Monaco, OnMount } from "@monaco-editor/react";
import type monaco from "monaco-editor";
import { cn } from "@/utils/classnames";
import { DataViewer } from "../query-playground/data-viewer";
import { toast } from "@/components/ui/sonner";
import { Button } from "@/components/ui/button";
import { CodeEditor } from "@/components/ui/code-editor";
import { forwardRef, ComponentProps } from "react";
import { forwardRef, ComponentProps, useRef } from "react";
import {
ResizableHandle,
ResizablePanel,
Expand Down Expand Up @@ -144,6 +144,19 @@ function initializeEditor(monaco: Monaco) {
monaco.languages.setLanguageConfiguration(Rego, configuration);
}

function _handleEditorMount(editor, monaco: Monaco) {
monaco.editor.setModelMarkers(editor.getModel(), "test", [
{
startLineNumber: 1,
startColumn: 10,
endLineNumber: -1,
endColumn: -1,
message: "a message",
severity: monaco.MarkerSeverity.Error,
},
]);
}

export const RegoEditor = forwardRef<HTMLDivElement, ComponentProps<"div">>(
({ className, ...props }, ref) => {
const query = useDBStore((s) => s.databases[s.active!.name].query);
Expand All @@ -161,6 +174,24 @@ export const RegoEditor = forwardRef<HTMLDivElement, ComponentProps<"div">>(
return JSON.stringify(res, null, 2);
});

const handleEditorMount = (editor, monaco) => {
const s = useDBStore.getState();
const errors = s.databases[s.active!.name].errors;
console.error(errors);
errors?.forEach(({ message, col, row }) => {
monaco.editor.setModelMarkers(editor.getModel(), "test", [
{
startLineNumber: row,
startColumn: col,
endLineNumber: -1,
endColumn: -1,
message,
severity: monaco.MarkerSeverity.Error,
},
]);
});
};

const setRego = (rego: string | undefined) =>
useDBStore.setState((s) => {
s.databases[s.active!.name].rego = rego;
Expand Down Expand Up @@ -318,6 +349,7 @@ export const RegoEditor = forwardRef<HTMLDivElement, ComponentProps<"div">>(
beforeMount={initializeEditor}
className="bg-muted"
defaultLanguage={Rego}
onMount={handleEditorMount}
options={{
folding: true,
lineNumbers: "on",
Expand Down
139 changes: 130 additions & 9 deletions src/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export interface Database {

query?: string;

errors?: { message: string; row: number; col: number }[];

rego?: string;

input?: Record<string, unknown>;
Expand Down Expand Up @@ -121,13 +123,25 @@ interface State {
// 2. we need the rego.v1 import because the Preview API has no "v1" flag
const defaultRego = "";
const rego = {
orders: `package conditions
orders: `package filters
import rego.v1

filter["users.name"] := input.user
filter["products.price"] := {"lte": 500} if input.budget == "low"
user := input.user

include if {
input.users.name == user
input.budget != "low"
}

include if {
input.budget == "low"
input.products.price < 500
input.users.name == user
}

conditions := data.convert.to_conditions(input, ["input.products", "input.users"], "data.filters.include")

query := ucast.as_sql(filter, "postgres", {"users": {"$self": "u"}, "products": {"$self": "p"}})
query := ucast.as_sql(conditions, "postgres", {"users": {"$self": "u"}, "products": {"$self": "p"}})
`,
schools: `package conditions
import rego.v1
Expand Down Expand Up @@ -160,6 +174,80 @@ JOIN students s2 ON ss2.student_id = s2.student_id
`,
};

const convertRego = `
package convert

import rego.v1

# TODO(sr): this is just good enough, the actual API is TBD
partial_eval(inp, unknowns, query) := http.send({
"method": "POST",
"url": "http://127.0.0.1:8181/v1/schmompile",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we're hitting the special endpoint of EOPA that'll end up doing PE post-analysis: https://github.com/StyraInc/enterprise-opa-private/pull/2142

"body": {
"query": query,
"unknowns": unknowns,
"input": inp,
},
}).body

to_conditions(inp, unknowns, query) := conds(partial_eval(inp, unknowns, query))

conds(pe) := pe if pe.code
conds(pe) := res if {
not pe.code
not pe.result.support # "support modules" are not supported right now
res := or_({query_to_condition(q) | some q in pe.result.queries})
}

query_to_condition(q) := and_({expr_to_condition(e) | some e in q})

expr_to_condition(e) := op_(op(e), field(e), value(e))

op(e) := o if {
e.terms[0].type == "ref"
e.terms[0].value[0].type == "var"
o := e.terms[0].value[0].value
is_valid(o)
}

is_valid(o) if o in {"eq", "lt", "gt", "neq"}

field(e) := f if {
# find the operand with 'input.*'
some t in array.slice(e.terms, 1, 3)
is_input_ref(t)
f := concat(".", [t.value[1].value, t.value[2].value])
}

value(e) := v if {
# find the operand without 'input.*'
some t in array.slice(e.terms, 1, 3)
not is_input_ref(t)
v := value_from_term(t)
}

value_from_term(t) := t.value if t.type != "null"
else := null

is_input_ref(t) if {
t.type == "ref"
t.value[0].value == "input"
}

# conditions helper functions
eq_(field, value) := op_("eq", field, value)

lt_(f, v) := op_("lt", f, v)

op_(o, f, v) := {"type": "field", "operator": o, "field": f, "value": v}

and_(values) := compound("and", values)

or_(values) := compound("or", values)

compound(op, values) := {"type": "compound", "operator": op, "value": values}
`;

export const useDBStore = create<State>()(
persist(
immer((set, get) => ({
Expand Down Expand Up @@ -284,15 +372,21 @@ export const useDBStore = create<State>()(
const connection = get().active!;
const { input, data } = get().databases[connection.name];

// EOPA Preview API
const helper = await putPolicy("convert.rego", convertRego);
if (!helper.ok) {
throw new Error(`convert policy: ${helper.statusText}`);
}

const main = await putPolicy("main.rego", rego);
if (!main.ok) {
throw new Error(`data policy: ${main.statusText}`);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll just update the policies via the Policy API for now. It's good enough for prototyping, we don't need multiplayer support (i.e. the Preview API)


const req = {
input,
data,
rego_modules: {
"main.rego": rego,
},
};
const resp = await fetch("/v0/preview/conditions", {
const resp = await fetch("/v1/data/filters", {
method: "POST",
body: JSON.stringify(req),
});
Expand All @@ -305,6 +399,23 @@ export const useDBStore = create<State>()(
throw new Error(result?.message);
}

const { conditions } = result?.result;
if ("code" in conditions) {
set((state) => {
const errors = conditions.errors.map(
({ message, location: { row, col } }) => ({ message, row, col })
);
console.dir({ errors }, { depth: null });
state.databases[connection.name].errors = errors;
return result;
});
throw new Error(
`${conditions.message}: ${conditions.errors
.map((e) => `${e.message} at ${e.location.row}`)
.join("; ")}`
);
}

set((state) => {
state.databases[connection.name].rego = rego;
state.databases[connection.name].evaluated = result?.result;
Expand Down Expand Up @@ -398,3 +509,13 @@ function combine(existing: string, filter: string | undefined): string {
}
return existing + "\nWHERE " + sansWhere;
}

async function putPolicy(id: string, code: string): Promise<Response> {
return fetch(`/v1/policies/${id}`, {
method: "PUT",
body: code,
headers: {
"Content-Type": "text/plain",
},
});
}
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineConfig({
server: {
proxy: {
"/v0": "http://127.0.0.1:8181",
"/v1": "http://127.0.0.1:8181",
},
},
});
Loading