fix editor
This commit is contained in:
parent
3a7031e462
commit
f6bde50ce3
6 changed files with 305 additions and 50 deletions
|
|
@ -32,9 +32,9 @@ if (!structure) {
|
||||||
const structureId = String(structure.id);
|
const structureId = String(structure.id);
|
||||||
bliss("update-settings", structureId, "--route-prefix", "/_bliss");
|
bliss("update-settings", structureId, "--route-prefix", "/_bliss");
|
||||||
let state = json("structure", structureId);
|
let state = json("structure", structureId);
|
||||||
if (!state.dbs.some((db) => String(db.id) === "0" && db.alias === "bliss")) {
|
// No db is attached: the editor reads structure sources through the /plumbing
|
||||||
bliss("attach-db", structureId, "0", "bliss");
|
// API (owner-checked), not by mounting the prime db — which is no longer
|
||||||
}
|
// attachable to other structures anyway.
|
||||||
|
|
||||||
const templates = [
|
const templates = [
|
||||||
["inspector/slideout", "slideout.eta"],
|
["inspector/slideout", "slideout.eta"],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
function handler(req, res) {
|
// Read a route's source through the plumbing API rather than mounting the prime
|
||||||
const sql = require("db")("bliss").sql;
|
// db directly. The prime db is no longer attachable to other structures, and
|
||||||
const kind = "route";
|
// going through /plumbing means this read is owner-checked: forwarding the
|
||||||
const artifact = sql.prepare("SELECT * FROM routes WHERE id = ?").get(req.params.id);
|
// caller's session cookie, plumbing 404s any route in a structure they can't
|
||||||
if (!artifact) return res.status(404).send("Route not found");
|
// see, so the live editor can only open what the user is already allowed to edit.
|
||||||
res.render("inspector/artifact_editor", { kind, artifact });
|
async function handler(req, res) {
|
||||||
|
const base = `${req.protocol}://${req.get("host")}`;
|
||||||
|
const response = await fetch(`${base}/plumbing/routes/${req.params.id}`, {
|
||||||
|
headers: { cookie: req.headers.cookie || "" },
|
||||||
|
});
|
||||||
|
if (!response.ok) return res.status(response.status).send("Route not found");
|
||||||
|
const artifact = await response.json();
|
||||||
|
res.render("inspector/artifact_editor", { kind: "route", artifact });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
function handler(req, res) {
|
// Read a template's source through the plumbing API rather than mounting the
|
||||||
const sql = require("db")("bliss").sql;
|
// prime db directly (see route-editor.js for the why). Forwarding the caller's
|
||||||
const kind = "template";
|
// session cookie keeps the read owner-checked: plumbing 404s any template in a
|
||||||
const artifact = sql.prepare("SELECT * FROM templates WHERE id = ?").get(req.params.id);
|
// structure the user can't see.
|
||||||
if (!artifact) return res.status(404).send("Template not found");
|
async function handler(req, res) {
|
||||||
res.render("inspector/artifact_editor", { kind, artifact });
|
const base = `${req.protocol}://${req.get("host")}`;
|
||||||
|
const response = await fetch(`${base}/plumbing/templates/${req.params.id}`, {
|
||||||
|
headers: { cookie: req.headers.cookie || "" },
|
||||||
|
});
|
||||||
|
if (!response.ok) return res.status(response.status).send("Template not found");
|
||||||
|
const artifact = await response.json();
|
||||||
|
res.render("inspector/artifact_editor", { kind: "template", artifact });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
141
plumbing.js
141
plumbing.js
|
|
@ -1,27 +1,45 @@
|
||||||
// plumbing.js — read-only JSON utility API.
|
// plumbing.js — read-only JSON utility API.
|
||||||
//
|
//
|
||||||
// This is plumbing, not part of the product surface. The workshop UI renders
|
// This is plumbing, not part of the product surface. The workshop UI renders
|
||||||
// HTML; anything that wants structured data (the bliss CLI, scripts, tooling)
|
// HTML; anything that wants structured data (the bliss CLI, scripts, tooling,
|
||||||
// reads it here instead of scraping fragments. Read-only by design: all writes
|
// the live editor) reads it here instead of scraping fragments. Read-only by
|
||||||
// still go through the real /workshop/* endpoints a human uses, so this stays a
|
// design: all writes still go through the real /workshop/* endpoints a human
|
||||||
// thin mirror of model.* with no business logic of its own.
|
// uses, so this stays a thin mirror of model.* with no business logic of its
|
||||||
|
// own.
|
||||||
|
//
|
||||||
|
// Access control invariant: every row handed out is gated against the structure
|
||||||
|
// it *actually belongs to*, using the same rules as the runtime (see
|
||||||
|
// canAccessStructure / makeLibs in index.js). Structure-scoped URLs additionally
|
||||||
|
// require the artifact to belong to the :id in the path, so you can't launder a
|
||||||
|
// routeId/templateId/dbId from a structure you can't see through one you can.
|
||||||
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const model = require("./db");
|
const model = require("./db");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function currentUserId(req) {
|
||||||
|
return req.session && req.session.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccess(req, structureId) {
|
||||||
|
return model.canAccessStructure(currentUserId(req), structureId);
|
||||||
|
}
|
||||||
|
|
||||||
// Private structures are invisible through the plumbing too. Every structure-
|
// Private structures are invisible through the plumbing too. Every structure-
|
||||||
// scoped route carries :id, so one param guard covers them all; a caller who
|
// scoped route carries :id, so one param guard covers them all; a caller who
|
||||||
// isn't on the member list gets a 404, same as the workshop.
|
// isn't allowed to see it gets a 404, same as the workshop.
|
||||||
router.param("id", (req, res, next, id) => {
|
router.param("id", (req, res, next, id) => {
|
||||||
if (!model.canAccessStructure(req.session && req.session.userId, id)) {
|
if (!canAccess(req, id)) {
|
||||||
return res.status(404).json({ error: "not found" });
|
return res.status(404).json({ error: "not found" });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap a handler so thrown errors come back as JSON instead of an HTML stack.
|
// Wrap a handler so thrown errors come back as JSON instead of an HTML stack.
|
||||||
|
// A null/undefined body is treated as "not found" (404) — helpers below return
|
||||||
|
// null whenever a lookup misses OR the caller isn't allowed to see it, so the
|
||||||
|
// two are deliberately indistinguishable from the outside.
|
||||||
function json(handler) {
|
function json(handler) {
|
||||||
return (req, res) => {
|
return (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -36,16 +54,59 @@ function json(handler) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ownership-checked artifact lookups ----------------------------------
|
||||||
|
// Each returns the row only if it exists AND the caller may see the structure
|
||||||
|
// it belongs to; null otherwise (=> 404). When `structureId` is supplied (a
|
||||||
|
// structure-scoped URL) the artifact must also belong to that structure.
|
||||||
|
|
||||||
|
function routeFor(req, routeId, structureId) {
|
||||||
|
const route = model.getRoute(routeId);
|
||||||
|
if (!route) return null;
|
||||||
|
if (structureId !== undefined && String(route.structure_id) !== String(structureId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!canAccess(req, route.structure_id)) return null;
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateFor(req, templateId, structureId) {
|
||||||
|
const template = model.getTemplate(templateId);
|
||||||
|
if (!template) return null;
|
||||||
|
if (structureId !== undefined && String(template.structure_id) !== String(structureId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!canAccess(req, template.structure_id)) return null;
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A db is visible if it's the structure's own db, or a foreign db aliased in
|
||||||
|
// from a structure the caller can also reach — mirrors makeLibs exactly, so the
|
||||||
|
// plumbing never exposes a private db's library that the runtime would refuse to
|
||||||
|
// mount.
|
||||||
|
function dbVisible(req, appDb, structureId) {
|
||||||
|
const ownDb = String(appDb.db_struct_id) === String(structureId);
|
||||||
|
return ownDb || canAccess(req, appDb.db_struct_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbFor(req, structureId, dbId) {
|
||||||
|
const appDb = model.getDbForStructure(structureId, dbId);
|
||||||
|
if (!appDb) return null;
|
||||||
|
if (!dbVisible(req, appDb, structureId)) return null;
|
||||||
|
return appDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleDbs(req, structureId) {
|
||||||
|
return model
|
||||||
|
.getDbsForStructure(structureId)
|
||||||
|
.filter((appDb) => dbVisible(req, appDb, structureId));
|
||||||
|
}
|
||||||
|
|
||||||
// All structures the caller is allowed to see (private ones they aren't a
|
// All structures the caller is allowed to see (private ones they aren't a
|
||||||
// member of are omitted).
|
// member of are omitted).
|
||||||
router.get(
|
router.get(
|
||||||
"/structures",
|
"/structures",
|
||||||
json((req) =>
|
json((req) =>
|
||||||
model
|
model.getStructures().filter((s) => canAccess(req, s.id)),
|
||||||
.getStructures()
|
|
||||||
.filter((s) =>
|
|
||||||
model.canAccessStructure(req.session && req.session.userId, s.id),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -59,49 +120,64 @@ router.get(
|
||||||
structure,
|
structure,
|
||||||
routes: model.getRoutes(req.params.id),
|
routes: model.getRoutes(req.params.id),
|
||||||
templates: model.getTemplates(req.params.id),
|
templates: model.getTemplates(req.params.id),
|
||||||
dbs: model.getDbsForStructure(req.params.id),
|
dbs: visibleDbs(req, req.params.id),
|
||||||
files: model.getFilesForStruct(req.params.id),
|
files: model.getFilesForStruct(req.params.id),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One route, including its handler source.
|
// One route, including its handler source. Both a structure-scoped form (for
|
||||||
|
// the CLI/sidebar) and an id-only form (for the live editor, which knows the
|
||||||
|
// artifact id but not always the structure) — both owner-checked.
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/routes/:routeId",
|
"/structures/:id/routes/:routeId",
|
||||||
json((req) => model.getRoute(req.params.routeId)),
|
json((req) => routeFor(req, req.params.routeId, req.params.id)),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/routes/:routeId",
|
||||||
|
json((req) => routeFor(req, req.params.routeId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One template, including content + test_object.
|
// One template, including content + test_object.
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/templates/:templateId",
|
"/structures/:id/templates/:templateId",
|
||||||
json((req) => model.getTemplate(req.params.templateId)),
|
json((req) => templateFor(req, req.params.templateId, req.params.id)),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/templates/:templateId",
|
||||||
|
json((req) => templateFor(req, req.params.templateId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One db (scoped to the structure), including its library source.
|
// One db (scoped to the structure), including its library source.
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/dbs/:dbId",
|
"/structures/:id/dbs/:dbId",
|
||||||
json((req) => model.getDbForStructure(req.params.id, req.params.dbId)),
|
json((req) => dbFor(req, req.params.id, req.params.dbId)),
|
||||||
);
|
|
||||||
|
|
||||||
// Files attached to a structure.
|
|
||||||
router.get(
|
|
||||||
"/structures/:id/files",
|
|
||||||
json((req) => model.getFilesForStruct(req.params.id)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Version history (newest first) for a route handler, template, or db library.
|
// Version history (newest first) for a route handler, template, or db library.
|
||||||
// Each row is a summary; fetch /versions/:versionId for the full snapshot.
|
// Each row is a summary; fetch /versions/:versionId for the full snapshot. The
|
||||||
|
// artifact is owner-checked first, so you can only list versions of something
|
||||||
|
// you can already read.
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/routes/:routeId/versions",
|
"/structures/:id/routes/:routeId/versions",
|
||||||
json((req) => model.getVersions("route", req.params.routeId)),
|
json((req) =>
|
||||||
|
routeFor(req, req.params.routeId, req.params.id) &&
|
||||||
|
model.getVersions("route", req.params.routeId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/templates/:templateId/versions",
|
"/structures/:id/templates/:templateId/versions",
|
||||||
json((req) => model.getVersions("template", req.params.templateId)),
|
json((req) =>
|
||||||
|
templateFor(req, req.params.templateId, req.params.id) &&
|
||||||
|
model.getVersions("template", req.params.templateId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/dbs/:dbId/versions",
|
"/structures/:id/dbs/:dbId/versions",
|
||||||
json((req) => model.getVersions("db", req.params.dbId)),
|
json((req) =>
|
||||||
|
dbFor(req, req.params.id, req.params.dbId) &&
|
||||||
|
model.getVersions("db", req.params.dbId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One version, including its full snapshot (the versioned fields). Not scoped
|
// One version, including its full snapshot (the versioned fields). Not scoped
|
||||||
|
|
@ -111,22 +187,17 @@ router.get(
|
||||||
json((req) => {
|
json((req) => {
|
||||||
const version = model.getVersion(req.params.versionId);
|
const version = model.getVersion(req.params.versionId);
|
||||||
if (!version) return null;
|
if (!version) return null;
|
||||||
if (
|
if (!canAccess(req, version.structure_id)) return null;
|
||||||
!model.canAccessStructure(
|
|
||||||
req.session && req.session.userId,
|
|
||||||
version.structure_id,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return version;
|
return version;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Logs for a route. ?since=<id> returns only newer rows (for polling).
|
// Logs for a route. ?since=<id> returns only newer rows (for polling). The route
|
||||||
|
// is owner-checked first so logs never leak from a structure you can't see.
|
||||||
router.get(
|
router.get(
|
||||||
"/structures/:id/routes/:routeId/logs",
|
"/structures/:id/routes/:routeId/logs",
|
||||||
json((req) => {
|
json((req) => {
|
||||||
|
if (!routeFor(req, req.params.routeId, req.params.id)) return null;
|
||||||
const { since } = req.query;
|
const { since } = req.query;
|
||||||
const logs =
|
const logs =
|
||||||
since !== undefined
|
since !== undefined
|
||||||
|
|
|
||||||
83
proof_plumbing_owner_checks.js
Normal file
83
proof_plumbing_owner_checks.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Proof: the /plumbing read API only ever hands out an artifact to a caller who
|
||||||
|
// may see the structure it BELONGS TO — including via the id-only endpoints the
|
||||||
|
// live editor uses, and even when a private db is aliased into a public one.
|
||||||
|
//
|
||||||
|
// It mounts the REAL plumbing.js router on a throwaway express app (temp db),
|
||||||
|
// with a tiny middleware that fakes a logged-in session from an ?as= query, and
|
||||||
|
// makes real HTTP requests. So the assertions exercise the shipped access logic.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const express = require("express");
|
||||||
|
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bliss-plumbing-"));
|
||||||
|
fs.mkdirSync(path.join(tmp, "dbs"));
|
||||||
|
process.chdir(tmp);
|
||||||
|
|
||||||
|
const PROJECT = "/home/happy/code/bliss";
|
||||||
|
const model = require(path.join(PROJECT, "db.js"));
|
||||||
|
model.applyMigrations();
|
||||||
|
|
||||||
|
// ---- scenario ------------------------------------------------------------
|
||||||
|
const alice = model.createUser("alice", "x"); // member of the private structure
|
||||||
|
const bob = model.createUser("bob", "x"); // outsider
|
||||||
|
|
||||||
|
// PRIVATE structure owned by alice: its own route, template, and db.
|
||||||
|
const priv = model.createStructure("private-vault", alice);
|
||||||
|
const privRoute = model.createRoute("GET", "/secret", priv, "handler(){}");
|
||||||
|
const privTemplate = model.createTemplate(priv, "secret_tpl", "top secret", "");
|
||||||
|
const secretsDbId = model.createDb(priv, "secrets");
|
||||||
|
model.setStructurePrivacy(priv, true, alice);
|
||||||
|
|
||||||
|
// PUBLIC structure owned by bob that ALIASES alice's private db under "secrets".
|
||||||
|
const pub = model.createStructure("public-front", bob);
|
||||||
|
model.attachDb(pub, secretsDbId, "secrets");
|
||||||
|
|
||||||
|
// ---- app: real plumbing router, faked session from ?as= ------------------
|
||||||
|
const app = express();
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
const as = req.query.as;
|
||||||
|
req.session = { userId: as === "alice" ? alice : as === "bob" ? bob : null };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/plumbing", require(path.join(PROJECT, "plumbing.js")));
|
||||||
|
const server = app.listen(0);
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
async function get(url) {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}${url}`);
|
||||||
|
return res.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(label, actual, wanted) {
|
||||||
|
const ok = actual === wanted;
|
||||||
|
console.log(` ${ok ? "PASS" : "FAIL"} | ${label} (got ${actual}, want ${wanted})`);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
console.log("\nid-only endpoints (what the live editor calls):");
|
||||||
|
results.push(expect("alice reads her private route ", await get(`/plumbing/routes/${privRoute}?as=alice`), 200));
|
||||||
|
results.push(expect("bob reads alice's private route ", await get(`/plumbing/routes/${privRoute}?as=bob`), 404));
|
||||||
|
results.push(expect("anon reads alice's private route ", await get(`/plumbing/routes/${privRoute}`), 404));
|
||||||
|
results.push(expect("alice reads her private template ", await get(`/plumbing/templates/${privTemplate}?as=alice`), 200));
|
||||||
|
results.push(expect("bob reads alice's private template", await get(`/plumbing/templates/${privTemplate}?as=bob`), 404));
|
||||||
|
|
||||||
|
console.log("\nstructure-scoped endpoints reject id-laundering:");
|
||||||
|
// bob CAN see his public structure, but the route id belongs to the private one.
|
||||||
|
results.push(expect("bob: pub structure + private routeId", await get(`/plumbing/structures/${pub}/routes/${privRoute}?as=bob`), 404));
|
||||||
|
|
||||||
|
console.log("\naliased private db is not leaked through a public structure:");
|
||||||
|
results.push(expect("bob reads aliased private db ", await get(`/plumbing/structures/${pub}/dbs/${secretsDbId}?as=bob`), 404));
|
||||||
|
results.push(expect("alice reads that db via her structure", await get(`/plumbing/structures/${priv}/dbs/${secretsDbId}?as=alice`), 200));
|
||||||
|
|
||||||
|
const pass = results.every(Boolean);
|
||||||
|
console.log(`\n${pass ? "PROVEN" : "FAILED"}: plumbing gates every artifact against its own structure's access rules.`);
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
process.exit(pass ? 0 : 1);
|
||||||
|
})();
|
||||||
88
proof_private_db.js
Normal file
88
proof_private_db.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// Proof: a private structure's DB cannot be mounted by a handler running for a
|
||||||
|
// user who isn't a member — even when that DB is aliased into a PUBLIC structure.
|
||||||
|
//
|
||||||
|
// It drives the REAL model code from db.js (canAccessStructure, getDbsForStructure,
|
||||||
|
// attachDb, ...) against a throwaway database in a temp dir, and reproduces the
|
||||||
|
// gate from index.js:192-211 (makeLibs) verbatim so the throw is the app's, not ours.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// db.js opens "./dbs/0.sqlite" and writes user dbs under "./dbs" relative to CWD.
|
||||||
|
// Point CWD at a fresh temp dir so we never touch the real data.
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bliss-proof-"));
|
||||||
|
fs.mkdirSync(path.join(tmp, "dbs"));
|
||||||
|
process.chdir(tmp);
|
||||||
|
|
||||||
|
const PROJECT = "/home/happy/code/bliss";
|
||||||
|
const model = require(path.join(PROJECT, "db.js"));
|
||||||
|
model.applyMigrations();
|
||||||
|
|
||||||
|
// ---- the exact gate from index.js makeLibs (lines 192-211), copied verbatim ----
|
||||||
|
// Given the structure being served and the requesting user, return a db(alias)
|
||||||
|
// resolver identical to what a route handler receives via require('db').
|
||||||
|
function makeDbResolver(structureId, memberUserId) {
|
||||||
|
const dbs = {};
|
||||||
|
const forbidden = new Set();
|
||||||
|
for (let appDb of model.getDbsForStructure(structureId)) {
|
||||||
|
const ownDb = String(appDb.db_struct_id) === String(structureId);
|
||||||
|
if (!ownDb && !model.canAccessStructure(memberUserId, appDb.db_struct_id)) {
|
||||||
|
forbidden.add(appDb.alias);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
model.getDbInstance(appDb.id); // would-mount
|
||||||
|
}
|
||||||
|
return (alias) => {
|
||||||
|
if (forbidden.has(alias)) {
|
||||||
|
throw new Error(`db '${alias}' is private; you do not have access`);
|
||||||
|
}
|
||||||
|
return dbs[alias];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attempt(label, structureId, userId, alias) {
|
||||||
|
try {
|
||||||
|
makeDbResolver(structureId, userId)(alias);
|
||||||
|
console.log(` MOUNTED | ${label}`);
|
||||||
|
return "mounted";
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` BLOCKED | ${label} -> ${e.message}`);
|
||||||
|
return "blocked";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- scenario ----
|
||||||
|
const alice = model.createUser("alice", "x"); // member of the private structure
|
||||||
|
const bob = model.createUser("bob", "x"); // outsider
|
||||||
|
|
||||||
|
// PRIVATE structure owned by alice, with its own DB "secrets".
|
||||||
|
const priv = model.createStructure("private-vault", alice);
|
||||||
|
const secretsDbId = model.createDb(priv, "secrets");
|
||||||
|
model.setStructurePrivacy(priv, true, alice); // flips private=1, keeps alice a member
|
||||||
|
|
||||||
|
// PUBLIC structure that ALIASES the private DB in under the same name.
|
||||||
|
const pub = model.createStructure("public-front", bob);
|
||||||
|
model.attachDb(pub, secretsDbId, "secrets");
|
||||||
|
|
||||||
|
console.log(`\nprivate structure #${priv} owns db #${secretsDbId} ("secrets"), private=${model.getStructure(priv).private}`);
|
||||||
|
console.log(`public structure #${pub} aliases that same db in as "secrets", private=${model.getStructure(pub).private}\n`);
|
||||||
|
|
||||||
|
console.log("Serving the PUBLIC structure, handler calls require('db')('secrets'):");
|
||||||
|
const r1 = attempt("as bob (outsider) ", pub, bob, "secrets");
|
||||||
|
const r2 = attempt("as anonymous / WS (userId=null)", pub, null, "secrets");
|
||||||
|
const r3 = attempt("as alice (member of private)", pub, alice, "secrets");
|
||||||
|
|
||||||
|
console.log("\nSanity — serving the PRIVATE structure itself (its OWN db, ownDb=true):");
|
||||||
|
const r4 = attempt("as alice (member) ", priv, alice, "secrets");
|
||||||
|
|
||||||
|
// ---- assertions ----
|
||||||
|
const pass =
|
||||||
|
r1 === "blocked" && r2 === "blocked" && r3 === "mounted" && r4 === "mounted";
|
||||||
|
console.log(
|
||||||
|
`\n${pass ? "PROVEN" : "FAILED"}: aliasing a private db into a public structure does NOT leak it — ` +
|
||||||
|
`only users who can access the owning structure can mount it.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
process.exit(pass ? 0 : 1);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue