feat(drive): add policy forbid_public_links
This commit is contained in:
@@ -24,7 +24,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::drive_repository::{DriveRepository, DriveRepositoryError};
|
||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
@@ -373,6 +373,62 @@ impl DriveManagementService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/policies`. Owner-only mutation of the
|
||||
/// drive's `policies` JSONB bag (§5 — "edit policies" is in the
|
||||
/// drive owner bundle, applies to personal AND shared drives).
|
||||
/// JSONB-level merge preserves unknown keys; only the partial
|
||||
/// supplied is overwritten. Returns the post-merge typed view.
|
||||
///
|
||||
/// `caller_is_admin` mirrors the membership endpoints — skips the
|
||||
/// per-drive Manage check. Audit emits `drive.policy_changed` with
|
||||
/// the post-merge bag for steady-state observability; ops can grep
|
||||
/// for the specific keys that flipped against the prior values.
|
||||
pub async fn update_policies(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
caller_is_admin: bool,
|
||||
drive_id: Uuid,
|
||||
partial: crate::domain::entities::drive::DrivePolicies,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DomainError> {
|
||||
let resource = Resource::Drive(drive_id);
|
||||
if !caller_is_admin {
|
||||
self.authz
|
||||
.require(Subject::User(caller_id), Permission::Manage, resource)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let merged = self
|
||||
.drive_repo
|
||||
.update_policies(drive_id, &partial)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => DomainError::internal_error(
|
||||
"Drive",
|
||||
format!("update_policies failed: {other:?}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = if caller_is_admin {
|
||||
"drive.policy_changed_via_admin"
|
||||
} else {
|
||||
"drive.policy_changed"
|
||||
},
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
forbid_sharing = merged.forbid_sharing,
|
||||
forbid_external_sharing = merged.forbid_external_sharing,
|
||||
forbid_public_links = merged.forbid_public_links,
|
||||
forbid_cross_drive_move = merged.forbid_cross_drive_move,
|
||||
"📜 drive policies updated",
|
||||
);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
// ── Business rules ──────────────────────────────────────────────────────
|
||||
|
||||
/// Personal drives are single-user single-owner; any member mutation is
|
||||
|
||||
@@ -4,8 +4,10 @@ use thiserror::Error;
|
||||
use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
@@ -80,6 +82,10 @@ pub struct ShareService {
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
/// Drive repository — D5 enforcement reads the drive's `policies`
|
||||
/// JSONB before any per-resource action that a policy can gate
|
||||
/// (e.g. `forbid_public_links` for token-share creation).
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
/// ReBAC engine — used to create/revoke token grants that mirror public
|
||||
/// share links so that `GET /api/grants/outgoing` reflects them.
|
||||
@@ -90,11 +96,13 @@ pub struct ShareService {
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
@@ -103,6 +111,7 @@ impl ShareService {
|
||||
share_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
drive_repository,
|
||||
password_hasher,
|
||||
authorization,
|
||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||
@@ -234,6 +243,40 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||
|
||||
// D5: `forbid_public_links` policy gate. The drive owner can
|
||||
// disable anonymous-link creation on every resource in their
|
||||
// drive without per-resource intervention. Lookup is one JOIN
|
||||
// (`get_policies_for_file` / `_for_folder` — single round-trip);
|
||||
// a denial returns `OperationNotSupported` with an audit log
|
||||
// mirroring the per-drive membership refusal shape used in
|
||||
// `drive_management_service::refuse_if_personal`.
|
||||
let item_uuid = Uuid::parse_str(&dto.item_id)
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let policies = match item_type {
|
||||
ShareItemType::File => self.drive_repository.get_policies_for_file(item_uuid).await,
|
||||
ShareItemType::Folder => {
|
||||
self.drive_repository
|
||||
.get_policies_for_folder(item_uuid)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
if policies.forbid_public_links {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "share.rejected",
|
||||
reason = "forbid_public_links",
|
||||
caller_id = %user_id,
|
||||
item_id = %dto.item_id,
|
||||
item_type = %dto.item_type,
|
||||
"👮🏻♂️ public-link creation refused: drive policy forbid_public_links",
|
||||
);
|
||||
return Err(DomainError::operation_not_supported(
|
||||
"Share",
|
||||
"This drive does not allow public links.",
|
||||
));
|
||||
}
|
||||
|
||||
let password_hash = match dto.password {
|
||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||
None => None,
|
||||
|
||||
+3
-1
@@ -845,6 +845,7 @@ impl AppServiceFactory {
|
||||
repos: &RepositoryServices,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
) -> Option<Arc<ShareService>> {
|
||||
if !self.config.features.enable_file_sharing {
|
||||
tracing::info!("File sharing service is disabled in configuration");
|
||||
@@ -867,6 +868,7 @@ impl AppServiceFactory {
|
||||
share_repository,
|
||||
repos.file_read_repository.clone(),
|
||||
repos.folder_repository.clone(),
|
||||
drive_repo.clone(),
|
||||
password_hasher,
|
||||
authorization.clone(),
|
||||
));
|
||||
@@ -1229,7 +1231,7 @@ impl AppServiceFactory {
|
||||
);
|
||||
|
||||
// 5. Share service
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization);
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization, &drive_repo);
|
||||
apps.share_service = share_service.clone();
|
||||
|
||||
let share_browse_service = share_service.as_ref().map(|s| {
|
||||
|
||||
@@ -133,4 +133,50 @@ impl Drive {
|
||||
pub fn is_personal(&self) -> bool {
|
||||
matches!(self.kind, DriveKind::Personal)
|
||||
}
|
||||
|
||||
/// Typed view of `policies` for enforcement code. Lenient deserialise:
|
||||
/// unknown keys are preserved on disk (the column stays the canonical
|
||||
/// JSONB bag) but ignored here, missing keys default to `false`.
|
||||
/// See `docs/plan/drive.md` §8.
|
||||
pub fn typed_policies(&self) -> DrivePolicies {
|
||||
DrivePolicies::from_value(&self.policies)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed mirror of the `policies` JSONB. Five known keys; the JSONB column
|
||||
/// remains the source of truth and may carry unknown keys verbatim — this
|
||||
/// struct is a read view for enforcement and a write view for the policy
|
||||
/// PATCH endpoint. Every field defaults to `false` (everything allowed)
|
||||
/// so a freshly-created drive doesn't need a populated policy bag.
|
||||
///
|
||||
/// See `docs/plan/drive.md` §8 for the enforcement matrix
|
||||
/// (which callsite each key is checked at).
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct DrivePolicies {
|
||||
/// Disables per-resource grants on resources in this drive. Drive-level
|
||||
/// membership (Owner/Editor/Viewer) still works. Enforced at
|
||||
/// `grant_handler::create_grant`.
|
||||
pub forbid_sharing: bool,
|
||||
/// Blocks grants whose subject has `users.is_external = true`. Enforced
|
||||
/// at `magic_link_invite_service::resolve_or_create_recipient` and
|
||||
/// `grant_handler::create_grant`.
|
||||
pub forbid_external_sharing: bool,
|
||||
/// Blocks anonymous-link (token-share) creation on resources in this
|
||||
/// drive. Enforced at `share_service::create_shared_link`.
|
||||
pub forbid_public_links: bool,
|
||||
/// Blocks MOVE when `src.drive_id != dst.drive_id`. Enforced at the
|
||||
/// move endpoints. Lands paired with D6's cross-drive move work.
|
||||
pub forbid_cross_drive_move: bool,
|
||||
}
|
||||
|
||||
impl DrivePolicies {
|
||||
/// Parse from the raw JSONB. Lenient — unknown keys are dropped from
|
||||
/// the typed view but remain in the source `serde_json::Value`. A
|
||||
/// malformed bag (e.g. wrong type) falls back to the all-false default
|
||||
/// rather than refusing the read; enforcement code never panics on
|
||||
/// existing data.
|
||||
pub fn from_value(value: &serde_json::Value) -> Self {
|
||||
serde_json::from_value(value.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,41 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
/// necessarily a member, so the per-drive role would be misleading
|
||||
/// here.
|
||||
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||
|
||||
/// Resolve a file's owning drive policies in one round-trip. Used by
|
||||
/// D5 enforcement points (`forbid_public_links`, `forbid_sharing`, …)
|
||||
/// to gate per-resource actions without a separate file-lookup +
|
||||
/// drive-lookup pair.
|
||||
///
|
||||
/// Returns `NotFound` when the file id is gone or its `drive_id`
|
||||
/// doesn't resolve to a drive row (a state the no-orphan triggers
|
||||
/// prevent in production, but the caller should still propagate the
|
||||
/// 404 cleanly).
|
||||
async fn get_policies_for_file(
|
||||
&self,
|
||||
file_id: Uuid,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
|
||||
/// Resolve a folder's owning drive policies in one round-trip. Same
|
||||
/// shape as [`Self::get_policies_for_file`].
|
||||
async fn get_policies_for_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
|
||||
/// Merge the given partial policy bag into the drive's existing
|
||||
/// `policies` JSONB, returning the updated bag. JSONB-level merge
|
||||
/// preserves unknown keys already present on disk (the column stays
|
||||
/// the canonical bag — see `DrivePolicies::from_value`). `caller_id`
|
||||
/// is recorded for the audit log emitted at the service layer.
|
||||
///
|
||||
/// Caller is responsible for the `Manage` permission check; this
|
||||
/// method does not re-verify.
|
||||
async fn update_policies(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
partial: &crate::domain::entities::drive::DrivePolicies,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
}
|
||||
|
||||
/// Convenience: convert the canonical kind discriminator from its SQL
|
||||
|
||||
@@ -533,4 +533,81 @@ impl DriveRepository for DrivePgRepository {
|
||||
|
||||
rows.iter().map(Self::row_to_drive_with_name).collect()
|
||||
}
|
||||
|
||||
async fn get_policies_for_file(
|
||||
&self,
|
||||
file_id: Uuid,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT d.policies \
|
||||
FROM storage.drives d \
|
||||
JOIN storage.files f ON f.drive_id = d.id \
|
||||
WHERE f.id = $1",
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("get_policies_for_file", e))?;
|
||||
let raw = row
|
||||
.ok_or_else(|| DriveRepositoryError::NotFound(file_id.to_string()))?
|
||||
.0;
|
||||
Ok(crate::domain::entities::drive::DrivePolicies::from_value(
|
||||
&raw,
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_policies_for_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT d.policies \
|
||||
FROM storage.drives d \
|
||||
JOIN storage.folders fo ON fo.drive_id = d.id \
|
||||
WHERE fo.id = $1",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("get_policies_for_folder", e))?;
|
||||
let raw = row
|
||||
.ok_or_else(|| DriveRepositoryError::NotFound(folder_id.to_string()))?
|
||||
.0;
|
||||
Ok(crate::domain::entities::drive::DrivePolicies::from_value(
|
||||
&raw,
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_policies(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
partial: &crate::domain::entities::drive::DrivePolicies,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
|
||||
// JSONB-level merge (`||`) keeps unknown keys already on disk —
|
||||
// the column remains the canonical bag (see
|
||||
// `DrivePolicies::from_value` — typed read is lenient, untyped
|
||||
// write is preserving). RETURNING surfaces the post-merge bag so
|
||||
// the audit log shows what the row actually carries afterwards.
|
||||
let partial_json = serde_json::to_value(partial).map_err(|e| {
|
||||
DriveRepositoryError::StorageError(format!("serialise partial policies: {e}"))
|
||||
})?;
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"UPDATE storage.drives \
|
||||
SET policies = policies || $2, \
|
||||
updated_at = now() \
|
||||
WHERE id = $1 \
|
||||
RETURNING policies",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.bind(&partial_json)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("update_policies", e))?;
|
||||
let raw = row
|
||||
.ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))?
|
||||
.0;
|
||||
Ok(crate::domain::entities::drive::DrivePolicies::from_value(
|
||||
&raw,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,3 +395,89 @@ pub async fn delete_drive(
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Body for `PATCH /api/drives/{id}/policies` (D5).
|
||||
///
|
||||
/// Partial merge: any field left out of the JSON keeps its current
|
||||
/// JSONB value (the repo uses `policies || $partial`). Each field
|
||||
/// defaults to `false` in `DrivePolicies`, but the merge is keyed on
|
||||
/// presence — so omitting a field means "leave it alone", not "set
|
||||
/// it to false". Clients flip a single key at a time without
|
||||
/// round-tripping the whole bag.
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateDrivePoliciesDto {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forbid_sharing: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forbid_external_sharing: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forbid_public_links: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forbid_cross_drive_move: Option<bool>,
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/policies` — Owner-only policy update (D5).
|
||||
///
|
||||
/// Caller must hold `Manage` on the drive (Owner role bundle). Personal
|
||||
/// drives are eligible too — a user can disable `forbid_public_links`
|
||||
/// on their own Personal drive without the membership API. Partial
|
||||
/// merge into the JSONB `policies` column; the post-merge typed view
|
||||
/// is returned.
|
||||
///
|
||||
/// Audit: emits `drive.policy_changed` with every key's post-merge
|
||||
/// value (steady-state observability).
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/drives/{id}/policies",
|
||||
params(("id" = Uuid, Path, description = "Drive UUID")),
|
||||
request_body = UpdateDrivePoliciesDto,
|
||||
responses(
|
||||
(status = 200, description = "Policies merged"),
|
||||
(status = 404, description = "Drive not found or caller lacks Manage"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "drives"
|
||||
)]
|
||||
pub async fn update_drive_policies(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(drive_id): Path<Uuid>,
|
||||
axum::Json(dto): axum::Json<UpdateDrivePoliciesDto>,
|
||||
) -> impl IntoResponse {
|
||||
// Translate the Option-per-field DTO into a serde_json partial that
|
||||
// only carries the supplied keys, so the JSONB merge in
|
||||
// `update_policies` skips fields the caller didn't touch. Building a
|
||||
// `DrivePolicies` and serialising would lose the partial-update
|
||||
// semantics (every field defaults to false → omitted vs. "set to
|
||||
// false" become indistinguishable on the wire).
|
||||
let mut partial_obj = serde_json::Map::new();
|
||||
if let Some(v) = dto.forbid_sharing {
|
||||
partial_obj.insert("forbid_sharing".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
if let Some(v) = dto.forbid_external_sharing {
|
||||
partial_obj.insert("forbid_external_sharing".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
if let Some(v) = dto.forbid_public_links {
|
||||
partial_obj.insert("forbid_public_links".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
if let Some(v) = dto.forbid_cross_drive_move {
|
||||
partial_obj.insert("forbid_cross_drive_move".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
let partial_value = serde_json::Value::Object(partial_obj);
|
||||
let partial: crate::domain::entities::drive::DrivePolicies =
|
||||
match serde_json::from_value(partial_value) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return AppError::bad_request(format!("invalid policy body: {e}")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match state
|
||||
.drive_management_service
|
||||
.update_policies(auth_user.id, false, drive_id, partial)
|
||||
.await
|
||||
{
|
||||
Ok(merged) => (StatusCode::OK, axum::Json(merged)).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,10 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
get(drive_handler::list_drives).post(drive_handler::create_drive),
|
||||
)
|
||||
.route("/{id}", axum::routing::delete(drive_handler::delete_drive))
|
||||
.route(
|
||||
"/{id}/policies",
|
||||
patch(drive_handler::update_drive_policies),
|
||||
)
|
||||
.route(
|
||||
"/{id}/members",
|
||||
get(drive_handler::list_drive_members).post(drive_handler::add_drive_member),
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# =============================================================
|
||||
# OxiCloud – D5 drive policies: `forbid_public_links`
|
||||
# =============================================================
|
||||
# Run:
|
||||
# hurl --variables-file tests/api/test.env --file-root tests \
|
||||
# --test tests/api/drive_policies.hurl
|
||||
#
|
||||
# The model under test (`docs/plan/drive.md` §8):
|
||||
# Each drive carries a `policies` JSONB. Five known keys, all
|
||||
# default-false. The first key shipped is `forbid_public_links`,
|
||||
# which blocks anonymous token-share creation on every resource
|
||||
# in the drive. Enforced at `share_service::create_shared_link`;
|
||||
# mutated by `PATCH /api/drives/{id}/policies` (Owner-only,
|
||||
# per the §4 role bundle).
|
||||
#
|
||||
# Cases:
|
||||
# 1. Baseline — policy off → POST /api/shares succeeds (201).
|
||||
# 2. Owner flips `forbid_public_links` via PATCH → 200,
|
||||
# response echoes the merged bag.
|
||||
# 3. Policy on → POST /api/shares refused with
|
||||
# OperationNotSupported (405) and the share row is NOT created.
|
||||
# 4. Owner flips the policy back off → POST /api/shares succeeds
|
||||
# again (proves merge semantics; the typed write doesn't
|
||||
# clobber unrelated keys).
|
||||
#
|
||||
# Self-contained: provisions `dp_owner` so it can run alongside
|
||||
# the rest of the suite. The user's default Personal drive is
|
||||
# the test surface — the policy applies equally to personal and
|
||||
# shared drives (`Owner` bundle includes "edit policies").
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Admin login.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
admin_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Provision `dp_owner`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "dp_owner",
|
||||
"password": "DpOwnerPwd1!",
|
||||
"email": "dp_owner@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "dp_owner", "password": "DpOwnerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
owner_token: jsonpath "$.access_token"
|
||||
owner_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Find the user's default Personal drive + root.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
personal_root_id: jsonpath "$[0].id"
|
||||
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
personal_drive_id: jsonpath "$[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$[0].kind" == "personal"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Seed a file to share.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{owner_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{personal_root_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
file_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Case 1: baseline. Policy off → POST /api/shares OK.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/shares
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"item_id": "{{file_id}}",
|
||||
"item_type": "file"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
baseline_share_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# Clean up the baseline share so the policy-on case starts fresh.
|
||||
DELETE {{base_url}}/api/shares/{{baseline_share_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Case 2: flip `forbid_public_links` on.
|
||||
# PATCH returns the merged bag.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"forbid_public_links": true
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.forbid_public_links" == true
|
||||
jsonpath "$.forbid_sharing" == false
|
||||
jsonpath "$.forbid_external_sharing" == false
|
||||
jsonpath "$.forbid_cross_drive_move" == false
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Case 3: policy on → POST /api/shares refused (405).
|
||||
# DomainError::operation_not_supported maps to HTTP 405
|
||||
# (Method Not Allowed) per the interface error map.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/shares
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"item_id": "{{file_id}}",
|
||||
"item_type": "file"
|
||||
}
|
||||
|
||||
HTTP 405
|
||||
|
||||
|
||||
# Confirm no share row was created — the listing on this file
|
||||
# is empty.
|
||||
GET {{base_url}}/api/shares?item_id={{file_id}}&item_type=file
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Case 4: flip the policy back off → share succeeds.
|
||||
# Proves the partial-merge: setting `forbid_public_links`
|
||||
# to false doesn't touch unrelated keys (still false here,
|
||||
# but the round-trip exercises the merge path).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"forbid_public_links": false
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.forbid_public_links" == false
|
||||
|
||||
|
||||
POST {{base_url}}/api/shares
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"item_id": "{{file_id}}",
|
||||
"item_type": "file"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
final_share_id: jsonpath "$.id"
|
||||
|
||||
DELETE {{base_url}}/api/shares/{{final_share_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Cleanup. Admin deletes the test user; cascade reaps
|
||||
# the default Personal drive, root folder, and file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
HTTP 200
|
||||
+2
-1
@@ -163,7 +163,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/dedup_create.hurl" \
|
||||
"$API_DIR/trash_per_drive.hurl" \
|
||||
"$API_DIR/drive_quota.hurl" \
|
||||
"$API_DIR/user_envelope_quota.hurl"
|
||||
"$API_DIR/user_envelope_quota.hurl" \
|
||||
"$API_DIR/drive_policies.hurl"
|
||||
|
||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user