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,69 +1,70 @@
|
|||
# Migration Safety Checklist
|
||||
|
||||
> **Merk:** Denne sjekklisten er skrevet for v1-arkitekturen der RLS var
|
||||
> basert på `workspace_id`-kolonner og `SET app.current_workspace_id`.
|
||||
> Workspace-modellen er erstattet av en node-basert tilgangsmatrise (se
|
||||
> `docs/retninger/bruker_ikke_workspace.md`). Sjekklisten må skrives om for
|
||||
> det nye mønsteret: `node_access`-matrise, edge-basert tilgang, og
|
||||
> RLS-policies som opererer på bruker→node-edges i stedet for workspace-scope.
|
||||
>
|
||||
> Seksjonene under er bevart som referanse for v1-mønsteret.
|
||||
|
||||
Sjekkliste for alle som kjører PostgreSQL-migrasjoner — lokalt eller i prod.
|
||||
|
||||
## RLS-modell
|
||||
|
||||
Synops bruker `node_access`-matrisen for tilgangskontroll, ikke workspace-scope.
|
||||
Maskinrommet kobler til som superuser (`sidelinja`) for skriveoperasjoner.
|
||||
For tunge lesespørringer brukes `SET LOCAL ROLE synops_reader`, som er underlagt RLS.
|
||||
|
||||
Sesjonsvariabel: `app.current_node_id` settes til brukerens node-UUID.
|
||||
Funksjon: `current_node_id()` leser denne variabelen.
|
||||
|
||||
RLS-policies finnes på: `nodes`, `edges`, `node_access`.
|
||||
|
||||
## Før migrering
|
||||
|
||||
- [ ] Les migrasjonfilen og forstå hva den gjør
|
||||
- [ ] Har migrasjonen en tilhørende **down-migrering**? (påkrevd for skjema-endringer)
|
||||
- [ ] Tar migrasjonen backup-hensyn? (dropper den kolonner/tabeller med data?)
|
||||
- [ ] Påvirker den RLS-policies? Sjekk at ingen policy fjernes utilsiktet.
|
||||
|
||||
## Etter migrering
|
||||
|
||||
### RLS-verifisering (KRITISK)
|
||||
Etter *enhver* migrering som oppretter eller endrer tabeller med `workspace_id`:
|
||||
Etter *enhver* migrering som oppretter eller endrer tabeller:
|
||||
|
||||
```sql
|
||||
-- 1. Verifiser at RLS er aktivert på alle workspace-tabeller
|
||||
-- 1. Verifiser at RLS er aktivert på relevante tabeller
|
||||
SELECT tablename, rowsecurity
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('nodes', 'graph_edges', 'messages', 'channels',
|
||||
'media_files', 'job_queue', 'message_attachments')
|
||||
AND tablename IN ('nodes', 'edges', 'node_access')
|
||||
ORDER BY tablename;
|
||||
-- Forventet: rowsecurity = true for alle
|
||||
|
||||
-- 2. Verifiser at policies eksisterer
|
||||
SELECT tablename, policyname, cmd, qual
|
||||
SELECT tablename, policyname, cmd
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY tablename;
|
||||
-- Forventet: workspace_isolation_* policy for hver tabell
|
||||
-- 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>';
|
||||
SELECT count(*) FROM nodes WHERE workspace_id = '<workspace_b_uuid>';
|
||||
-- 3. Test isolasjon: sett bruker A, forsøk å lese hidden node opprettet av bruker B
|
||||
BEGIN;
|
||||
SET LOCAL app.current_node_id = '<bruker_a_uuid>';
|
||||
SET LOCAL ROLE synops_reader;
|
||||
SELECT count(*) FROM nodes WHERE created_by = '<bruker_b_uuid>' AND visibility = 'hidden';
|
||||
-- Forventet: 0 rader (RLS blokkerer)
|
||||
COMMIT;
|
||||
|
||||
-- 4. Verifiser at superuser IKKE blokkeres (Rust workers trenger dette)
|
||||
RESET app.current_workspace_id;
|
||||
SET ROLE postgres;
|
||||
-- 4. Verifiser at superuser IKKE blokkeres (maskinrommet skriver som superuser)
|
||||
SELECT count(*) FROM nodes;
|
||||
-- Forventet: alle rader synlige
|
||||
-- Forventet: alle rader synlige (superuser bypasser RLS)
|
||||
```
|
||||
|
||||
### Indeksverifisering
|
||||
```sql
|
||||
-- Sjekk at viktige indekser finnes
|
||||
SELECT indexname, tablename
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename IN ('nodes', 'graph_edges', 'messages')
|
||||
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
|
||||
SELECT tc.table_name, tc.constraint_name, tc.constraint_type
|
||||
FROM information_schema.table_constraints tc
|
||||
WHERE tc.table_schema = 'public'
|
||||
|
|
@ -73,85 +74,59 @@ ORDER BY tc.table_name;
|
|||
|
||||
## 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
|
||||
feature eller en direkte PG-tilkobling uten `SET LOCAL ROLE synops_reader`
|
||||
kan føre til datalekkasje. Denne testen fanger det opp.
|
||||
|
||||
### Automatisk CI-test (to-workspace leak detection)
|
||||
Kjøres i migrasjonstester og som egen CI-steg:
|
||||
### Automatisk test (to-bruker leak detection)
|
||||
|
||||
```sql
|
||||
-- Opprett to test-workspaces
|
||||
INSERT INTO workspaces (id, name, slug) VALUES
|
||||
('aaaaaaaa-0000-0000-0000-000000000001', 'Workspace A', 'ws-a'),
|
||||
('aaaaaaaa-0000-0000-0000-000000000002', 'Workspace B', 'ws-b');
|
||||
-- Opprett to testbrukere
|
||||
INSERT INTO nodes (id, node_kind, title, visibility, created_by) VALUES
|
||||
('aaaaaaaa-0000-0000-0000-000000000001', 'person', 'Test A', 'hidden', NULL),
|
||||
('aaaaaaaa-0000-0000-0000-000000000002', 'person', 'Test B', 'hidden', NULL);
|
||||
|
||||
-- Seed testdata i begge
|
||||
INSERT INTO nodes (id, node_type, workspace_id) VALUES
|
||||
('bbbbbbbb-0000-0000-0000-000000000001', 'tema', 'aaaaaaaa-0000-0000-0000-000000000001'),
|
||||
('bbbbbbbb-0000-0000-0000-000000000002', 'tema', 'aaaaaaaa-0000-0000-0000-000000000002');
|
||||
-- Opprett hidden noder for hver bruker
|
||||
INSERT INTO nodes (id, node_kind, title, visibility, created_by) VALUES
|
||||
('bbbbbbbb-0000-0000-0000-000000000001', 'content', 'Hemmelighet A', 'hidden',
|
||||
'aaaaaaaa-0000-0000-0000-000000000001'),
|
||||
('bbbbbbbb-0000-0000-0000-000000000002', 'content', 'Hemmelighet B', 'hidden',
|
||||
'aaaaaaaa-0000-0000-0000-000000000002');
|
||||
|
||||
-- TEST 1: Sett workspace A, forsøk å lese workspace B
|
||||
SET app.current_workspace_id = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
-- TEST 1: Bruker A skal ikke se Bruker B sine hidden noder
|
||||
BEGIN;
|
||||
SET LOCAL app.current_node_id = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
SET LOCAL ROLE synops_reader;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT count(*) FROM nodes WHERE workspace_id = 'aaaaaaaa-0000-0000-0000-000000000002') > 0 THEN
|
||||
RAISE EXCEPTION 'RLS LEAK: Workspace A kan lese Workspace B sine noder!';
|
||||
IF (SELECT count(*) FROM nodes WHERE id = 'bbbbbbbb-0000-0000-0000-000000000002') > 0 THEN
|
||||
RAISE EXCEPTION 'RLS LEAK: Bruker A kan lese Bruker B sine hidden noder!';
|
||||
END IF;
|
||||
END $$;
|
||||
COMMIT;
|
||||
|
||||
-- TEST 2: Uten SET (tom current_setting) skal returnere 0 rader
|
||||
RESET app.current_workspace_id;
|
||||
-- TEST 2: Uten satt bruker-id skal hidden noder være usynlige
|
||||
BEGIN;
|
||||
SET LOCAL ROLE synops_reader;
|
||||
DO $$
|
||||
BEGIN
|
||||
-- For vanlig bruker (ikke superuser) bør dette returnere 0
|
||||
IF (SELECT count(*) FROM nodes) > 0 AND current_setting('is_superuser') = 'off' THEN
|
||||
RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese data!';
|
||||
IF (SELECT count(*) FROM nodes WHERE visibility = 'hidden') > 0 THEN
|
||||
RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese hidden data!';
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
COMMIT;
|
||||
|
||||
### Audit-trigger (produksjon)
|
||||
Valgfri trigger som logger mistenkelige queries i prod:
|
||||
|
||||
```sql
|
||||
-- Tabell for RLS-audit
|
||||
CREATE TABLE IF NOT EXISTS rls_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
table_name TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
current_workspace TEXT,
|
||||
session_user TEXT NOT NULL,
|
||||
query_timestamp TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
-- Rydd opp
|
||||
DELETE FROM nodes WHERE id IN (
|
||||
'aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002',
|
||||
'bbbbbbbb-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000002'
|
||||
);
|
||||
|
||||
-- Funksjon som logger når current_workspace_id ikke er satt
|
||||
CREATE OR REPLACE FUNCTION audit_rls_context() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF current_setting('app.current_workspace_id', true) IS NULL
|
||||
OR current_setting('app.current_workspace_id', true) = '' THEN
|
||||
IF current_setting('is_superuser') = 'off' THEN
|
||||
INSERT INTO rls_audit_log (table_name, operation, current_workspace, session_user)
|
||||
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:
|
||||
## Nye tabeller
|
||||
|
||||
```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).**
|
||||
Hvis en ny tabell opprettes som inneholder brukerdata:
|
||||
1. Aktiver RLS: `ALTER TABLE ny_tabell ENABLE ROW LEVEL SECURITY;`
|
||||
2. Opprett SELECT-policy for `synops_reader` med `current_node_id()`-sjekk
|
||||
3. Grant SELECT til `synops_reader`
|
||||
4. Kjør leak hunter
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "ope
|
|||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
error: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
mod auth;
|
||||
mod intentions;
|
||||
mod queries;
|
||||
mod stdb;
|
||||
mod warmup;
|
||||
|
||||
|
|
@ -117,6 +118,7 @@ async fn main() {
|
|||
.route("/intentions/create_edge", post(intentions::create_edge))
|
||||
.route("/intentions/update_node", post(intentions::update_node))
|
||||
.route("/intentions/delete_node", post(intentions::delete_node))
|
||||
.route("/query/nodes", get(queries::query_nodes))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.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.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.
|
||||
- [~] 4.4 RLS-policies på PG: `node_access`-basert filtrering for tunge spørringer.
|
||||
> Påbegynt: 2026-03-17T15:19
|
||||
- [x] 4.4 RLS-policies på PG: `node_access`-basert filtrering for tunge spørringer.
|
||||
|
||||
## Fase 5: Kommunikasjonsnoder
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue