Fullfør oppgave 4.4: RLS-policies på PG med node_access-filtrering
Implementerer Row Level Security for tunge PostgreSQL-spørringer. Maskinrommet skriver som superuser (sidelinja), men leser med SET LOCAL ROLE synops_reader som er underlagt RLS-policies. Endringer: - Migration 004: synops_reader rolle, current_node_id() funksjon, RLS-policies på nodes (created_by/node_access/visibility), edges (endepunkt-tilgang + system-edge-skjuling), og node_access (kun egne rader) - queries.rs: RLS-kontekst-helper (set_rls_context) og GET /query/nodes endepunkt med søk, filtrering og paginering - migration_safety.md: omskrevet fra v1 workspace-RLS til node_access-basert RLS med oppdaterte leak hunter-tester Verifisert på server: hidden noder filtrert for ukjente brukere, synlige for eiere. Edges filtrert tilsvarende. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
50a6934f05
commit
1355d189b2
6 changed files with 457 additions and 160 deletions
|
|
@ -1,157 +1,132 @@
|
||||||
# Migration Safety Checklist
|
# Migration Safety Checklist
|
||||||
|
|
||||||
> **Merk:** Denne sjekklisten er skrevet for v1-arkitekturen der RLS var
|
Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.
|
||||||
> basert på `workspace_id`-kolonner og `SET app.current_workspace_id`.
|
|
||||||
> Workspace-modellen er erstattet av en node-basert tilgangsmatrise (se
|
## RLS-modell
|
||||||
> `docs/retninger/bruker_ikke_workspace.md`). Sjekklisten må skrives om for
|
|
||||||
> det nye mønsteret: `node_access`-matrise, edge-basert tilgang, og
|
Synops bruker `node_access`-matrisen for tilgangskontroll, ikke workspace-scope.
|
||||||
> RLS-policies som opererer på bruker→node-edges i stedet for workspace-scope.
|
Maskinrommet kobler til som superuser (`sidelinja`) for skriveoperasjoner.
|
||||||
>
|
For tunge lesespørringer brukes `SET LOCAL ROLE synops_reader`, som er underlagt RLS.
|
||||||
> Seksjonene under er bevart som referanse for v1-mønsteret.
|
|
||||||
|
Sesjonsvariabel: `app.current_node_id` settes til brukerens node-UUID.
|
||||||
Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.
|
Funksjon: `current_node_id()` leser denne variabelen.
|
||||||
|
|
||||||
## Før migrering
|
RLS-policies finnes på: `nodes`, `edges`, `node_access`.
|
||||||
|
|
||||||
- [ ] Les migrasjonfilen og forstå hva den gjør
|
## Før migrering
|
||||||
- [ ] Har migrasjonen en tilhørende **down-migrering**? (påkrevd for skjema-endringer)
|
|
||||||
- [ ] Tar migrasjonen backup-hensyn? (dropper den kolonner/tabeller med data?)
|
- [ ] Les migrasjonfilen og forstå hva den gjør
|
||||||
|
- [ ] Tar migrasjonen backup-hensyn? (dropper den kolonner/tabeller med data?)
|
||||||
## Etter migrering
|
- [ ] Påvirker den RLS-policies? Sjekk at ingen policy fjernes utilsiktet.
|
||||||
|
|
||||||
### RLS-verifisering (KRITISK)
|
## Etter migrering
|
||||||
Etter *enhver* migrering som oppretter eller endrer tabeller med `workspace_id`:
|
|
||||||
|
### RLS-verifisering (KRITISK)
|
||||||
```sql
|
Etter *enhver* migrering som oppretter eller endrer tabeller:
|
||||||
-- 1. Verifiser at RLS er aktivert på alle workspace-tabeller
|
|
||||||
SELECT tablename, rowsecurity
|
```sql
|
||||||
FROM pg_tables
|
-- 1. Verifiser at RLS er aktivert på relevante tabeller
|
||||||
WHERE schemaname = 'public'
|
SELECT tablename, rowsecurity
|
||||||
AND tablename IN ('nodes', 'graph_edges', 'messages', 'channels',
|
FROM pg_tables
|
||||||
'media_files', 'job_queue', 'message_attachments')
|
WHERE schemaname = 'public'
|
||||||
ORDER BY tablename;
|
AND tablename IN ('nodes', 'edges', 'node_access')
|
||||||
-- Forventet: rowsecurity = true for alle
|
ORDER BY tablename;
|
||||||
|
-- Forventet: rowsecurity = true for alle
|
||||||
-- 2. Verifiser at policies eksisterer
|
|
||||||
SELECT tablename, policyname, cmd, qual
|
-- 2. Verifiser at policies eksisterer
|
||||||
FROM pg_policies
|
SELECT tablename, policyname, cmd
|
||||||
WHERE schemaname = 'public'
|
FROM pg_policies
|
||||||
ORDER BY tablename;
|
WHERE schemaname = 'public'
|
||||||
-- Forventet: workspace_isolation_* policy for hver tabell
|
ORDER BY tablename;
|
||||||
|
-- Forventet: node_select, edge_select, na_select
|
||||||
-- 3. Test isolasjon: sett workspace A, forsøk å lese workspace B
|
|
||||||
SET app.current_workspace_id = '<workspace_a_uuid>';
|
-- 3. Test isolasjon: sett bruker A, forsøk å lese hidden node opprettet av bruker B
|
||||||
SELECT count(*) FROM nodes WHERE workspace_id = '<workspace_b_uuid>';
|
BEGIN;
|
||||||
-- Forventet: 0 rader (RLS blokkerer)
|
SET LOCAL app.current_node_id = '<bruker_a_uuid>';
|
||||||
|
SET LOCAL ROLE synops_reader;
|
||||||
-- 4. Verifiser at superuser IKKE blokkeres (Rust workers trenger dette)
|
SELECT count(*) FROM nodes WHERE created_by = '<bruker_b_uuid>' AND visibility = 'hidden';
|
||||||
RESET app.current_workspace_id;
|
-- Forventet: 0 rader (RLS blokkerer)
|
||||||
SET ROLE postgres;
|
COMMIT;
|
||||||
SELECT count(*) FROM nodes;
|
|
||||||
-- Forventet: alle rader synlige
|
-- 4. Verifiser at superuser IKKE blokkeres (maskinrommet skriver som superuser)
|
||||||
```
|
SELECT count(*) FROM nodes;
|
||||||
|
-- Forventet: alle rader synlige (superuser bypasser RLS)
|
||||||
### Indeksverifisering
|
```
|
||||||
```sql
|
|
||||||
-- Sjekk at viktige indekser finnes
|
### Indeksverifisering
|
||||||
SELECT indexname, tablename
|
```sql
|
||||||
FROM pg_indexes
|
SELECT indexname, tablename
|
||||||
WHERE schemaname = 'public'
|
FROM pg_indexes
|
||||||
AND tablename IN ('nodes', 'graph_edges', 'messages')
|
WHERE schemaname = 'public'
|
||||||
ORDER BY tablename;
|
AND tablename IN ('nodes', 'edges', 'node_access')
|
||||||
```
|
ORDER BY tablename;
|
||||||
|
-- Viktige indekser: idx_na_subject, idx_na_object (for RLS-ytelse)
|
||||||
### Constraint-verifisering
|
```
|
||||||
```sql
|
|
||||||
-- Sjekk at foreign keys er intakte
|
### Constraint-verifisering
|
||||||
SELECT tc.table_name, tc.constraint_name, tc.constraint_type
|
```sql
|
||||||
FROM information_schema.table_constraints tc
|
SELECT tc.table_name, tc.constraint_name, tc.constraint_type
|
||||||
WHERE tc.table_schema = 'public'
|
FROM information_schema.table_constraints tc
|
||||||
AND tc.constraint_type IN ('FOREIGN KEY', 'CHECK', 'UNIQUE')
|
WHERE tc.table_schema = 'public'
|
||||||
ORDER BY tc.table_name;
|
AND tc.constraint_type IN ('FOREIGN KEY', 'CHECK', 'UNIQUE')
|
||||||
```
|
ORDER BY tc.table_name;
|
||||||
|
```
|
||||||
## RLS Leak Hunter (CI-test)
|
|
||||||
|
## RLS Leak Hunter (CI-test)
|
||||||
`SET app.current_workspace_id` er en skjult single point of failure — en glemt SET i en ny feature, en feil i connection-pool, eller en ny tjeneste som kobler til PG uten middleware kan føre til cross-workspace datalekkasje. Denne testen fanger det opp.
|
|
||||||
|
`app.current_node_id` er en single point of failure — en glemt SET i en ny
|
||||||
### Automatisk CI-test (to-workspace leak detection)
|
feature eller en direkte PG-tilkobling uten `SET LOCAL ROLE synops_reader`
|
||||||
Kjøres i migrasjonstester og som egen CI-steg:
|
kan føre til datalekkasje. Denne testen fanger det opp.
|
||||||
|
|
||||||
```sql
|
### Automatisk test (to-bruker leak detection)
|
||||||
-- Opprett to test-workspaces
|
|
||||||
INSERT INTO workspaces (id, name, slug) VALUES
|
```sql
|
||||||
('aaaaaaaa-0000-0000-0000-000000000001', 'Workspace A', 'ws-a'),
|
-- Opprett to testbrukere
|
||||||
('aaaaaaaa-0000-0000-0000-000000000002', 'Workspace B', 'ws-b');
|
INSERT INTO nodes (id, node_kind, title, visibility, created_by) VALUES
|
||||||
|
('aaaaaaaa-0000-0000-0000-000000000001', 'person', 'Test A', 'hidden', NULL),
|
||||||
-- Seed testdata i begge
|
('aaaaaaaa-0000-0000-0000-000000000002', 'person', 'Test B', 'hidden', NULL);
|
||||||
INSERT INTO nodes (id, node_type, workspace_id) VALUES
|
|
||||||
('bbbbbbbb-0000-0000-0000-000000000001', 'tema', 'aaaaaaaa-0000-0000-0000-000000000001'),
|
-- Opprett hidden noder for hver bruker
|
||||||
('bbbbbbbb-0000-0000-0000-000000000002', 'tema', 'aaaaaaaa-0000-0000-0000-000000000002');
|
INSERT INTO nodes (id, node_kind, title, visibility, created_by) VALUES
|
||||||
|
('bbbbbbbb-0000-0000-0000-000000000001', 'content', 'Hemmelighet A', 'hidden',
|
||||||
-- TEST 1: Sett workspace A, forsøk å lese workspace B
|
'aaaaaaaa-0000-0000-0000-000000000001'),
|
||||||
SET app.current_workspace_id = 'aaaaaaaa-0000-0000-0000-000000000001';
|
('bbbbbbbb-0000-0000-0000-000000000002', 'content', 'Hemmelighet B', 'hidden',
|
||||||
DO $$
|
'aaaaaaaa-0000-0000-0000-000000000002');
|
||||||
BEGIN
|
|
||||||
IF (SELECT count(*) FROM nodes WHERE workspace_id = 'aaaaaaaa-0000-0000-0000-000000000002') > 0 THEN
|
-- TEST 1: Bruker A skal ikke se Bruker B sine hidden noder
|
||||||
RAISE EXCEPTION 'RLS LEAK: Workspace A kan lese Workspace B sine noder!';
|
BEGIN;
|
||||||
END IF;
|
SET LOCAL app.current_node_id = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||||
END $$;
|
SET LOCAL ROLE synops_reader;
|
||||||
|
DO $$
|
||||||
-- TEST 2: Uten SET (tom current_setting) skal returnere 0 rader
|
BEGIN
|
||||||
RESET app.current_workspace_id;
|
IF (SELECT count(*) FROM nodes WHERE id = 'bbbbbbbb-0000-0000-0000-000000000002') > 0 THEN
|
||||||
DO $$
|
RAISE EXCEPTION 'RLS LEAK: Bruker A kan lese Bruker B sine hidden noder!';
|
||||||
BEGIN
|
END IF;
|
||||||
-- For vanlig bruker (ikke superuser) bør dette returnere 0
|
END $$;
|
||||||
IF (SELECT count(*) FROM nodes) > 0 AND current_setting('is_superuser') = 'off' THEN
|
COMMIT;
|
||||||
RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese data!';
|
|
||||||
END IF;
|
-- TEST 2: Uten satt bruker-id skal hidden noder være usynlige
|
||||||
END $$;
|
BEGIN;
|
||||||
```
|
SET LOCAL ROLE synops_reader;
|
||||||
|
DO $$
|
||||||
### Audit-trigger (produksjon)
|
BEGIN
|
||||||
Valgfri trigger som logger mistenkelige queries i prod:
|
IF (SELECT count(*) FROM nodes WHERE visibility = 'hidden') > 0 THEN
|
||||||
|
RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese hidden data!';
|
||||||
```sql
|
END IF;
|
||||||
-- Tabell for RLS-audit
|
END $$;
|
||||||
CREATE TABLE IF NOT EXISTS rls_audit_log (
|
COMMIT;
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
table_name TEXT NOT NULL,
|
-- Rydd opp
|
||||||
operation TEXT NOT NULL,
|
DELETE FROM nodes WHERE id IN (
|
||||||
current_workspace TEXT,
|
'aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002',
|
||||||
session_user TEXT NOT NULL,
|
'bbbbbbbb-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000002'
|
||||||
query_timestamp TIMESTAMPTZ NOT NULL DEFAULT now()
|
);
|
||||||
);
|
```
|
||||||
|
|
||||||
-- Funksjon som logger når current_workspace_id ikke er satt
|
## Nye tabeller
|
||||||
CREATE OR REPLACE FUNCTION audit_rls_context() RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
Hvis en ny tabell opprettes som inneholder brukerdata:
|
||||||
IF current_setting('app.current_workspace_id', true) IS NULL
|
1. Aktiver RLS: `ALTER TABLE ny_tabell ENABLE ROW LEVEL SECURITY;`
|
||||||
OR current_setting('app.current_workspace_id', true) = '' THEN
|
2. Opprett SELECT-policy for `synops_reader` med `current_node_id()`-sjekk
|
||||||
IF current_setting('is_superuser') = 'off' THEN
|
3. Grant SELECT til `synops_reader`
|
||||||
INSERT INTO rls_audit_log (table_name, operation, current_workspace, session_user)
|
4. Kjør leak hunter
|
||||||
VALUES (TG_TABLE_NAME, TG_OP, current_setting('app.current_workspace_id', true), session_user);
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Kjør leak hunter mot ALLE tabeller med workspace_id — ikke bare de som er listet over.** Nye tabeller legges til i listen automatisk via introspeksjon:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Finn alle tabeller med workspace_id-kolonne (bør alle ha RLS)
|
|
||||||
SELECT t.tablename
|
|
||||||
FROM pg_tables t
|
|
||||||
JOIN information_schema.columns c ON c.table_name = t.tablename
|
|
||||||
WHERE c.column_name = 'workspace_id'
|
|
||||||
AND t.schemaname = 'public'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_policies p WHERE p.tablename = t.tablename
|
|
||||||
);
|
|
||||||
-- Forventet: 0 rader. Enhver rad her = tabell med workspace_id UTEN RLS-policy.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Automatisering
|
|
||||||
Disse sjekkene kjøres automatisk i migrasjonstestene (se `docs/arkitektur.md` §10.2). Manuell kjøring er kun nødvendig ved prod-migrasjoner til automatiserte tester er på plass. **RLS Leak Hunter bør prioriteres som første CI-steg — den beskytter mot den mest alvorlige feilkategorien (cross-workspace datalekkasje).**
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "ope
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
error: String,
|
pub error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod auth;
|
mod auth;
|
||||||
mod intentions;
|
mod intentions;
|
||||||
|
mod queries;
|
||||||
mod stdb;
|
mod stdb;
|
||||||
mod warmup;
|
mod warmup;
|
||||||
|
|
||||||
|
|
@ -117,6 +118,7 @@ async fn main() {
|
||||||
.route("/intentions/create_edge", post(intentions::create_edge))
|
.route("/intentions/create_edge", post(intentions::create_edge))
|
||||||
.route("/intentions/update_node", post(intentions::update_node))
|
.route("/intentions/update_node", post(intentions::update_node))
|
||||||
.route("/intentions/delete_node", post(intentions::delete_node))
|
.route("/intentions/delete_node", post(intentions::delete_node))
|
||||||
|
.route("/query/nodes", get(queries::query_nodes))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
|
||||||
202
maskinrommet/src/queries.rs
Normal file
202
maskinrommet/src/queries.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// Tunge spørringer — lesestien via PostgreSQL med RLS.
|
||||||
|
//
|
||||||
|
// For søk, statistikk, og graf-traversering brukes PG direkte (ikke STDB).
|
||||||
|
// Alle spørringer kjøres med SET LOCAL ROLE synops_reader, som er underlagt
|
||||||
|
// RLS-policies. Brukerens node_id settes som sesjonsvariabel.
|
||||||
|
//
|
||||||
|
// Ref: docs/retninger/datalaget.md (tunge spørringer-seksjonen)
|
||||||
|
|
||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::intentions::ErrorResponse;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RLS-kontekst
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Setter opp RLS-kontekst for en transaksjon.
|
||||||
|
/// Etter dette kallet er alle SELECT-spørringer filtrert via node_access.
|
||||||
|
///
|
||||||
|
/// MÅ kalles innenfor en transaksjon (SET LOCAL gjelder kun innenfor tx).
|
||||||
|
async fn set_rls_context(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
user_node_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
// Sett brukerens node_id som sesjonsvariabel
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SET LOCAL app.current_node_id = '{}'",
|
||||||
|
user_node_id
|
||||||
|
))
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Bytt til synops_reader-rollen (underlagt RLS)
|
||||||
|
sqlx::query("SET LOCAL ROLE synops_reader")
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /query/nodes — søk og filtrering av noder
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct QueryNodesRequest {
|
||||||
|
/// Fritekst-søk i tittel og innhold. Valgfritt.
|
||||||
|
pub q: Option<String>,
|
||||||
|
/// Filtrer på node_kind. Valgfritt.
|
||||||
|
pub kind: Option<String>,
|
||||||
|
/// Maks antall resultater. Default: 50.
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
/// Offset for paginering. Default: 0.
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct QueryNodeResult {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub node_kind: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub visibility: String,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub created_by: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct QueryNodesResponse {
|
||||||
|
pub nodes: Vec<QueryNodeResult>,
|
||||||
|
pub total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: msg.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /query/nodes?q=...&kind=...&limit=...&offset=...
|
||||||
|
///
|
||||||
|
/// Søk og filtrering av noder. Bruker RLS — returnerer kun noder
|
||||||
|
/// brukeren har tilgang til.
|
||||||
|
pub async fn query_nodes(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<QueryNodesRequest>,
|
||||||
|
) -> Result<Json<QueryNodesResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let limit = params.limit.unwrap_or(50).min(200);
|
||||||
|
let offset = params.offset.unwrap_or(0).max(0);
|
||||||
|
|
||||||
|
let result = run_query_nodes(&state.db, user.node_id, ¶ms.q, ¶ms.kind, limit, offset).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => Ok(Json(resp)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "query_nodes feilet");
|
||||||
|
Err(internal_error("Databasefeil ved søk"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_query_nodes(
|
||||||
|
db: &PgPool,
|
||||||
|
user_node_id: Uuid,
|
||||||
|
q: &Option<String>,
|
||||||
|
kind: &Option<String>,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<QueryNodesResponse, sqlx::Error> {
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
set_rls_context(&mut tx, user_node_id).await?;
|
||||||
|
|
||||||
|
// Bygg spørring basert på filtre
|
||||||
|
let (nodes, total) = if let Some(search) = q.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
let search_pattern = format!("%{}%", search.replace('%', "\\%").replace('_', "\\_"));
|
||||||
|
|
||||||
|
let nodes = sqlx::query_as::<_, QueryNodeResult>(
|
||||||
|
r#"
|
||||||
|
SELECT id, node_kind, title, content, visibility::text as visibility,
|
||||||
|
metadata, created_at, created_by
|
||||||
|
FROM nodes
|
||||||
|
WHERE (title ILIKE $1 OR content ILIKE $1)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&search_pattern)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM nodes WHERE (title ILIKE $1 OR content ILIKE $1)",
|
||||||
|
)
|
||||||
|
.bind(&search_pattern)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
(nodes, total.0)
|
||||||
|
} else if let Some(kind) = kind.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
let nodes = sqlx::query_as::<_, QueryNodeResult>(
|
||||||
|
r#"
|
||||||
|
SELECT id, node_kind, title, content, visibility::text as visibility,
|
||||||
|
metadata, created_at, created_by
|
||||||
|
FROM nodes
|
||||||
|
WHERE node_kind = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM nodes WHERE node_kind = $1",
|
||||||
|
)
|
||||||
|
.bind(kind)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
(nodes, total.0)
|
||||||
|
} else {
|
||||||
|
let nodes = sqlx::query_as::<_, QueryNodeResult>(
|
||||||
|
r#"
|
||||||
|
SELECT id, node_kind, title, content, visibility::text as visibility,
|
||||||
|
metadata, created_at, created_by
|
||||||
|
FROM nodes
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM nodes")
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
(nodes, total.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transaksjon avsluttes — SET LOCAL tilbakestilles automatisk
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(QueryNodesResponse { nodes, total })
|
||||||
|
}
|
||||||
119
migrations/004_rls_policies.sql
Normal file
119
migrations/004_rls_policies.sql
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
-- 004_rls_policies.sql
|
||||||
|
-- RLS-policies på nodes og edges, basert på node_access-matrisen.
|
||||||
|
--
|
||||||
|
-- Maskinrommet kobler til som superuser (sidelinja) for skriveoperasjoner.
|
||||||
|
-- For tunge lesespørringer (søk, statistikk, graf-traversering) brukes
|
||||||
|
-- SET LOCAL ROLE synops_reader, som er underlagt RLS.
|
||||||
|
--
|
||||||
|
-- Ref: docs/retninger/bruker_ikke_workspace.md (RLS-policy-spesifikasjon)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Applikasjonsrolle for RLS-filtrerte spørringer
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synops_reader') THEN
|
||||||
|
CREATE ROLE synops_reader NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Gi lesetilgang til alle relevante tabeller
|
||||||
|
GRANT SELECT ON nodes, edges, node_access, auth_identities TO synops_reader;
|
||||||
|
|
||||||
|
-- Tillat synops_reader å lese sekvenser (for eventuelle funksjoner)
|
||||||
|
GRANT USAGE ON SCHEMA public TO synops_reader;
|
||||||
|
|
||||||
|
-- Sidelinja (superuser) kan bytte til synops_reader
|
||||||
|
GRANT synops_reader TO sidelinja;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- current_node_id() — leser brukerens node_id fra sesjonsvariabel
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION current_node_id() RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN current_setting('app.current_node_id', true)::UUID;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- synops_reader må kunne kalle funksjonen
|
||||||
|
GRANT EXECUTE ON FUNCTION current_node_id() TO synops_reader;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- RLS på nodes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE nodes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policy: bruker kan se noder de har opprettet, har tilgang til, eller som er
|
||||||
|
-- discoverable/readable/open.
|
||||||
|
--
|
||||||
|
-- Tre sjekker (ref: bruker_ikke_workspace.md):
|
||||||
|
-- 1. Egne noder (created_by) — instant
|
||||||
|
-- 2. Eksplisitt tilgang via node_access — indeksert lookup
|
||||||
|
-- 3. Offentlig synlige noder (discoverable+) — kolonne-sjekk
|
||||||
|
CREATE POLICY node_select ON nodes FOR SELECT TO synops_reader
|
||||||
|
USING (
|
||||||
|
created_by = current_node_id()
|
||||||
|
OR id IN (
|
||||||
|
SELECT object_id FROM node_access
|
||||||
|
WHERE subject_id = current_node_id()
|
||||||
|
)
|
||||||
|
OR visibility >= 'discoverable'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- RLS på edges
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE edges ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policy: bruker kan se edges der de har tilgang til minst én av endepunktene.
|
||||||
|
-- System-edges (alias etc.) er alltid skjult med mindre brukeren er source.
|
||||||
|
CREATE POLICY edge_select ON edges FOR SELECT TO synops_reader
|
||||||
|
USING (
|
||||||
|
-- Ikke vis system-edges til andre enn eieren
|
||||||
|
(NOT system OR source_id = current_node_id())
|
||||||
|
AND (
|
||||||
|
-- Bruker opprettet edgen
|
||||||
|
created_by = current_node_id()
|
||||||
|
-- Bruker er source eller target
|
||||||
|
OR source_id = current_node_id()
|
||||||
|
OR target_id = current_node_id()
|
||||||
|
-- Bruker har tilgang til source- eller target-noden
|
||||||
|
OR source_id IN (
|
||||||
|
SELECT object_id FROM node_access
|
||||||
|
WHERE subject_id = current_node_id()
|
||||||
|
)
|
||||||
|
OR target_id IN (
|
||||||
|
SELECT object_id FROM node_access
|
||||||
|
WHERE subject_id = current_node_id()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- RLS på node_access (brukere kan bare se sine egne tilganger)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE node_access ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY na_select ON node_access FOR SELECT TO synops_reader
|
||||||
|
USING (subject_id = current_node_id());
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Indeks for RLS-ytelse
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- node_access brukes i subquery med subject_id — allerede indeksert (idx_na_subject).
|
||||||
|
-- Legg til indeks på object_id for edge-policyen (reverse lookup).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_na_object ON node_access (object_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -74,8 +74,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
- [x] 4.1 `recompute_access` i maskinrommet: ved edge-endring, oppdater `node_access`-matrisen. Håndter direkte edges (owner, admin, member, reader).
|
- [x] 4.1 `recompute_access` i maskinrommet: ved edge-endring, oppdater `node_access`-matrisen. Håndter direkte edges (owner, admin, member, reader).
|
||||||
- [x] 4.2 Team-transitivitet: member_of-edge til team → arv tilgang fra teamets edges.
|
- [x] 4.2 Team-transitivitet: member_of-edge til team → arv tilgang fra teamets edges.
|
||||||
- [x] 4.3 Visibility-filtrering: STDB-spørringer respekterer visibility-enum. Frontend ser bare noder brukeren har tilgang til.
|
- [x] 4.3 Visibility-filtrering: STDB-spørringer respekterer visibility-enum. Frontend ser bare noder brukeren har tilgang til.
|
||||||
- [~] 4.4 RLS-policies på PG: `node_access`-basert filtrering for tunge spørringer.
|
- [x] 4.4 RLS-policies på PG: `node_access`-basert filtrering for tunge spørringer.
|
||||||
> Påbegynt: 2026-03-17T15:19
|
|
||||||
|
|
||||||
## Fase 5: Kommunikasjonsnoder
|
## Fase 5: Kommunikasjonsnoder
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue