From c193b30c8e720cb93f195e007722e74229f91604 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 5 Nov 2025 22:15:27 +0100 Subject: [PATCH] capsets --- backend/src/auth/api_tokens.rs | 63 ++++---- backend/src/openapi.rs | 1 - backend/src/routes/auth.rs | 6 +- backend/src/routes/capability_sets.rs | 21 ++- backend/src/routes/mod.rs | 13 +- backend/src/routes/profile.rs | 99 ++++--------- backend/src/routes/webdav/mod.rs | 2 +- backend/tests/api_tokens_flow.rs | 203 ++++++++++++++++---------- backend/tests/capability_sets_flow.rs | 8 + docs/capability_sets.md | 2 +- 10 files changed, 213 insertions(+), 205 deletions(-) diff --git a/backend/src/auth/api_tokens.rs b/backend/src/auth/api_tokens.rs index e69d265..bdf8b26 100644 --- a/backend/src/auth/api_tokens.rs +++ b/backend/src/auth/api_tokens.rs @@ -9,11 +9,9 @@ use rand::RngCore; use uuid::Uuid; use crate::{ - auth::capability_sets::{ - ensure_capability_set, load_capabilities_for_set, normalize_capabilities, - }, + auth::capability_sets::{load_capabilities_for_set, load_capability_set}, error::AppError, - models::{ApiCapability, ApiToken, NewApiToken}, + models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken}, schema::api_tokens, state::PgPooledConnection, tenants::{apply_api_token_prefix, clear_api_token_prefix}, @@ -37,10 +35,9 @@ pub fn create_api_token( tenant_id: Uuid, label: Option, expires_at: Option, - capabilities: Vec, + capability_set_id: Uuid, ) -> Result { - let capabilities = normalize_capabilities(capabilities)?; - let capability_set = ensure_capability_set(conn, tenant_id, &capabilities)?; + let capability_set = validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?; let raw_secret = generate_secret()?; let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); @@ -120,40 +117,13 @@ pub fn regenerate_api_token( }) } -/// Updates the set of capabilities associated with an API token. -pub fn update_api_token_capabilities( - conn: &mut PgPooledConnection, - token_id: Uuid, - user_id: Uuid, - tenant_id: Option, - capabilities: Vec, -) -> Result { - let capabilities = normalize_capabilities(capabilities)?; - - let token = find_user_token(conn, token_id, user_id, tenant_id)?; - - if token.revoked_at.is_some() { - return Err(AppError::bad_request( - "cannot modify capabilities of a revoked API token", - )); - } - - let capability_set = ensure_capability_set(conn, token.tenant_id, &capabilities)?; - - let updated = diesel::update(api_tokens::table.find(token.id)) - .set(api_tokens::capability_set_id.eq(capability_set.id)) - .get_result::(conn)?; - - Ok(updated) -} - /// Attempts to resolve an API token by its secret value while ensuring it provides the /// requested capability. pub fn find_active_token_by_secret( conn: &mut PgPooledConnection, tenant_id: Option, secret: &str, - required_capability: ApiCapability, + required_capability: Option, ) -> Result, AppError> { if secret.len() < TOKEN_PREFIX_LENGTH { return Ok(None); @@ -181,9 +151,11 @@ pub fn find_active_token_by_secret( })?; for token in candidates { - let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?; - if !capabilities.contains(&required_capability) { - continue; + if let Some(required) = required_capability { + let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?; + if !capabilities.contains(&required) { + continue; + } } if verify_token_secret(secret, &token.token_hash)? { @@ -247,6 +219,21 @@ fn find_user_token( .ok_or_else(AppError::not_found) } +fn validate_capability_set_belongs_to_tenant( + conn: &mut PgPooledConnection, + capability_set_id: Uuid, + tenant_id: Uuid, +) -> Result { + let capability_set = load_capability_set(conn, capability_set_id)?; + if capability_set.tenant_id != tenant_id { + return Err(AppError::bad_request( + "capability set does not belong to the tenant", + )); + } + + Ok(capability_set) +} + fn with_api_token_prefix( conn: &mut PgPooledConnection, prefix: &str, diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index f244a48..e15e58b 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -101,7 +101,6 @@ pub mod schemas { }; pub use crate::routes::profile::{ ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery, - UpdateApiTokenCapabilitiesRequest, }; pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index feecf1b..4cda26c 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -29,8 +29,8 @@ use crate::{ }, error::{AppError, AppResult}, models::{ - ApiCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, - UserMembership, UserSession, + MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership, + UserSession, }, schema::{ magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl, @@ -232,7 +232,7 @@ pub async fn api_token_exchange( let mut conn = state.db_unscoped()?; - let token = find_active_token_by_secret(&mut conn, None, secret, ApiCapability::ProfileRead)? + let token = find_active_token_by_secret(&mut conn, None, secret, None)? .ok_or_else(AppError::unauthorized)?; let user: User = dsl::users.find(token.user_id).first(&mut conn)?; diff --git a/backend/src/routes/capability_sets.rs b/backend/src/routes/capability_sets.rs index bc3bb7f..c6699e1 100644 --- a/backend/src/routes/capability_sets.rs +++ b/backend/src/routes/capability_sets.rs @@ -23,8 +23,6 @@ use crate::{ pub struct CapabilitySetResponse { pub id: Uuid, pub slug: String, - #[serde(default)] - pub label: Option, pub is_system: bool, pub cap_version: i32, pub capabilities: Vec, @@ -81,7 +79,6 @@ fn to_response(set: CapabilitySet, capabilities: Vec) -> Capabili CapabilitySetResponse { id: set.id, slug: set.slug, - label: None, is_system: set.is_system, cap_version: set.cap_version, capabilities, @@ -115,6 +112,22 @@ pub async fn list_capability_sets( Ok(Json(responses)) } +#[utoipa::path( + get, + path = "/api/capabilities", + responses((status = 200, body = [ApiCapability])), + tag = "Capability Sets" +)] +pub async fn list_capabilities( + TenantScopedConn { .. }: TenantScopedConn, +) -> AppResult>> { + let capabilities = ApiCapability::variants() + .iter() + .map(|value| value.parse::().expect("valid capability")) + .collect(); + Ok(Json(capabilities)) +} + #[utoipa::path( get, path = "/api/capability-sets/{id}", @@ -335,12 +348,14 @@ pub async fn delete_capability_set( #[openapi( paths( crate::routes::capability_sets::list_capability_sets, + crate::routes::capability_sets::list_capabilities, crate::routes::capability_sets::get_capability_set, crate::routes::capability_sets::create_capability_set, crate::routes::capability_sets::update_capability_set, crate::routes::capability_sets::delete_capability_set, ), components(schemas( + crate::models::ApiCapability, crate::routes::capability_sets::CapabilitySetResponse, crate::routes::capability_sets::CreateCapabilitySetRequest, crate::routes::capability_sets::UpdateCapabilitySetRequest, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index a53f7a8..f6c8379 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -305,11 +305,6 @@ pub fn create_router(state: AppState) -> Router<()> { post(profile::regenerate_api_token) .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), ) - .route( - "/api-tokens/:id", - patch(profile::update_api_token) - .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), - ) .route( "/api-tokens/:id", delete(profile::delete_api_token) @@ -358,6 +353,13 @@ pub fn create_router(state: AppState) -> Router<()> { ])), ); + let capabilities_routes = Router::new().route( + "/", + get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ); + let protected_state = state.clone(); let assets_routes = Router::new().route( "/:asset_id", @@ -373,6 +375,7 @@ pub fn create_router(state: AppState) -> Router<()> { .nest("/api/correspondents", correspondents_routes) .nest("/api/profile", profile_routes) .nest("/api/capability-sets", capability_sets_routes) + .nest("/api/capabilities", capabilities_routes) .nest("/api/assets", assets_routes) .layer(middleware::from_extractor_with_state::(protected_state)); diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index ceb80b9..0e19516 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -12,14 +12,13 @@ use crate::auth::{ api_tokens::{ create_api_token as issue_token, list_api_tokens as load_tokens, regenerate_api_token as rotate_token, revoke_api_token as revoke_token, - update_api_token_capabilities as update_capabilities, }, - capability_sets::load_capabilities_for_set, + capability_sets::load_capability_set, passkeys::PasskeySummary, TenantScopedConn, }; use crate::error::{AppError, AppResult}; -use crate::models::{ApiCapability, ApiToken}; +use crate::models::ApiToken; use crate::state::{AppState, PgPooledConnection}; use crate::utils::{db::no_content, time::to_iso}; @@ -29,7 +28,7 @@ pub struct ApiTokenResponse { pub tenant_id: Uuid, #[schema(nullable)] pub label: Option, - pub capabilities: Vec, + pub capability_set_id: Uuid, pub created_at: String, #[schema(nullable)] pub last_used_at: Option, @@ -51,14 +50,7 @@ pub struct CreateApiTokenRequest { pub label: Option, #[schema(nullable)] pub expires_at: Option, - #[serde(default)] - #[schema(nullable)] - pub capabilities: Option>, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct UpdateApiTokenCapabilitiesRequest { - pub capabilities: Vec, + pub capability_set_id: Uuid, } #[derive(Debug, Deserialize, ToSchema)] @@ -104,10 +96,7 @@ pub async fn list_api_tokens( }: TenantScopedConn, ) -> AppResult>> { let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; - let mut responses = Vec::with_capacity(tokens.len()); - for token in tokens { - responses.push(api_token_to_response(&mut conn, token)?); - } + let responses = tokens.into_iter().map(api_token_to_response).collect(); Ok(Json(responses)) } @@ -132,13 +121,7 @@ pub async fn create_api_token( None => None, }; - let capabilities = payload - .capabilities - .ok_or_else(|| AppError::bad_request("at least one capability is required"))?; - - if capabilities.is_empty() { - return Err(AppError::bad_request("at least one capability is required")); - } + let capability_set_id = validate_capability_set(&mut conn, tenant_id, payload.capability_set_id)?; let issued = issue_token( &mut conn, @@ -146,10 +129,10 @@ pub async fn create_api_token( tenant_id, payload.label.clone(), expires_at, - capabilities, + capability_set_id, )?; - let token_info = api_token_to_response(&mut conn, issued.record)?; + let token_info = api_token_to_response(issued.record); let response = ApiTokenCreatedResponse { token: issued.token, @@ -176,7 +159,7 @@ pub async fn regenerate_api_token( Path(token_id): Path, ) -> AppResult> { let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?; - let token_info = api_token_to_response(&mut conn, issued.record)?; + let token_info = api_token_to_response(issued.record); let response = ApiTokenCreatedResponse { token: issued.token, token_info, @@ -185,41 +168,6 @@ pub async fn regenerate_api_token( Ok(Json(response)) } -#[utoipa::path( - patch, - path = "/api/profile/api-tokens/{id}", - params(("id" = Uuid, Path, description = "API token ID")), - request_body = UpdateApiTokenCapabilitiesRequest, - responses((status = 200, description = "API token updated", body = ApiTokenResponse)), - tag = "Profile" -)] -pub async fn update_api_token( - TenantScopedConn { - mut conn, - tenant_id, - user_id, - .. - }: TenantScopedConn, - Path(token_id): Path, - Json(payload): Json, -) -> AppResult> { - if payload.capabilities.is_empty() { - return Err(AppError::bad_request("at least one capability is required")); - } - - let updated = update_capabilities( - &mut conn, - token_id, - user_id, - Some(tenant_id), - payload.capabilities, - )?; - - let response = api_token_to_response(&mut conn, updated)?; - - Ok(Json(response)) -} - #[utoipa::path( delete, path = "/api/profile/api-tokens/{id}", @@ -271,10 +219,7 @@ pub async fn delete_passkey( no_content() } -fn api_token_to_response( - conn: &mut PgPooledConnection, - token: ApiToken, -) -> AppResult { +fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { let ApiToken { id, tenant_id, @@ -287,18 +232,16 @@ fn api_token_to_response( .. } = token; - let capabilities = load_capabilities_for_set(conn, capability_set_id)?; - - Ok(ApiTokenResponse { + ApiTokenResponse { id, tenant_id, label, - capabilities, + capability_set_id, created_at: to_iso(created_at), last_used_at: last_used_at.map(to_iso), expires_at: expires_at.map(to_iso), revoked_at: revoked_at.map(to_iso), - }) + } } fn parse_timestamp(value: &str) -> AppResult { @@ -307,13 +250,26 @@ fn parse_timestamp(value: &str) -> AppResult { Ok(dt.naive_utc()) } +fn validate_capability_set( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + capability_set_id: Uuid, +) -> AppResult { + let set = load_capability_set(conn, capability_set_id)?; + if set.tenant_id != tenant_id { + return Err(AppError::bad_request( + "capability set does not belong to the tenant", + )); + } + Ok(set.id) +} + #[derive(utoipa::OpenApi)] #[openapi( paths( crate::routes::profile::list_api_tokens, crate::routes::profile::create_api_token, crate::routes::profile::regenerate_api_token, - crate::routes::profile::update_api_token, crate::routes::profile::delete_api_token, crate::routes::profile::list_passkeys, crate::routes::profile::delete_passkey @@ -323,7 +279,6 @@ fn parse_timestamp(value: &str) -> AppResult { crate::routes::profile::ApiTokenResponse, crate::routes::profile::ApiTokenCreatedResponse, crate::routes::profile::CreateApiTokenRequest, - crate::routes::profile::UpdateApiTokenCapabilitiesRequest, crate::routes::profile::RevokePasskeyQuery, crate::auth::passkeys::PasskeySummary )) diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index 4643620..8bab1bf 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -438,7 +438,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result token, None => { diff --git a/backend/tests/api_tokens_flow.rs b/backend/tests/api_tokens_flow.rs index 461ef9d..cb54186 100644 --- a/backend/tests/api_tokens_flow.rs +++ b/backend/tests/api_tokens_flow.rs @@ -7,7 +7,7 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; -use papercrate::models::{ApiCapability, ApiToken}; +use papercrate::models::ApiToken; use papercrate::routes::webdav; use papercrate::schema::api_tokens; use serde::Deserialize; @@ -26,27 +26,8 @@ const LEGACY_WEBDAV_CAPS: &[&str] = &[ "webdav:read", ]; -const OWNER_CAPS: &[&str] = &[ - "correspondents:edit", - "correspondents:read", - "correspondents:write", - "documents:edit", - "documents:read", - "documents:upload", - "documents:write", - "folders:edit", - "folders:read", - "folders:write", - "profile:read", - "profile:write", - "tags:edit", - "tags:read", - "tags:write", - "webdav:read", - "webdav:write", - "capability_sets:read", - "capability_sets:write", -]; +const READ_ONLY_CAPS: &[&str] = &["documents:read"]; +const LIMITED_WEBDAV_CAPS: &[&str] = &["documents:read", "webdav:read"]; #[derive(Debug, Deserialize)] struct TokenInfo { @@ -54,7 +35,7 @@ struct TokenInfo { label: Option, last_used_at: Option, revoked_at: Option, - capabilities: Vec, + capability_set_id: Uuid, } #[derive(Debug, Deserialize)] @@ -78,6 +59,12 @@ struct TenantView { name: String, } +#[derive(Debug, Deserialize)] +struct CapabilitySetSummary { + id: Uuid, + slug: String, +} + #[tokio::test] async fn api_token_crud_flow() -> Result<()> { let _guard = acquire_db_lock().await; @@ -88,34 +75,30 @@ async fn api_token_crud_flow() -> Result<()> { app.insert_user(username, password, "admin").await?; let access_token = app.login_token(username, password).await?; - let created = create_token( + let legacy_set_id = ensure_capability_set_slug( &app, &access_token, - json!({ "label": "dav", "capabilities": LEGACY_WEBDAV_CAPS }), + "legacy_webdav", + LEGACY_WEBDAV_CAPS, ) .await?; + + let created = + create_token(&app, &access_token, Some("dav"), legacy_set_id, None).await?; let token_id = created.info.id; assert_eq!(created.info.label.as_deref(), Some("dav")); assert!(created.info.last_used_at.is_none()); - assert_capabilities(&created.info.capabilities, LEGACY_WEBDAV_CAPS); + assert_eq!(created.info.capability_set_id, legacy_set_id); let regenerated = regenerate_token(&app, &access_token, token_id).await?; assert_eq!(regenerated.info.id, token_id); assert_ne!(regenerated.token, created.token); assert!(regenerated.info.last_used_at.is_none()); - let updated = update_token_capabilities( - &app, - &access_token, - token_id, - json!({ "capabilities": OWNER_CAPS }), - ) - .await?; - assert_capabilities(&updated.capabilities, OWNER_CAPS); - let listed = list_tokens(&app, &access_token).await?; assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, token_id); + assert_eq!(listed[0].capability_set_id, legacy_set_id); let tenant_id_for_token = app .with_conn(move |conn| { @@ -127,6 +110,20 @@ async fn api_token_crud_flow() -> Result<()> { }) .await?; + let readonly_set_id = ensure_capability_set_slug( + &app, + &access_token, + "readonly", + READ_ONLY_CAPS, + ) + .await?; + + let readonly_token = + create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?; + let readonly_exchange = exchange_token(&app, &readonly_token.token).await?; + assert_eq!(readonly_exchange.tenant.id, tenant_id_for_token); + delete_token(&app, &access_token, readonly_token.info.id).await?; + let exchange = exchange_token(&app, ®enerated.token).await?; assert_eq!(exchange.token_type, "Bearer"); assert!(!exchange.access_token.is_empty()); @@ -155,12 +152,16 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { app.insert_user(username, password, "admin").await?; let access_token = app.login_token(username, password).await?; - let created = create_token( + let legacy_set_id = ensure_capability_set_slug( &app, &access_token, - json!({ "label": "webdav", "capabilities": LEGACY_WEBDAV_CAPS }), + "legacy_webdav", + LEGACY_WEBDAV_CAPS, ) .await?; + + let created = + create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?; let token_id = created.info.id; let router = webdav::create_router().with_state(app.state.clone()); @@ -229,16 +230,18 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { assert_eq!(response.status(), StatusCode::MULTI_STATUS); // Token without webdav_read cannot authenticate. - let limited_token = create_token( + let read_only_set_id = ensure_capability_set_slug( &app, &access_token, - json!({ - "label": "limited", - "capabilities": ["documents:read"] - }), + "documents_read", + READ_ONLY_CAPS, ) .await?; + let limited_token = + create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?; + assert_eq!(limited_token.info.capability_set_id, read_only_set_id); + let limited_header = format!( "Basic {}", BASE64.encode(format!("{}:{}", username, limited_token.token)) @@ -253,20 +256,28 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { let limited_response = router.clone().oneshot(limited_request).await?; assert_eq!(limited_response.status(), StatusCode::UNAUTHORIZED); - let _ = app - .patch_json( - &format!("/api/profile/api-tokens/{}", limited_token.info.id), - &json!({ - "capabilities": ["documents:read", "webdav:read"] - }), - Some(&access_token), - ) - .await?; + let limited_set_id = ensure_capability_set_slug( + &app, + &access_token, + "documents_read_webdav", + LIMITED_WEBDAV_CAPS, + ) + .await?; + + let upgraded_token = + create_token(&app, &access_token, Some("limited-webdav"), limited_set_id, None).await?; + assert_eq!(upgraded_token.info.capability_set_id, limited_set_id); let upgraded_request = Request::builder() .method(propfind.clone()) .uri("/") - .header(header::AUTHORIZATION, limited_header) + .header( + header::AUTHORIZATION, + format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, upgraded_token.token)) + ), + ) .header("depth", "0") .body(Body::empty())?; let upgraded_response = router.clone().oneshot(upgraded_request).await?; @@ -290,8 +301,22 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { async fn create_token( app: &TestApp, access_token: &str, - payload: serde_json::Value, + label: Option<&str>, + capability_set_id: Uuid, + expires_at: Option<&str>, ) -> Result { + let mut payload = json!({ + "capability_set_id": capability_set_id, + }); + + if let Some(label) = label { + payload["label"] = json!(label); + } + + if let Some(expires) = expires_at { + payload["expires_at"] = json!(expires); + } + let response = app .post_json("/api/profile/api-tokens", &payload, Some(access_token)) .await?; @@ -317,24 +342,6 @@ async fn regenerate_token( Ok(serde_json::from_slice(&body)?) } -async fn update_token_capabilities( - app: &TestApp, - access_token: &str, - token_id: Uuid, - payload: serde_json::Value, -) -> Result { - let response = app - .patch_json( - &format!("/api/profile/api-tokens/{token_id}"), - &payload, - Some(access_token), - ) - .await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_vec(response.into_body()).await?; - Ok(serde_json::from_slice(&body)?) -} - async fn list_tokens(app: &TestApp, access_token: &str) -> Result> { let response = app .get("/api/profile/api-tokens", Some(access_token)) @@ -355,17 +362,51 @@ async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Resu Ok(()) } -fn assert_capabilities(actual: &[ApiCapability], expected: &[&str]) { - assert_eq!(actual.len(), expected.len()); - for capability in expected { - assert!( - actual - .iter() - .any(|candidate| candidate.as_str() == *capability), - "capability '{}' not present", - capability - ); +async fn ensure_capability_set_slug( + app: &TestApp, + access_token: &str, + slug: &str, + capabilities: &[&str], +) -> Result { + if let Some(existing) = find_capability_set_slug(app, access_token, slug).await? { + return Ok(existing); } + + let response = app + .post_json( + "/api/capability-sets", + &json!({ + "slug": slug, + "capabilities": capabilities, + }), + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let body = body_to_vec(response.into_body()).await?; + let summary: CapabilitySetSummary = serde_json::from_slice(&body)?; + Ok(summary.id) +} + +async fn find_capability_set_slug( + app: &TestApp, + access_token: &str, + slug: &str, +) -> Result> { + let sets = list_capability_sets(app, access_token).await?; + Ok(sets.into_iter().find(|set| set.slug == slug).map(|set| set.id)) +} + +async fn list_capability_sets( + app: &TestApp, + access_token: &str, +) -> Result> { + let response = app + .get("/api/capability-sets", Some(access_token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) } async fn exchange_token(app: &TestApp, api_token: &str) -> Result { let response = app diff --git a/backend/tests/capability_sets_flow.rs b/backend/tests/capability_sets_flow.rs index 43a0f91..7ac6e18 100644 --- a/backend/tests/capability_sets_flow.rs +++ b/backend/tests/capability_sets_flow.rs @@ -40,6 +40,14 @@ async fn capability_set_crud_flow() -> Result<()> { assert!(sets.iter().any(|set| set.slug == "readonly")); assert!(sets.iter().any(|set| set.slug == "webdav")); + let capabilities_resp = app.get("/api/capabilities", Some(&token)).await?; + assert_eq!(capabilities_resp.status(), StatusCode::OK); + let capabilities_body = body_to_vec(capabilities_resp.into_body()).await?; + let capabilities: Vec = serde_json::from_slice(&capabilities_body)?; + assert!(capabilities.contains(&"documents:read".to_string())); + assert!(capabilities.contains(&"capability_sets:write".to_string())); + assert_eq!(capabilities.len(), 18); + // Create a new capability set. let create = app .post_json( diff --git a/docs/capability_sets.md b/docs/capability_sets.md index 1481c67..0419a43 100644 --- a/docs/capability_sets.md +++ b/docs/capability_sets.md @@ -91,7 +91,7 @@ curl -X DELETE \ ## Assigning Sets - **User memberships**: change the `capability_set_id` column (via future admin APIs or direct SQL) to reassign a user. The authentication pipeline will enforce the new capabilities automatically. -- **API tokens**: `PATCH /api/profile/api-tokens/{id}` accepts a capability array; underneath the token is mapped to the corresponding capability set. With the new endpoints, we can expose a `capability_set_id` field to limit tokens to specific bundles. +- **API tokens**: `POST /api/profile/api-tokens` requires a `capability_set_id`. Tokens are bound to the selected set; raw capability arrays are no longer accepted. ## Guard Coverage