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
18 changes: 13 additions & 5 deletions docs/decisions/cloud-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,19 @@ users cannot obtain new bundles or keys.
table pages, saved queries, report PDF export and attachment export. A
role gets `FORBIDDEN` for anything its permissions (or the forms,
reports and dashboards it may open) do not grant. No role may run ad hoc
SQL or export attachments. A dashboard grant implies read on the saved
queries of its components and filters and of the forms it embeds; it
grants no form, table, write or action, so an embedded form or report
still needs its own grant. Table listing and schema inspection show only
tables the role may read.
SQL or export attachments. Read on a form, report or dashboard implies
read on the saved queries it shows. For a dashboard that is the queries
of its KPI, table and chart components and of its filter options, never
those of an embedded form or report: an embedded form, report or button
action still needs its own grant, and the form grant is what opens the
form's queries. A `queryId` on any other component kind grants nothing.
Table listing and schema inspection show only tables the role may read.
- Dashboard filters, columns, `visibleWhen` and fixed filter options are
presentation, not row or column security. `run_saved_query` and
`run_saved_query_page` take parameters, filters, sorts and limits from
the caller, so a role that may read a query can read every row and
column it returns. Row- and field-level rules are deferred (PRD §20.1).
To limit what a role sees, grant it a narrower saved query.
Checkpoints, restoring a checkpoint as a copy and resetting installation
data copy or replace every table, so they need unrestricted access. The
readable-table set is cached per session and role and recomputed after a
Expand Down
34 changes: 17 additions & 17 deletions src-tauri/src/authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
//! runtime UI needs to show what a role may open: a form grants its source
//! table, lookup and related-list tables, and option queries; a report
//! grants its dataset table and queries; a dashboard grants read on the
//! queries of its components and filters and of the forms it embeds (never
//! the forms themselves, their tables, or any write or action). Ad hoc SQL
//! queries of its KPI, table and chart components and filter options (never
//! embedded forms or reports, their queries or tables, or any write or
//! action). Dashboard filters and columns are not row or column security:
//! the caller supplies parameters, filters and limits (PRD §20.1). Ad hoc SQL
//! is refused for a role. This is not a defense against a user holding direct database credentials.
use crate::archive::DocumentConfig;
use crate::dashboards::{ComponentKind, Dashboard};
Expand Down Expand Up @@ -211,28 +213,26 @@ fn form_queries(form: &Form) -> impl Iterator<Item = &str> {
source.into_iter().chain(options)
}

/// Saved queries a dashboard reads: component queries, filter choices, and
/// the queries of the forms it embeds.
fn dashboard_queries<'a>(
config: &'a DocumentConfig,
dashboard: &'a Dashboard,
) -> impl Iterator<Item = &'a str> {
/// Saved queries a dashboard reads: KPI, table and chart queries, and filter
/// choices. Embedded forms are excluded: their queries need the form grant
/// (`readable_query`'s `by_form`), since the dashboard refuses forms the role
/// cannot open.
fn dashboard_queries(dashboard: &Dashboard) -> impl Iterator<Item = &str> {
let components = dashboard
.components
.iter()
.filter(|c| {
matches!(
c.kind,
ComponentKind::Kpi | ComponentKind::Table | ComponentKind::Chart
)
})
.filter_map(|c| c.query_id.as_deref());
let filters = dashboard
.filters
.iter()
.filter_map(|f| f.options_query_id.as_deref());
let forms = dashboard
.components
.iter()
.filter(|c| c.kind == ComponentKind::Form)
.filter_map(|c| c.form_id.as_deref())
.filter_map(|id| config.design.forms.iter().find(|f| f.id == id))
.flat_map(form_queries);
components.chain(filters).chain(forms)
components.chain(filters)
}

