Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,12 @@ paper/*.log
# Editor
.vscode/
.idea/

# Claude Code local state
.claude/

# Astro / web (web/ has its own .gitignore for build outputs)
web/node_modules/
web/dist/
web/.astro/
web/src/wasm/
133 changes: 133 additions & 0 deletions prototype/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions prototype/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"crates/postern-core",
"crates/postern-diff",
"crates/postern-guardrail",
"crates/postern-wasm",
]

[workspace.package]
Expand Down
20 changes: 20 additions & 0 deletions prototype/crates/postern-wasm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "postern-wasm"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true
description = "wasm-bindgen surface over postern-core for the Astro demo site"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
postern-core = { path = "../postern-core" }
serde = { workspace = true }
serde_json = { workspace = true }
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"

# Note: `[profile.release]` lives in the workspace Cargo.toml — Cargo
# warns when sub-crates set their own profile in a workspace context.
109 changes: 109 additions & 0 deletions prototype/crates/postern-wasm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! Postern WASM surface — minimum bindings the Astro demo needs.
//!
//! Exposes a single function `rewrite_plan(request)` that takes a
//! JSON value of shape:
//!
//! ```jsonc
//! {
//! "catalog": { "users_data": ["id", "name", ...], ... },
//! "policy": [ { "principal": "CRM", "relation": "users_data", "columns": ["id", "name"] }, ... ],
//! "principal": "CRM",
//! "plan": { "op": "scan", "rel": "users_data" }
//! }
//! ```
//!
//! and returns:
//!
//! ```jsonc
//! {
//! "ok": true,
//! "allowed": ["id", "name", "region", "age"],
//! "input_plan": { ... }, // echoed for the UI
//! "output_plan": { "op": "project", "sub": ..., "cols": [...] }
//! }
//! ```
//!
//! or on refusal:
//!
//! ```jsonc
//! { "ok": false, "reason": "unknown relation" | "filter on forbidden column", "allowed": [...] }
//! ```

use postern_core::{rewrite, Catalog, Plan, Policy};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

#[derive(Debug, Deserialize)]
struct Request {
catalog: Catalog,
policy: Policy,
principal: String,
plan: Plan,
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
enum Response {
Accept {
ok: bool,
allowed: Vec<String>,
input_plan: Plan,
output_plan: Plan,
},
Refuse {
ok: bool,
reason: String,
allowed: Vec<String>,
},
}

/// Rewrite a plan under the supplied catalog + policy + principal.
///
/// Accepts a JS value (deserialised via `serde-wasm-bindgen`),
/// returns a JS value with the result. JS-side typing is intentionally
/// loose — the demo treats this as a JSON-shaped function.
#[wasm_bindgen]
pub fn rewrite_plan(request: JsValue) -> Result<JsValue, JsValue> {
let req: Request = serde_wasm_bindgen::from_value(request)
.map_err(|e| JsValue::from_str(&format!("invalid request: {e}")))?;

let allowed = req.policy.allowed(&req.principal, req.plan.touched());

let response = match rewrite(&req.catalog, &req.policy, &req.principal, &req.plan) {
Some(output_plan) => Response::Accept {
ok: true,
allowed: allowed.clone(),
input_plan: req.plan.clone(),
output_plan,
},
None => {
// Reproduce the refusal-reason logic from postern_core::rewrite
// so the UI can show "why".
let touched = req.plan.touched();
let cat_cols = req.catalog.columns(touched);
let reason = if cat_cols.is_empty() {
format!("unknown relation: {touched}")
} else {
let bad: Vec<String> = req
.plan
.filter_cols()
.into_iter()
.filter(|c| !allowed.contains(c))
.collect();
if bad.is_empty() {
"refused (unspecified)".into()
} else {
format!("filter on forbidden column(s): {}", bad.join(", "))
}
};
Response::Refuse {
ok: false,
reason,
allowed,
}
}
};

serde_wasm_bindgen::to_value(&response)
.map_err(|e| JsValue::from_str(&format!("serialize: {e}")))
}
30 changes: 30 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# build output
dist/
# generated types
.astro/

# dependencies
node_modules/

# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*


# environment variables
.env
.env.production

# wasm-pack output — regenerated by `pnpm build:wasm`
src/wasm/

# macOS-specific files
.DS_Store

# jetbrains setting folder
.idea/

# Cloudflare Wrangler
.wrangler/
Loading
Loading