diff --git a/docs/decisions/cloud-security-model.md b/docs/decisions/cloud-security-model.md index 93dab4ea..0c3c1005 100644 --- a/docs/decisions/cloud-security-model.md +++ b/docs/decisions/cloud-security-model.md @@ -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 diff --git a/src-tauri/src/authz.rs b/src-tauri/src/authz.rs index 756eb267..46281ad3 100644 --- a/src-tauri/src/authz.rs +++ b/src-tauri/src/authz.rs @@ -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}; @@ -211,28 +213,26 @@ fn form_queries(form: &Form) -> impl Iterator { 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 { +/// 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 { 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 { @@ -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() } diff --git a/src-tauri/src/authz_tests.rs b/src-tauri/src/authz_tests.rs index 298e8be7..f2bf7846 100644 --- a/src-tauri/src/authz_tests.rs +++ b/src-tauri/src/authz_tests.rs @@ -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!([ @@ -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", @@ -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"), @@ -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" diff --git a/src/dashboards/DashboardView.tsx b/src/dashboards/DashboardView.tsx index 399e483d..a8fe6253 100644 --- a/src/dashboards/DashboardView.tsx +++ b/src/dashboards/DashboardView.tsx @@ -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; diff --git a/src/runtime/rbac.ts b/src/runtime/rbac.ts index 4a061607..046566ba 100644 --- a/src/runtime/rbac.ts +++ b/src/runtime/rbac.ts @@ -1,3 +1,4 @@ +import type { Dashboard } from "../dashboards/types"; import type { DocumentConfig } from "../lib/types"; import type { ObjectKind, Operation, Permissions, Role } from "./types"; @@ -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 + .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, roleId: string | null, - dashboardId: string, + dashboard: Pick, 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 { diff --git a/tests/integration/dashboard-rbac.test.tsx b/tests/integration/dashboard-rbac.test.tsx index 2f03bef1..1fc0b30c 100644 --- a/tests/integration/dashboard-rbac.test.tsx +++ b/tests/integration/dashboard-rbac.test.tsx @@ -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 & { design: Record }>( "read_document_config", { windowLabel: "main" }, @@ -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 = [ { @@ -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) }, ], }, ]; @@ -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"); @@ -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) }]; diff --git a/tests/unit/rbac.test.ts b/tests/unit/rbac.test.ts index c0ec6471..04ebd59f 100644 --- a/tests/unit/rbac.test.ts +++ b/tests/unit/rbac.test.ts @@ -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"; @@ -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) => + ({ 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; + 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", () => { diff --git a/web/docs/cloud/security.md b/web/docs/cloud/security.md index 6c1aa7dd..9a3afd8b 100644 --- a/web/docs/cloud/security.md +++ b/web/docs/cloud/security.md @@ -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. diff --git a/web/docs/guides/dashboards.md b/web/docs/guides/dashboards.md index 4420d169..b609e019 100644 --- a/web/docs/guides/dashboards.md +++ b/web/docs/guides/dashboards.md @@ -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 diff --git a/web/docs/guides/queries.md b/web/docs/guides/queries.md index 6b630bff..7dc0f0a1 100644 --- a/web/docs/guides/queries.md +++ b/web/docs/guides/queries.md @@ -91,7 +91,7 @@ A run returns at most 10,000 rows and says so when it stops there. When a query ## Permissions -Each saved query is an object in the Roles tab. A role needs read access to a query to run it, directly or through a dashboard component or an action step. Ad hoc SQL in Query mode is available only to the developer, never to a role. See [Roles and permissions](./roles). +Each saved query is an object in the Roles tab. A role can run a query when it has read on the query, or read on a form, report, or dashboard that shows the query. An action step still needs read on its query. Filters do not limit which rows a role can read, so grant a narrower query when a role must see less. Ad hoc SQL in Query mode is available only to the developer, never to a role. See [Roles and permissions](./roles). ## Next steps diff --git a/web/docs/guides/roles.md b/web/docs/guides/roles.md index 9f0b8a11..67adfd72 100644 --- a/web/docs/guides/roles.md +++ b/web/docs/guides/roles.md @@ -21,7 +21,9 @@ The **Objects** table lists every form, report, dashboard, table, and saved quer A grant on a form or report also lets the role read the tables and queries it needs to load, such as the source of a lookup. -A dashboard needs more than its own read grant. Each number, chart, and table on it needs read on its query, and each report on it needs read on that report. +Read on a form, report, or dashboard also lets the role read the saved queries it shows. For a dashboard, that covers its numbers, charts, tables, and filter choices. It does not cover embedded forms and reports. Each of those needs its own grant, and each button needs its action. + +Permissions apply to whole objects. Dashboard filters and columns do not limit which rows or columns a role can read. To show a role less, write a narrower saved query and grant that one. ## Navigation