fn readable_query(config: &DocumentConfig, role: &Role, id: &str) -> bool {
Expand Down Expand Up @@ -261,7 +261,7 @@ fn readable_query(config: &DocumentConfig, role: &Role, id: &str) -> bool {
.dashboards
.iter()
.filter(|d| explicit(role, "dashboard", &d.id, Op::Read))
.any(|d| dashboard_queries(config, d).any(|q| q == id))
.any(|d| dashboard_queries(d).any(|q| q == id))
};
by_form || by_report() || by_dashboard()
}
Expand Down
42 changes: 26 additions & 16 deletions src-tauri/src/authz_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ fn a_report_grants_its_dataset_and_table_queries_for_reading_only() {
));
}

/// `config()` plus a dashboard whose components, filter and embedded forms
/// read queries, and which also embeds a report and runs an action.
/// `config()` plus a dashboard whose components and filter read queries, which
/// also embeds query-reading forms and a report, runs an action, and carries a
/// stray `queryId` on a text component.
fn dashboard_config() -> DocumentConfig {
let mut c = config();
c.dashboards = serde_json::from_value(json!([
Expand All @@ -155,7 +156,8 @@ fn dashboard_config() -> DocumentConfig {
{"id": "e1", "kind": "form", "formId": "f-summary"},
{"id": "e2", "kind": "form", "formId": "f-orders"},
{"id": "r1", "kind": "report", "reportId": "r-invoices"},
{"id": "b1", "kind": "button", "actionId": "act-1"}
{"id": "b1", "kind": "button", "actionId": "act-1"},
{"id": "x1", "kind": "text", "queryId": "q-stray"}
]
},
{"id": "d-other", "name": "Other",
Expand All @@ -166,26 +168,31 @@ fn dashboard_config() -> DocumentConfig {
}

#[test]
fn a_dashboard_grants_read_on_its_queries_and_embedded_form_queries_only() {
fn a_dashboard_grants_read_on_its_component_and_filter_queries_only() {
let c = dashboard_config();
let r = role(vec![grant("dashboard", "d-sales", &[Op::Read])], &[]);
let queries = [
"q-kpi",
"q-chart",
"q-table",
"q-regions",
"q-summary",
"q-status",
];
let queries = ["q-kpi", "q-chart", "q-table", "q-regions"];
for q in queries {
assert!(allows(&c, &r, "query", q, Op::Read, &no_fk), "{q}");
for op in [Op::Create, Op::Update, Op::Delete, Op::Execute] {
assert!(!allows(&c, &r, "query", q, op, &no_fk), "{q} {op:?}");
}
}
// Another dashboard's query, and an embedded report's queries, stay closed.
assert!(!allows(&c, &r, "query", "q-other", Op::Read, &no_fk));
assert!(!allows(&c, &r, "query", "q-lines", Op::Read, &no_fk));
// Another dashboard's query, embedded forms' and reports' queries, and a
// `queryId` on a component kind that runs no query stay closed.
for q in ["q-other", "q-summary", "q-status", "q-lines", "q-stray"] {
assert!(!allows(&c, &r, "query", q, Op::Read, &no_fk), "{q}");
}
// Granting the embedded form opens its queries through the form.
let both = role(
vec![
grant("dashboard", "d-sales", &[Op::Read]),
grant("form", "f-summary", &[Op::Read]),
],
&[],
);
assert!(allows(&c, &both, "query", "q-summary", Op::Read, &no_fk));
assert!(!allows(&c, &both, "query", "q-status", Op::Read, &no_fk));
// Embedded forms, reports, their tables and actions are not granted.
for (kind, id) in [
("form", "f-orders"),
Expand Down Expand Up @@ -218,7 +225,10 @@ fn a_previewed_role_granted_only_a_dashboard_runs_its_queries() {
let check =
|kind: &str, id: &str, op: Op| m.with_session("w", |s| check_session(s, kind, id, op));
assert!(check("query", "q-chart", Op::Read).is_ok());
assert!(check("query", "q-summary", Op::Read).is_ok());
assert_eq!(
check("query", "q-summary", Op::Read).unwrap_err().code,
"FORBIDDEN"
);
assert_eq!(
check("query", "q-other", Op::Read).unwrap_err().code,
"FORBIDDEN"
Expand Down
7 changes: 3 additions & 4 deletions src/dashboards/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,15 @@ function LiveDashboard({
() => ({ ...initial, ...dashboardParams(dashboard.filters, settled) }),
[initial, dashboard.filters, settled],
);
const readable = (id: string) =>
canReadDashboardQuery(config, runtime.roleId, dashboard.id, id);
const readable = (id: string) => canReadDashboardQuery(config, runtime.roleId, dashboard, id);
const queryIds = useMemo(() => {
const ids = dashboard.components
.filter((c) => ["kpi", "table", "chart"].includes(c.kind) && c.queryId)
.map((c) => c.queryId as string);
return [...new Set(ids)].filter((id) =>
canReadDashboardQuery(config, runtime.roleId, dashboard.id, id),
canReadDashboardQuery(config, runtime.roleId, dashboard, id),
);
}, [dashboard.components, dashboard.id, config, runtime.roleId]);
}, [dashboard, config, runtime.roleId]);
const data = useDashboardData(queryIds, params);
const { refresh } = data;

Expand Down
19 changes: 15 additions & 4 deletions src/runtime/rbac.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Dashboard } from "../dashboards/types";
import type { DocumentConfig } from "../lib/types";
import type { ObjectKind, Operation, Permissions, Role } from "./types";

Expand Down Expand Up @@ -75,18 +76,28 @@ export function can(
return entry[flag] === true;
}

/** Saved queries a dashboard shows: KPI, table and chart queries, and filter options. */
export const dashboardQueryIds = (dashboard: Pick<Dashboard, "components" | "filters">) => [
...dashboard.components
.filter((c) => ["kpi", "table", "chart"].includes(c.kind) && c.queryId)
.map((c) => c.queryId as string),
...dashboard.filters.flatMap((f) => (f.optionsQueryId ? [f.optionsQueryId] : [])),
];

/**
* Read access to a query a dashboard component runs: a dashboard grant implies
* read on its own queries (authz.rs `dashboard_queries`), never writes or actions.
* Read access to a query a dashboard shows: a dashboard grant implies read on
* its own queries (authz.rs `dashboard_queries`), never on the queries of the
* forms or reports it embeds, writes, or actions.
*/
export const canReadDashboardQuery = (
config: Pick<DocumentConfig, "roles">,
roleId: string | null,
dashboardId: string,
dashboard: Pick<Dashboard, "id" | "components" | "filters">,
queryId: string,
) =>
can(config, roleId, "query", queryId, "read") ||
can(config, roleId, "dashboard", dashboardId, "read");
(can(config, roleId, "dashboard", dashboard.id, "read") &&
dashboardQueryIds(dashboard).includes(queryId));

/** Error thrown when a runtime write or action is attempted without permission. */
export class PermissionError extends Error {
Expand Down
39 changes: 37 additions & 2 deletions tests/integration/dashboard-rbac.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ it("lets a role granted only a dashboard read the dashboard's queries", async ()
{ column: "amount", value: value("real", amount) },
]);
const [total, regions, secret, home, nav] = [id(), id(), id(), id(), id()];
const [summary, summaryForm, stray] = [id(), id(), id()];
const config = await invoke<Record<string, unknown> & { design: Record<string, unknown> }>(
"read_document_config",
{ windowLabel: "main" },
Expand All @@ -43,6 +44,18 @@ it("lets a role granted only a dashboard read the dashboard's queries", async ()
},
{ id: regions, name: "Regions", sql: "SELECT DISTINCT region FROM sales ORDER BY region" },
{ id: secret, name: "Secret", sql: "SELECT * FROM sales" },
{ id: summary, name: "Summary", sql: "SELECT id, region, amount FROM sales" },
{ id: stray, name: "Stray", sql: "SELECT id FROM sales" },
];
config.design.forms = [
{
id: summaryForm,
name: "Summary",
source: { kind: "query", queryId: summary },
modes: ["list"],
listColumns: ["id", "region", "amount"],
pageSize: 25,
},
];
config.dashboards = [
{
Expand All @@ -52,8 +65,24 @@ it("lets a role granted only a dashboard read the dashboard's queries", async ()
{ id: id(), name: "Region", param: "region", control: "select", optionsQueryId: regions },
],
components: [
{ id: id(), kind: "kpi", title: "Revenue", queryId: total, valueField: "total", placement: at(1) },
{
id: id(),
kind: "kpi",
title: "Revenue",
queryId: total,
valueField: "total",
placement: at(1),
},
{ id: id(), kind: "table", title: "Totals", queryId: total, placement: at(7) },
{
id: id(),
kind: "form",
title: "Summary form",
formId: summaryForm,
mode: "list",
placement: { column: 1, row: 3, columnSpan: 12, rowSpan: 2 },
},
{ id: id(), kind: "text", title: "Note", text: "Hi", queryId: stray, placement: at(1) },
],
},
];
Expand Down Expand Up @@ -85,6 +114,9 @@ it("lets a role granted only a dashboard read the dashboard's queries", async ()
const table = await screen.findByRole("region", { name: "Totals" }, LONG);
expect(await within(table).findByText("500", {}, LONG)).toBeInTheDocument();
expect(screen.queryByText("You do not have access to this data.")).toBeNull();
expect(
await screen.findByText("You do not have access to Summary.", {}, LONG),
).toBeInTheDocument();
const filter = screen.getByRole("combobox", { name: "Region" });
await within(filter).findByRole("option", { name: "West" }, LONG);
await user.selectOptions(filter, "East");
Expand All @@ -96,11 +128,14 @@ it("lets a role granted only a dashboard read the dashboard's queries", async ()
(error: unknown) => asTauriError(error).code,
);
await waitFor(
async () => expect(await code("run_saved_query", { id: secret, limit: null })).toBe("FORBIDDEN"),
async () =>
expect(await code("run_saved_query", { id: secret, limit: null })).toBe("FORBIDDEN"),
LONG,
);
expect(await code("run_saved_query", { id: total, limit: null })).toBe("OK");
expect(await code("run_saved_query", { id: regions, limit: null })).toBe("OK");
expect(await code("run_saved_query", { id: summary, limit: null })).toBe("FORBIDDEN");
expect(await code("run_saved_query", { id: stray, limit: null })).toBe("FORBIDDEN");
const page = { table: "sales", offset: 0, limit: 10, sorts: [], filters: [] };
expect(await code("read_table_page", page)).toBe("FORBIDDEN");
const row = [{ column: "id", value: value("integer", 9) }];
Expand Down
28 changes: 24 additions & 4 deletions tests/unit/rbac.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import type { Dashboard } from "../../src/dashboards/types";
import type { DocumentConfig } from "../../src/lib/types";
import { canOpen, startPage, visibleNavigation } from "../../src/runtime/navigation";
import {
can,
canReadDashboardQuery,
dashboardQueryIds,
setObjectPermission,
toggleListed,
} from "../../src/runtime/rbac";
Expand Down Expand Up @@ -74,12 +76,30 @@ describe("rbac.can", () => {
},
};
const withViewer = { ...config, roles: [clerk, viewer] } as DocumentConfig;
expect(canReadDashboardQuery(withViewer, "viewer", "d1", "q-sales")).toBe(true);
expect(canReadDashboardQuery(withViewer, "viewer", "d2", "q-sales")).toBe(false);
const component = (kind: string, extra: Record<string, string>) =>
({ id: kind, kind, ...extra }) as unknown as Dashboard["components"][number];
const d1 = {
id: "d1",
filters: [{ id: "f", name: "Region", param: "region", optionsQueryId: "q-regions" }],
components: [
component("kpi", { queryId: "q-sales" }),
component("chart", { queryId: "q-chart" }),
component("table", { queryId: "q-table" }),
component("form", { formId: "orders-form", queryId: "q-form" }),
component("text", { queryId: "q-stray" }),
],
} as Pick<Dashboard, "id" | "components" | "filters">;
const d2 = { ...d1, id: "d2" };
expect(dashboardQueryIds(d1)).toEqual(["q-sales", "q-chart", "q-table", "q-regions"]);
for (const q of ["q-sales", "q-chart", "q-table", "q-regions"])
expect(canReadDashboardQuery(withViewer, "viewer", d1, q)).toBe(true);
for (const q of ["q-form", "q-stray", "q-other"])
expect(canReadDashboardQuery(withViewer, "viewer", d1, q)).toBe(false);
expect(canReadDashboardQuery(withViewer, "viewer", d2, "q-sales")).toBe(false);
expect(can(withViewer, "viewer", "query", "q-sales", "read")).toBe(false);
expect(can(withViewer, "viewer", "query", "q-sales", "update")).toBe(false);
expect(canReadDashboardQuery(withViewer, "clerk", "d1", "q-sales")).toBe(false);
expect(canReadDashboardQuery(withViewer, null, "d1", "q-sales")).toBe(true);
expect(canReadDashboardQuery(withViewer, "clerk", d1, "q-sales")).toBe(false);
expect(canReadDashboardQuery(withViewer, null, d1, "q-sales")).toBe(true);
});

it("denies unknown roles", () => {
Expand Down
4 changes: 3 additions & 1 deletion web/docs/cloud/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ Revocation does not reach into a computer. A key grant already issued stays vali

## PostgreSQL and runtime roles

The Runtime enforces runtime roles in navigation, queries, forms, reports, dashboards, and actions. A role that may open a form, report, or dashboard may also read the saved queries it shows. That never lets the role change records or run actions. For an app that connects straight to PostgreSQL, runtime roles are not a defense against an authorized user who extracts the database credential. That user can connect with any database client and do whatever the credential allows.
The Runtime enforces runtime roles in navigation, queries, forms, reports, dashboards, and actions. A role that may open a form, report, or dashboard may also read the saved queries it shows. That never lets the role change records or run actions.

Dashboard filters and columns are not row or column security. A role that may read a query can run it with any filter values and read every row and column it returns. Row and field rules are not available yet. To limit what a role sees, write a narrower saved query and grant that one. For an app that connects straight to PostgreSQL, runtime roles are not a defense against an authorized user who extracts the database credential. That user can connect with any database client and do whatever the credential allows.

For database-level isolation, create a separate least-privileged database role for each runtime user or group. Grant it only the tables and rows it needs, and deliver it as a per-user credential. ixtable Cloud does not host or back up your PostgreSQL database. Restoring an app archive does not restore PostgreSQL records.

Expand Down
4 changes: 3 additions & 1 deletion web/docs/guides/dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ The dashboard runs each distinct query once when it opens, and components that s

A navigation item can open a dashboard in the Runtime. An action's Open dashboard step can open one with parameter values, which become the initial filter values. An action button on a dashboard cannot set form state. In Studio's View tab, a button that navigates shows a note instead, because navigation happens only in the Runtime.

A role needs read access to open a dashboard, and separate grants for what is on it. Each KPI, chart, and table needs read on its query. Each report needs read on that report, and each button needs the right to run its action. [Roles and permissions](./roles) explains the grants.
A role needs read access to open a dashboard. That grant also lets the role read the queries behind the dashboard's KPIs, charts, tables, and filter choices. Each embedded form and report needs its own read grant, and each button needs the right to run its action. [Roles and permissions](./roles) explains the grants.

Filters and table columns shape what the dashboard shows. They do not hide data from a role. A role that can read a query can read every row and column it returns, so grant a narrower query when a role must see less.

## Next steps

Expand Down
Loading
Loading