Flere intensjoner: create_edge, update_node, delete_node (oppgave 2.5)

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 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 13:25:16 +01:00
parent f7a1a36824
commit 1e9a4c83c3
4 changed files with 524 additions and 38 deletions

View file

@ -67,6 +67,20 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
- Body (JSON): `{ node_kind?, title?, content?, visibility?, metadata? }` - Body (JSON): `{ node_kind?, title?, content?, visibility?, metadata? }`
- Defaults: `node_kind="content"`, `visibility="hidden"`, andre felter tomme - Defaults: `node_kind="content"`, `visibility="hidden"`, andre felter tomme
- Respons: `{ node_id: "<uuid>" }` - Respons: `{ node_id: "<uuid>" }`
- `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: "<uuid>" }`
- `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: "<uuid>" }`
- `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 ## 6. Instruks for Claude Code

View file

@ -4,6 +4,9 @@
// skriver til SpacetimeDB først (instant feedback via WebSocket), // skriver til SpacetimeDB først (instant feedback via WebSocket),
// deretter persisterer til PostgreSQL asynkront. // 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 // Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md
use axum::{extract::State, http::StatusCode, Json}; use axum::{extract::State, http::StatusCode, Json};
@ -15,12 +18,112 @@ use crate::auth::AuthUser;
use crate::AppState; use crate::AppState;
// ============================================================================= // =============================================================================
// create_node // Felles
// ============================================================================= // =============================================================================
/// Gyldige visibility-verdier (speiler PG enum). /// Gyldige visibility-verdier (speiler PG enum).
const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"]; const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"];
#[derive(Serialize)]
pub struct ErrorResponse {
error: String,
}
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn forbidden(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn stdb_error(op: &str, e: crate::stdb::StdbError) -> (StatusCode, Json<ErrorResponse>) {
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<bool, sqlx::Error> {
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<bool, sqlx::Error> {
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<bool, sqlx::Error> {
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)")
.bind(node_id)
.fetch_one(db)
.await
}
// =============================================================================
// create_node
// =============================================================================
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateNodeRequest { pub struct CreateNodeRequest {
/// Hint om hva noden er. Default: "content". /// Hint om hva noden er. Default: "content".
@ -40,11 +143,6 @@ pub struct CreateNodeResponse {
pub node_id: Uuid, pub node_id: Uuid,
} }
#[derive(Serialize)]
pub struct ErrorResponse {
error: String,
}
/// POST /intentions/create_node /// POST /intentions/create_node
/// ///
/// Validerer input, skriver til STDB (instant), spawner async PG-skriving. /// Validerer input, skriver til STDB (instant), spawner async PG-skriving.
@ -92,15 +190,7 @@ pub async fn create_node(
&created_by_str, &created_by_str,
) )
.await .await
.map_err(|e| { .map_err(|e| stdb_error("create_node", e))?;
tracing::error!("STDB create_node feilet: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Kunne ikke skrive til SpacetimeDB: {e}"),
}),
)
})?;
tracing::info!( tracing::info!(
node_id = %node_id, node_id = %node_id,
@ -110,7 +200,7 @@ pub async fn create_node(
); );
// -- Spawn async PG-skriving -- // -- Spawn async PG-skriving --
spawn_pg_insert( spawn_pg_insert_node(
state.db.clone(), state.db.clone(),
node_id, node_id,
node_kind, node_kind,
@ -121,13 +211,312 @@ pub async fn create_node(
user.node_id, user.node_id,
); );
// -- Returner node_id umiddelbart --
Ok(Json(CreateNodeResponse { node_id })) 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<serde_json::Value>,
/// Systemedge — usynlig ved traversering. Default: false.
pub system: Option<bool>,
}
#[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<AppState>,
user: AuthUser,
Json(req): Json<CreateEdgeRequest>,
) -> Result<Json<CreateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- 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<String>,
/// Ny tittel. Beholder eksisterende hvis None.
pub title: Option<String>,
/// Nytt innhold. Beholder eksisterende hvis None.
pub content: Option<String>,
/// Ny synlighet. Beholder eksisterende hvis None.
pub visibility: Option<String>,
/// Ny metadata. Beholder eksisterende hvis None.
pub metadata: Option<serde_json::Value>,
}
#[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<AppState>,
user: AuthUser,
Json(req): Json<UpdateNodeRequest>,
) -> Result<Json<UpdateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- 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<AppState>,
user: AuthUser,
Json(req): Json<DeleteNodeRequest>,
) -> Result<Json<DeleteNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- 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<String>,
content: Option<String>,
visibility: String,
metadata: serde_json::Value,
}
/// Spawner en tokio-task som skriver noden til PostgreSQL i bakgrunnen. /// 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_node(
fn spawn_pg_insert(
db: PgPool, db: PgPool,
node_id: Uuid, node_id: Uuid,
node_kind: String, node_kind: String,
@ -156,28 +545,109 @@ fn spawn_pg_insert(
match result { match result {
Ok(_) => { Ok(_) => {
tracing::info!( tracing::info!(node_id = %node_id, "Node persistert til PostgreSQL");
node_id = %node_id,
"Node persistert til PostgreSQL"
);
} }
Err(e) => { 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<ErrorResponse>) { /// Spawner en tokio-task som skriver edgen til PostgreSQL i bakgrunnen.
( fn spawn_pg_insert_edge(
StatusCode::BAD_REQUEST, db: PgPool,
Json(ErrorResponse { edge_id: Uuid,
error: msg.to_string(), 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");
}
}
});
} }

View file

@ -113,6 +113,9 @@ async fn main() {
.route("/health", get(health)) .route("/health", get(health))
.route("/me", get(me)) .route("/me", get(me))
.route("/intentions/create_node", post(intentions::create_node)) .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()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);

View file

@ -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.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.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. - [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). - [x] 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
- [ ] 2.6 Docker Compose: legg maskinrommet inn i server-stacken. Intern nettverkstilgang til PG og STDB. - [ ] 2.6 Docker Compose: legg maskinrommet inn i server-stacken. Intern nettverkstilgang til PG og STDB.
## Fase 3: Frontend — skjelett ## Fase 3: Frontend — skjelett