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:
vegard 2026-03-17 15:30:29 +01:00
parent 50a6934f05
commit 1355d189b2
6 changed files with 457 additions and 160 deletions

View file

@ -1,69 +1,70 @@
# Migration Safety Checklist # 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. 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 ## Før migrering
- [ ] Les migrasjonfilen og forstå hva den gjør - [ ] 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?) - [ ] Tar migrasjonen backup-hensyn? (dropper den kolonner/tabeller med data?)
- [ ] Påvirker den RLS-policies? Sjekk at ingen policy fjernes utilsiktet.
## Etter migrering ## Etter migrering
### RLS-verifisering (KRITISK) ### RLS-verifisering (KRITISK)
Etter *enhver* migrering som oppretter eller endrer tabeller med `workspace_id`: Etter *enhver* migrering som oppretter eller endrer tabeller:
```sql ```sql
-- 1. Verifiser at RLS er aktivert på alle workspace-tabeller -- 1. Verifiser at RLS er aktivert på relevante tabeller
SELECT tablename, rowsecurity SELECT tablename, rowsecurity
FROM pg_tables FROM pg_tables
WHERE schemaname = 'public' WHERE schemaname = 'public'
AND tablename IN ('nodes', 'graph_edges', 'messages', 'channels', AND tablename IN ('nodes', 'edges', 'node_access')
'media_files', 'job_queue', 'message_attachments')
ORDER BY tablename; ORDER BY tablename;
-- Forventet: rowsecurity = true for alle -- Forventet: rowsecurity = true for alle
-- 2. Verifiser at policies eksisterer -- 2. Verifiser at policies eksisterer
SELECT tablename, policyname, cmd, qual SELECT tablename, policyname, cmd
FROM pg_policies FROM pg_policies
WHERE schemaname = 'public' WHERE schemaname = 'public'
ORDER BY tablename; 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 -- 3. Test isolasjon: sett bruker A, forsøk å lese hidden node opprettet av bruker B
SET app.current_workspace_id = '<workspace_a_uuid>'; BEGIN;
SELECT count(*) FROM nodes WHERE workspace_id = '<workspace_b_uuid>'; 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) -- Forventet: 0 rader (RLS blokkerer)
COMMIT;
-- 4. Verifiser at superuser IKKE blokkeres (Rust workers trenger dette) -- 4. Verifiser at superuser IKKE blokkeres (maskinrommet skriver som superuser)
RESET app.current_workspace_id;
SET ROLE postgres;
SELECT count(*) FROM nodes; SELECT count(*) FROM nodes;
-- Forventet: alle rader synlige -- Forventet: alle rader synlige (superuser bypasser RLS)
``` ```
### Indeksverifisering ### Indeksverifisering
```sql ```sql
-- Sjekk at viktige indekser finnes
SELECT indexname, tablename SELECT indexname, tablename
FROM pg_indexes FROM pg_indexes
WHERE schemaname = 'public' WHERE schemaname = 'public'
AND tablename IN ('nodes', 'graph_edges', 'messages') AND tablename IN ('nodes', 'edges', 'node_access')
ORDER BY tablename; ORDER BY tablename;
-- Viktige indekser: idx_na_subject, idx_na_object (for RLS-ytelse)
``` ```
### Constraint-verifisering ### Constraint-verifisering
```sql ```sql
-- Sjekk at foreign keys er intakte
SELECT tc.table_name, tc.constraint_name, tc.constraint_type SELECT tc.table_name, tc.constraint_name, tc.constraint_type
FROM information_schema.table_constraints tc FROM information_schema.table_constraints tc
WHERE tc.table_schema = 'public' WHERE tc.table_schema = 'public'
@ -73,85 +74,59 @@ 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
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) ### Automatisk test (to-bruker leak detection)
Kjøres i migrasjonstester og som egen CI-steg:
```sql ```sql
-- Opprett to test-workspaces -- Opprett to testbrukere
INSERT INTO workspaces (id, name, slug) VALUES INSERT INTO nodes (id, node_kind, title, visibility, created_by) VALUES
('aaaaaaaa-0000-0000-0000-000000000001', 'Workspace A', 'ws-a'), ('aaaaaaaa-0000-0000-0000-000000000001', 'person', 'Test A', 'hidden', NULL),
('aaaaaaaa-0000-0000-0000-000000000002', 'Workspace B', 'ws-b'); ('aaaaaaaa-0000-0000-0000-000000000002', 'person', 'Test B', 'hidden', NULL);
-- Seed testdata i begge -- Opprett hidden noder for hver bruker
INSERT INTO nodes (id, node_type, workspace_id) VALUES INSERT INTO nodes (id, node_kind, title, visibility, created_by) VALUES
('bbbbbbbb-0000-0000-0000-000000000001', 'tema', 'aaaaaaaa-0000-0000-0000-000000000001'), ('bbbbbbbb-0000-0000-0000-000000000001', 'content', 'Hemmelighet A', 'hidden',
('bbbbbbbb-0000-0000-0000-000000000002', 'tema', 'aaaaaaaa-0000-0000-0000-000000000002'); '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 -- TEST 1: Bruker A skal ikke se Bruker B sine hidden noder
SET app.current_workspace_id = 'aaaaaaaa-0000-0000-0000-000000000001'; BEGIN;
SET LOCAL app.current_node_id = 'aaaaaaaa-0000-0000-0000-000000000001';
SET LOCAL ROLE synops_reader;
DO $$ DO $$
BEGIN BEGIN
IF (SELECT count(*) FROM nodes WHERE workspace_id = 'aaaaaaaa-0000-0000-0000-000000000002') > 0 THEN IF (SELECT count(*) FROM nodes WHERE id = 'bbbbbbbb-0000-0000-0000-000000000002') > 0 THEN
RAISE EXCEPTION 'RLS LEAK: Workspace A kan lese Workspace B sine noder!'; RAISE EXCEPTION 'RLS LEAK: Bruker A kan lese Bruker B sine hidden noder!';
END IF; END IF;
END $$; END $$;
COMMIT;
-- TEST 2: Uten SET (tom current_setting) skal returnere 0 rader -- TEST 2: Uten satt bruker-id skal hidden noder være usynlige
RESET app.current_workspace_id; BEGIN;
SET LOCAL ROLE synops_reader;
DO $$ DO $$
BEGIN BEGIN
-- For vanlig bruker (ikke superuser) bør dette returnere 0 IF (SELECT count(*) FROM nodes WHERE visibility = 'hidden') > 0 THEN
IF (SELECT count(*) FROM nodes) > 0 AND current_setting('is_superuser') = 'off' THEN RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese hidden data!';
RAISE EXCEPTION 'RLS LEAK: Uautentisert tilkobling kan lese data!';
END IF; END IF;
END $$; END $$;
``` COMMIT;
### Audit-trigger (produksjon) -- Rydd opp
Valgfri trigger som logger mistenkelige queries i prod: DELETE FROM nodes WHERE id IN (
'aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002',
```sql 'bbbbbbbb-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000002'
-- 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()
); );
-- 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 Hvis en ny tabell opprettes som inneholder brukerdata:
-- Finn alle tabeller med workspace_id-kolonne (bør alle ha RLS) 1. Aktiver RLS: `ALTER TABLE ny_tabell ENABLE ROW LEVEL SECURITY;`
SELECT t.tablename 2. Opprett SELECT-policy for `synops_reader` med `current_node_id()`-sjekk
FROM pg_tables t 3. Grant SELECT til `synops_reader`
JOIN information_schema.columns c ON c.table_name = t.tablename 4. Kjør leak hunter
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).**

View file

@ -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>) {

View file

@ -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
View 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, &params.q, &params.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 })
}

View 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;

View file

@ -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