From 1e9a4c83c3fde182a07933592b3a872b649a6568 Mon Sep 17 00:00:00 2001 From: vegard Date: Tue, 17 Mar 2026 13:25:16 +0100 Subject: [PATCH] Flere intensjoner: create_edge, update_node, delete_node (oppgave 2.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementerer tre nye skrivestier i maskinrommet med tilgangskontroll: - POST /intentions/create_edge — opprett retningsbestemt edge mellom to noder. Validerer at begge noder eksisterer og edge_type er satt. - POST /intentions/update_node — partial update av eksisterende node. Kun oppgitte felter endres, resten beholdes fra PG. - POST /intentions/delete_node — slett node med cascade av edges. Tilgangskontroll for update/delete: brukeren må enten være created_by på noden, eller ha en owner/admin-edge til den. Sjekkes mot PG som autoritativ kilde. Alle endepunkter følger samme mønster som create_node: STDB-skriving (instant) → async PG-persistering → umiddelbar respons. Verifisert på server med 10 testcaser: 1. /me med gyldig token → 200 2. create_node → 200 med node_id 3. create_edge (gyldig) → 200 med edge_id 4. create_edge (ugyldig source) → 400 5. create_edge (tom edge_type) → 400 6. update_node (partial, eier) → 200 7. update_node (ingen tilgang) → 403 8. delete_node (eier) → 200 9. delete_node (ingen tilgang) → 403 10. update via owner-edge (Sidelinja) → 200 Co-Authored-By: Claude Opus 4.6 --- docs/infra/api_grensesnitt.md | 14 + maskinrommet/src/intentions.rs | 542 ++++++++++++++++++++++++++++++--- maskinrommet/src/main.rs | 3 + tasks.md | 3 +- 4 files changed, 524 insertions(+), 38 deletions(-) diff --git a/docs/infra/api_grensesnitt.md b/docs/infra/api_grensesnitt.md index 8d68872..0c94924 100644 --- a/docs/infra/api_grensesnitt.md +++ b/docs/infra/api_grensesnitt.md @@ -67,6 +67,20 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet → - Body (JSON): `{ node_kind?, title?, content?, visibility?, metadata? }` - Defaults: `node_kind="content"`, `visibility="hidden"`, andre felter tomme - Respons: `{ node_id: "" }` +- `POST /intentions/create_edge` — Opprett edge mellom to noder. + Validerer at begge nodene eksisterer og at edge_type ikke er tom. + - Body (JSON): `{ source_id, target_id, edge_type, metadata?, system? }` + - Defaults: `metadata={}`, `system=false` + - Respons: `{ edge_id: "" }` +- `POST /intentions/update_node` — Oppdater eksisterende node (partial update). + Krever tilgang: created_by eller owner/admin-edge. + - Body (JSON): `{ node_id, node_kind?, title?, content?, visibility?, metadata? }` + - Kun oppgitte felter endres, resten beholdes + - Respons: `{ node_id: "" }` +- `POST /intentions/delete_node` — Slett node og tilhørende edges. + Krever tilgang: created_by eller owner/admin-edge. + - Body (JSON): `{ node_id }` + - Respons: `{ deleted: true }` ## 6. Instruks for Claude Code diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index c6583cd..ee4a962 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -4,6 +4,9 @@ // skriver til SpacetimeDB først (instant feedback via WebSocket), // deretter persisterer til PostgreSQL asynkront. // +// Tilgangskontroll: Muterende operasjoner (update, delete) krever at +// brukeren er created_by på noden, eller har owner/admin-edge til den. +// // Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md use axum::{extract::State, http::StatusCode, Json}; @@ -15,12 +18,112 @@ use crate::auth::AuthUser; use crate::AppState; // ============================================================================= -// create_node +// Felles // ============================================================================= /// Gyldige visibility-verdier (speiler PG enum). const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"]; +#[derive(Serialize)] +pub struct ErrorResponse { + error: String, +} + +fn bad_request(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: msg.to_string(), + }), + ) +} + +fn forbidden(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: msg.to_string(), + }), + ) +} + +fn internal_error(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: msg.to_string(), + }), + ) +} + +fn stdb_error(op: &str, e: crate::stdb::StdbError) -> (StatusCode, Json) { + tracing::error!("STDB {op} feilet: {e}"); + internal_error(&format!("Kunne ikke skrive til SpacetimeDB: {e}")) +} + +// ============================================================================= +// Tilgangskontroll +// ============================================================================= + +/// Sjekker om brukeren har skrivetilgang til en node. +/// Returnerer true hvis brukeren er created_by, eller har owner/admin-edge. +async fn user_can_modify_node(db: &PgPool, user_id: Uuid, node_id: Uuid) -> Result { + let row = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2 + ) OR EXISTS( + SELECT 1 FROM edges + WHERE source_id = $2 AND target_id = $1 + AND edge_type IN ('owner', 'admin') + ) + "#, + ) + .bind(node_id) + .bind(user_id) + .fetch_one(db) + .await?; + + Ok(row) +} + +/// Sjekker om brukeren har skrivetilgang til en edge. +/// Brukeren må ha opprettet edgen, eller ha owner/admin-edge til source-noden. +#[allow(dead_code)] +async fn user_can_modify_edge(db: &PgPool, user_id: Uuid, edge_id: Uuid) -> Result { + let row = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM edges WHERE id = $1 AND created_by = $2 + ) OR EXISTS( + SELECT 1 FROM edges e + JOIN edges access_edge ON access_edge.source_id = $2 + AND access_edge.target_id = e.source_id + AND access_edge.edge_type IN ('owner', 'admin') + WHERE e.id = $1 + ) + "#, + ) + .bind(edge_id) + .bind(user_id) + .fetch_one(db) + .await?; + + Ok(row) +} + +/// Sjekker om en node eksisterer i PG. +async fn node_exists(db: &PgPool, node_id: Uuid) -> Result { + sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)") + .bind(node_id) + .fetch_one(db) + .await +} + +// ============================================================================= +// create_node +// ============================================================================= + #[derive(Deserialize)] pub struct CreateNodeRequest { /// Hint om hva noden er. Default: "content". @@ -40,11 +143,6 @@ pub struct CreateNodeResponse { pub node_id: Uuid, } -#[derive(Serialize)] -pub struct ErrorResponse { - error: String, -} - /// POST /intentions/create_node /// /// Validerer input, skriver til STDB (instant), spawner async PG-skriving. @@ -92,15 +190,7 @@ pub async fn create_node( &created_by_str, ) .await - .map_err(|e| { - tracing::error!("STDB create_node feilet: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Kunne ikke skrive til SpacetimeDB: {e}"), - }), - ) - })?; + .map_err(|e| stdb_error("create_node", e))?; tracing::info!( node_id = %node_id, @@ -110,7 +200,7 @@ pub async fn create_node( ); // -- Spawn async PG-skriving -- - spawn_pg_insert( + spawn_pg_insert_node( state.db.clone(), node_id, node_kind, @@ -121,13 +211,312 @@ pub async fn create_node( user.node_id, ); - // -- Returner node_id umiddelbart -- Ok(Json(CreateNodeResponse { node_id })) } +// ============================================================================= +// create_edge +// ============================================================================= + +#[derive(Deserialize)] +pub struct CreateEdgeRequest { + /// Kilde-node (fra). + pub source_id: Uuid, + /// Mål-node (til). + pub target_id: Uuid, + /// Relasjontype (freeform streng). Ref: docs/primitiver/edges.md + pub edge_type: String, + /// Typespesifikk metadata (JSON-objekt). + pub metadata: Option, + /// Systemedge — usynlig ved traversering. Default: false. + pub system: Option, +} + +#[derive(Serialize)] +pub struct CreateEdgeResponse { + pub edge_id: Uuid, +} + +/// POST /intentions/create_edge +/// +/// Oppretter en retningsbestemt edge mellom to noder. +/// Krever at begge nodene eksisterer. Brukeren trenger ikke spesiell +/// tilgang for å opprette edges — tilgangskontroll på edges håndheves +/// ved lesing (node_access-matrisen, fase 4). +pub async fn create_edge( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // -- Valider input -- + if req.edge_type.is_empty() { + return Err(bad_request("edge_type kan ikke være tom")); + } + + // Sjekk at begge nodene eksisterer + let (source_exists, target_exists) = tokio::try_join!( + node_exists(&state.db, req.source_id), + node_exists(&state.db, req.target_id), + ) + .map_err(|e| { + tracing::error!("PG-feil ved nodesjekk: {e}"); + internal_error("Databasefeil ved validering") + })?; + + if !source_exists { + return Err(bad_request(&format!("source_id {} finnes ikke", req.source_id))); + } + if !target_exists { + return Err(bad_request(&format!("target_id {} finnes ikke", req.target_id))); + } + + let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({})); + let metadata_str = metadata.to_string(); + let system = req.system.unwrap_or(false); + + // -- Generer UUIDv7 -- + let edge_id = Uuid::now_v7(); + let edge_id_str = edge_id.to_string(); + let source_id_str = req.source_id.to_string(); + let target_id_str = req.target_id.to_string(); + let created_by_str = user.node_id.to_string(); + + // -- Skriv til SpacetimeDB (instant) -- + state + .stdb + .create_edge( + &edge_id_str, + &source_id_str, + &target_id_str, + &req.edge_type, + &metadata_str, + system, + &created_by_str, + ) + .await + .map_err(|e| stdb_error("create_edge", e))?; + + tracing::info!( + edge_id = %edge_id, + source_id = %req.source_id, + target_id = %req.target_id, + edge_type = %req.edge_type, + created_by = %user.node_id, + "Edge opprettet i STDB" + ); + + // -- Spawn async PG-skriving -- + let edge_type = req.edge_type.clone(); + spawn_pg_insert_edge( + state.db.clone(), + edge_id, + req.source_id, + req.target_id, + edge_type, + metadata, + system, + user.node_id, + ); + + Ok(Json(CreateEdgeResponse { edge_id })) +} + +// ============================================================================= +// update_node +// ============================================================================= + +#[derive(Deserialize)] +pub struct UpdateNodeRequest { + /// ID til noden som skal oppdateres. + pub node_id: Uuid, + /// Ny node_kind. Beholder eksisterende hvis None. + pub node_kind: Option, + /// Ny tittel. Beholder eksisterende hvis None. + pub title: Option, + /// Nytt innhold. Beholder eksisterende hvis None. + pub content: Option, + /// Ny synlighet. Beholder eksisterende hvis None. + pub visibility: Option, + /// Ny metadata. Beholder eksisterende hvis None. + pub metadata: Option, +} + +#[derive(Serialize)] +pub struct UpdateNodeResponse { + pub node_id: Uuid, +} + +/// POST /intentions/update_node +/// +/// Oppdaterer en eksisterende node. Krever at brukeren er created_by +/// eller har owner/admin-edge til noden. +pub async fn update_node( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // -- Tilgangskontroll -- + let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved tilgangssjekk: {e}"); + internal_error("Databasefeil ved tilgangssjekk") + })?; + + if !can_modify { + return Err(forbidden("Ingen tilgang til å endre denne noden")); + } + + // -- Hent eksisterende node fra PG for å fylle inn manglende felt -- + let existing = sqlx::query_as::<_, NodeRow>( + "SELECT node_kind, title, content, visibility::text, metadata FROM nodes WHERE id = $1", + ) + .bind(req.node_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved henting av node: {e}"); + internal_error("Databasefeil ved henting av node") + })? + .ok_or_else(|| bad_request(&format!("Node {} finnes ikke", req.node_id)))?; + + let node_kind = req.node_kind.unwrap_or(existing.node_kind); + if node_kind.is_empty() { + return Err(bad_request("node_kind kan ikke være tom")); + } + + let visibility = req.visibility.unwrap_or(existing.visibility); + if !VALID_VISIBILITIES.contains(&visibility.as_str()) { + return Err(bad_request(&format!( + "Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}" + ))); + } + + let title = req.title.unwrap_or(existing.title.unwrap_or_default()); + let content = req.content.unwrap_or(existing.content.unwrap_or_default()); + let metadata = req.metadata.unwrap_or(existing.metadata); + let metadata_str = metadata.to_string(); + + let node_id_str = req.node_id.to_string(); + + // -- Skriv til SpacetimeDB (instant) -- + state + .stdb + .update_node( + &node_id_str, + &node_kind, + &title, + &content, + &visibility, + &metadata_str, + ) + .await + .map_err(|e| stdb_error("update_node", e))?; + + tracing::info!( + node_id = %req.node_id, + updated_by = %user.node_id, + "Node oppdatert i STDB" + ); + + // -- Spawn async PG-skriving -- + spawn_pg_update_node( + state.db.clone(), + req.node_id, + node_kind, + title, + content, + visibility, + metadata, + ); + + Ok(Json(UpdateNodeResponse { node_id: req.node_id })) +} + +// ============================================================================= +// delete_node +// ============================================================================= + +#[derive(Deserialize)] +pub struct DeleteNodeRequest { + /// ID til noden som skal slettes. + pub node_id: Uuid, +} + +#[derive(Serialize)] +pub struct DeleteNodeResponse { + pub deleted: bool, +} + +/// POST /intentions/delete_node +/// +/// Sletter en node og alle dens edges (CASCADE i PG, eksplisitt i STDB). +/// Krever at brukeren er created_by eller har owner/admin-edge til noden. +pub async fn delete_node( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // -- Tilgangskontroll -- + let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved tilgangssjekk: {e}"); + internal_error("Databasefeil ved tilgangssjekk") + })?; + + if !can_modify { + return Err(forbidden("Ingen tilgang til å slette denne noden")); + } + + // Sjekk at noden eksisterer + let exists = node_exists(&state.db, req.node_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved nodesjekk: {e}"); + internal_error("Databasefeil ved validering") + })?; + + if !exists { + return Err(bad_request(&format!("Node {} finnes ikke", req.node_id))); + } + + let node_id_str = req.node_id.to_string(); + + // -- Slett fra SpacetimeDB (instant) -- + state + .stdb + .delete_node(&node_id_str) + .await + .map_err(|e| stdb_error("delete_node", e))?; + + tracing::info!( + node_id = %req.node_id, + deleted_by = %user.node_id, + "Node slettet fra STDB" + ); + + // -- Spawn async PG-sletting -- + spawn_pg_delete_node(state.db.clone(), req.node_id); + + Ok(Json(DeleteNodeResponse { deleted: true })) +} + +// ============================================================================= +// Bakgrunns-PG-operasjoner +// ============================================================================= + +#[derive(sqlx::FromRow)] +struct NodeRow { + node_kind: String, + title: Option, + content: Option, + visibility: String, + metadata: serde_json::Value, +} + /// Spawner en tokio-task som skriver noden til PostgreSQL i bakgrunnen. -/// Frontend får oppdatering via STDB WebSocket uavhengig av denne. -fn spawn_pg_insert( +fn spawn_pg_insert_node( db: PgPool, node_id: Uuid, node_kind: String, @@ -156,28 +545,109 @@ fn spawn_pg_insert( match result { Ok(_) => { - tracing::info!( - node_id = %node_id, - "Node persistert til PostgreSQL" - ); + tracing::info!(node_id = %node_id, "Node persistert til PostgreSQL"); } Err(e) => { - // Logg feilen. I fremtiden: dead letter queue (fase 12.3). - tracing::error!( - node_id = %node_id, - error = %e, - "Kunne ikke persistere node til PostgreSQL" - ); + tracing::error!(node_id = %node_id, error = %e, "Kunne ikke persistere node til PostgreSQL"); } } }); } -fn bad_request(msg: &str) -> (StatusCode, Json) { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: msg.to_string(), - }), - ) +/// Spawner en tokio-task som skriver edgen til PostgreSQL i bakgrunnen. +fn spawn_pg_insert_edge( + db: PgPool, + edge_id: Uuid, + source_id: Uuid, + target_id: Uuid, + edge_type: String, + metadata: serde_json::Value, + system: bool, + created_by: Uuid, +) { + tokio::spawn(async move { + let result = sqlx::query( + r#" + INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + ) + .bind(edge_id) + .bind(source_id) + .bind(target_id) + .bind(&edge_type) + .bind(&metadata) + .bind(system) + .bind(created_by) + .execute(&db) + .await; + + match result { + Ok(_) => { + tracing::info!(edge_id = %edge_id, "Edge persistert til PostgreSQL"); + } + Err(e) => { + tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL"); + } + } + }); +} + +/// Spawner en tokio-task som oppdaterer noden i PostgreSQL. +fn spawn_pg_update_node( + db: PgPool, + node_id: Uuid, + node_kind: String, + title: String, + content: String, + visibility: String, + metadata: serde_json::Value, +) { + tokio::spawn(async move { + let result = sqlx::query( + r#" + UPDATE nodes + SET node_kind = $2, title = NULLIF($3, ''), content = NULLIF($4, ''), + visibility = $5::visibility, metadata = $6 + WHERE id = $1 + "#, + ) + .bind(node_id) + .bind(&node_kind) + .bind(&title) + .bind(&content) + .bind(&visibility) + .bind(&metadata) + .execute(&db) + .await; + + match result { + Ok(_) => { + tracing::info!(node_id = %node_id, "Node oppdatert i PostgreSQL"); + } + Err(e) => { + tracing::error!(node_id = %node_id, error = %e, "Kunne ikke oppdatere node i PostgreSQL"); + } + } + }); +} + +/// Spawner en tokio-task som sletter noden fra PostgreSQL. +/// Edges slettes automatisk via ON DELETE CASCADE. +fn spawn_pg_delete_node(db: PgPool, node_id: Uuid) { + tokio::spawn(async move { + let result = sqlx::query("DELETE FROM nodes WHERE id = $1") + .bind(node_id) + .execute(&db) + .await; + + match result { + Ok(_) => { + tracing::info!(node_id = %node_id, "Node slettet fra PostgreSQL"); + } + Err(e) => { + tracing::error!(node_id = %node_id, error = %e, "Kunne ikke slette node fra PostgreSQL"); + } + } + }); } diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 1a73391..4973433 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -113,6 +113,9 @@ async fn main() { .route("/health", get(health)) .route("/me", get(me)) .route("/intentions/create_node", post(intentions::create_node)) + .route("/intentions/create_edge", post(intentions::create_edge)) + .route("/intentions/update_node", post(intentions::update_node)) + .route("/intentions/delete_node", post(intentions::delete_node)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/tasks.md b/tasks.md index 8e6b3bb..9eeaa85 100644 --- a/tasks.md +++ b/tasks.md @@ -55,8 +55,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 2.2 Auth-middleware: valider Authentik JWT-tokens, slå opp `auth_identities` → node_id. Returner 401 for ugyldige tokens. - [x] 2.3 SpacetimeDB-klient i maskinrommet: koble til STDB, skriv noder og edges via reducers. - [x] 2.4 Skrivestien: `POST /intentions/create_node` — valider, skriv STDB (instant), spawn async PG-skriving. Returner node_id umiddelbart. -- [~] 2.5 Flere intensjoner: `create_edge`, `update_node`, `delete_node`. Validering av tilgang (created_by eller owner/admin-edge). - > Påbegynt: 2026-03-17T13:11 +- [x] 2.5 Flere intensjoner: `create_edge`, `update_node`, `delete_node`. Validering av tilgang (created_by eller owner/admin-edge). - [ ] 2.6 Docker Compose: legg maskinrommet inn i server-stacken. Intern nettverkstilgang til PG og STDB. ## Fase 3: Frontend — skjelett