capsets
This commit is contained in:
@@ -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<String>,
|
||||
expires_at: Option<NaiveDateTime>,
|
||||
capabilities: Vec<ApiCapability>,
|
||||
capability_set_id: Uuid,
|
||||
) -> Result<IssuedApiToken, AppError> {
|
||||
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<Uuid>,
|
||||
capabilities: Vec<ApiCapability>,
|
||||
) -> Result<ApiToken, AppError> {
|
||||
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::<ApiToken>(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<Uuid>,
|
||||
secret: &str,
|
||||
required_capability: ApiCapability,
|
||||
required_capability: Option<ApiCapability>,
|
||||
) -> Result<Option<ApiToken>, 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<CapabilitySet, AppError> {
|
||||
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<T, F>(
|
||||
conn: &mut PgPooledConnection,
|
||||
prefix: &str,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -23,8 +23,6 @@ use crate::{
|
||||
pub struct CapabilitySetResponse {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub cap_version: i32,
|
||||
pub capabilities: Vec<ApiCapability>,
|
||||
@@ -81,7 +79,6 @@ fn to_response(set: CapabilitySet, capabilities: Vec<ApiCapability>) -> 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<Json<Vec<ApiCapability>>> {
|
||||
let capabilities = ApiCapability::variants()
|
||||
.iter()
|
||||
.map(|value| value.parse::<ApiCapability>().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,
|
||||
|
||||
@@ -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::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub capabilities: Vec<ApiCapability>,
|
||||
pub capability_set_id: Uuid,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub last_used_at: Option<String>,
|
||||
@@ -51,14 +50,7 @@ pub struct CreateApiTokenRequest {
|
||||
pub label: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub capabilities: Option<Vec<ApiCapability>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateApiTokenCapabilitiesRequest {
|
||||
pub capabilities: Vec<ApiCapability>,
|
||||
pub capability_set_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -104,10 +96,7 @@ pub async fn list_api_tokens(
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
|
||||
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<Uuid>,
|
||||
) -> AppResult<Json<ApiTokenCreatedResponse>> {
|
||||
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<Uuid>,
|
||||
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
|
||||
) -> AppResult<Json<ApiTokenResponse>> {
|
||||
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<ApiTokenResponse> {
|
||||
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<NaiveDateTime> {
|
||||
@@ -307,13 +250,26 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
||||
Ok(dt.naive_utc())
|
||||
}
|
||||
|
||||
fn validate_capability_set(
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
capability_set_id: Uuid,
|
||||
) -> AppResult<Uuid> {
|
||||
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<NaiveDateTime> {
|
||||
crate::routes::profile::ApiTokenResponse,
|
||||
crate::routes::profile::ApiTokenCreatedResponse,
|
||||
crate::routes::profile::CreateApiTokenRequest,
|
||||
crate::routes::profile::UpdateApiTokenCapabilitiesRequest,
|
||||
crate::routes::profile::RevokePasskeyQuery,
|
||||
crate::auth::passkeys::PasskeySummary
|
||||
))
|
||||
|
||||
@@ -438,7 +438,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
&mut conn,
|
||||
None,
|
||||
secret,
|
||||
ApiCapability::WebdavRead,
|
||||
Some(ApiCapability::WebdavRead),
|
||||
)? {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
|
||||
@@ -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<String>,
|
||||
last_used_at: Option<String>,
|
||||
revoked_at: Option<String>,
|
||||
capabilities: Vec<ApiCapability>,
|
||||
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<CreateTokenResponse> {
|
||||
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<TokenInfo> {
|
||||
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<Vec<TokenInfo>> {
|
||||
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<Uuid> {
|
||||
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<Option<Uuid>> {
|
||||
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<Vec<CapabilitySetSummary>> {
|
||||
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<LoginResponseView> {
|
||||
let response = app
|
||||
|
||||
@@ -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<String> = 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user