use anyhow::Result;
use clap::Parser;
use serde::Serialize;
use crate::cli::drive::format::{
output_as, sanitize_for_terminal, write_scalar_jsonl, JsonlSerialize, OutputFormat,
};
use crate::cli::drive::helpers::active_account_rules;
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::folder_ancestry::{self, DecisionSource, FileTargetDecision};
use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
use crate::drive::write_gate::{self, DriveOperation, FolderPermissionRule, Verdict};
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum OperationArg {
Read,
Create,
Upload,
Edit,
SheetsWrite,
SheetsStructure,
SheetsDelete,
SheetsProtection,
DocsWrite,
Trash,
SlidesWrite,
DocsDelete,
DocsFormat,
DocsStructure,
DocsTableDelete,
}
impl From<OperationArg> for DriveOperation {
fn from(arg: OperationArg) -> Self {
match arg {
OperationArg::Read => Self::Read,
OperationArg::Create => Self::Create,
OperationArg::Upload => Self::Upload,
OperationArg::Edit => Self::Edit,
OperationArg::SheetsWrite => Self::SheetsWrite,
OperationArg::SheetsStructure => Self::SheetsStructure,
OperationArg::SheetsDelete => Self::SheetsDelete,
OperationArg::SheetsProtection => Self::SheetsProtection,
OperationArg::DocsWrite => Self::DocsWrite,
OperationArg::Trash => Self::Trash,
OperationArg::SlidesWrite => Self::SlidesWrite,
OperationArg::DocsDelete => Self::DocsDelete,
OperationArg::DocsFormat => Self::DocsFormat,
OperationArg::DocsStructure => Self::DocsStructure,
OperationArg::DocsTableDelete => Self::DocsTableDelete,
}
}
}
#[derive(Parser)]
pub struct CheckCommand {
pub id: String,
#[arg(long, value_enum)]
pub operation: OperationArg,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl CheckCommand {
pub async fn execute(self, client: &DriveClient) -> Result<()> {
let rules = active_account_rules()?;
run_check(
client,
&self.id,
self.operation.into(),
&rules,
&self.output,
)
.await
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckReport {
pub target_id: String,
pub operation: String,
pub verdict: String,
pub decided_by_folder_id: Option<String>,
pub decided_by_depth: Option<usize>,
pub decided_by_file_id: Option<String>,
pub evaluated_via: String,
pub requires_lease: bool,
}
impl JsonlSerialize for CheckReport {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
write_scalar_jsonl(self, out)
}
}
async fn run_check(
client: &DriveClient,
target_id: &str,
op: DriveOperation,
rules: &[FolderPermissionRule],
output: &OutputFormat,
) -> Result<()> {
let files_api = FilesApi::new(client);
let evaluated = evaluate_target(&files_api, target_id, op, rules).await?;
let requires_lease = op.ever_requires_lease() && evaluated.requires_lease;
let evaluated_via = evaluated_via(evaluated.source).to_string();
let decision = evaluated.decision;
let log_fields = write_gate::decided_by_log_fields(decision.decided_by.as_ref());
let report = CheckReport {
target_id: target_id.to_string(),
operation: op.to_string(),
verdict: match decision.verdict {
Verdict::Allow => "allow".to_string(),
Verdict::Deny => "deny".to_string(),
},
decided_by_folder_id: log_fields.folder_id,
decided_by_depth: log_fields.depth,
decided_by_file_id: log_fields.file_id,
evaluated_via,
requires_lease,
};
if output_as(&report, output)? {
return Ok(());
}
print_report(&report);
Ok(())
}
async fn evaluate_target(
files_api: &FilesApi<'_>,
target_id: &str,
op: DriveOperation,
rules: &[FolderPermissionRule],
) -> Result<FileTargetDecision> {
let target = files_api.get_metadata(target_id).await?;
if target.mime_type == GOOGLE_FOLDER_MIME_TYPE {
let decision = folder_ancestry::resolve_decision_from(files_api, target, op, rules).await?;
let requires_lease =
write_gate::decided_rule_requires_lease(decision.decided_by.as_ref(), op, rules);
return Ok(FileTargetDecision {
decision,
resolved_folder_id: None,
source: DecisionSource::FolderChain,
requires_lease,
});
}
folder_ancestry::resolve_decision_for_file_target(files_api, &target, op, rules).await
}
const fn evaluated_via(source: DecisionSource) -> &'static str {
match source {
DecisionSource::FileRule => "file-rule",
DecisionSource::FolderChain => "folder-chain",
DecisionSource::NoVisibleParents => "no-visible-parents",
}
}
fn should_note_no_visible_parents(evaluated_via: &str, verdict: &str) -> bool {
evaluated_via == "no-visible-parents" && verdict == "deny"
}
fn print_report(report: &CheckReport) {
println!("target: {}", sanitize_for_terminal(&report.target_id));
println!("operation: {}", report.operation);
println!("verdict: {}", report.verdict);
match (&report.decided_by_folder_id, report.decided_by_depth) {
(Some(folder_id), Some(depth)) => {
println!(
"decided by: rule on folder {} (depth {depth})",
sanitize_for_terminal(folder_id)
);
}
_ => match &report.decided_by_file_id {
Some(file_id) => println!(
"decided by: rule on file {}",
sanitize_for_terminal(file_id)
),
None => println!("decided by: default policy (no matching rule)"),
},
}
if report.verdict == "allow" {
println!(
"lease: {}",
if report.requires_lease {
"required"
} else {
"not required"
}
);
}
if should_note_no_visible_parents(&report.evaluated_via, &report.verdict) {
println!(
"note: this target has no parent folder visible to this account, so no\n\
\x20 folder_id rule can apply — grant it with a file_id rule instead"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
#[test]
fn slides_operation_maps_to_the_distinct_engine_grant() {
assert_eq!(
super::DriveOperation::from(super::OperationArg::SlidesWrite),
super::DriveOperation::SlidesWrite
);
}
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::utils::secret::Secret;
#[test]
fn operation_arg_maps_onto_every_drive_operation() {
assert_eq!(
DriveOperation::from(OperationArg::Read),
DriveOperation::Read
);
assert_eq!(
DriveOperation::from(OperationArg::Create),
DriveOperation::Create
);
assert_eq!(
DriveOperation::from(OperationArg::Upload),
DriveOperation::Upload
);
assert_eq!(
DriveOperation::from(OperationArg::Edit),
DriveOperation::Edit
);
assert_eq!(
DriveOperation::from(OperationArg::SheetsWrite),
DriveOperation::SheetsWrite
);
assert_eq!(
DriveOperation::from(OperationArg::SheetsStructure),
DriveOperation::SheetsStructure
);
assert_eq!(
DriveOperation::from(OperationArg::SheetsDelete),
DriveOperation::SheetsDelete
);
assert_eq!(
DriveOperation::from(OperationArg::SheetsProtection),
DriveOperation::SheetsProtection
);
assert_eq!(
DriveOperation::from(OperationArg::DocsWrite),
DriveOperation::DocsWrite
);
}
#[test]
fn trash_operation_maps_to_the_gate() {
assert_eq!(
DriveOperation::from(OperationArg::Trash),
DriveOperation::Trash
);
}
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
fn rule(folder_id: &str, recursive: bool, allow: &[DriveOperation]) -> FolderPermissionRule {
FolderPermissionRule::folder(folder_id)
.recursive(recursive)
.allowing(allow.iter().copied())
}
fn file_rule(file_id: &str, allow: &[DriveOperation]) -> FolderPermissionRule {
FolderPermissionRule::file(file_id).allowing(allow.iter().copied())
}
#[tokio::test]
async fn folder_target_evaluates_from_itself() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1", "name": "folder-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.expect(1)
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let rules = [rule("folder-1", false, &[DriveOperation::Create])];
let decision = evaluate_target(&files_api, "folder-1", DriveOperation::Create, &rules)
.await
.unwrap();
assert_eq!(decision.decision.verdict, Verdict::Allow);
}
#[tokio::test]
async fn requires_lease_ignores_a_same_folder_rule_for_a_different_operation() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1", "name": "folder-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let rules = [
FolderPermissionRule::folder("folder-1")
.allowing([DriveOperation::Create])
.requiring_lease(false),
FolderPermissionRule::folder("folder-1").allowing([DriveOperation::Upload]),
];
let decision = evaluate_target(&files_api, "folder-1", DriveOperation::Create, &rules)
.await
.unwrap();
assert_eq!(decision.decision.verdict, Verdict::Allow);
assert!(!decision.requires_lease);
}
#[tokio::test]
async fn create_upload_read_and_trash_never_report_requires_lease() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1", "name": "folder-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let rules = [rule(
"folder-1",
false,
&[
DriveOperation::Create,
DriveOperation::Upload,
DriveOperation::Trash,
DriveOperation::Read,
],
)];
for op in [
DriveOperation::Create,
DriveOperation::Upload,
DriveOperation::Read,
DriveOperation::Trash,
] {
let evaluated = evaluate_target(&files_api, "folder-1", op, &rules)
.await
.unwrap();
assert_eq!(evaluated.decision.verdict, Verdict::Allow);
assert!(
evaluated.requires_lease,
"sanity: the raw decision should still say a lease is required for {op:?} \
before `run_check`'s override is applied"
);
let requires_lease = op.ever_requires_lease() && evaluated.requires_lease;
assert!(!requires_lease, "{op:?} should never report requires_lease");
}
}
#[tokio::test]
async fn run_check_assembles_the_report_from_the_evaluated_decision() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1", "name": "folder-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.mount(&server)
.await;
let rules = [rule("folder-1", false, &[DriveOperation::Create])];
run_check(
&client,
"folder-1",
DriveOperation::Create,
&rules,
&OutputFormat::Json,
)
.await
.unwrap();
}
#[tokio::test]
async fn run_check_prints_a_deny_verdict_in_table_format() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1", "name": "folder-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.mount(&server)
.await;
run_check(
&client,
"folder-1",
DriveOperation::Create,
&[],
&OutputFormat::Table,
)
.await
.unwrap();
}
#[tokio::test]
async fn file_target_evaluates_from_its_parent() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/file-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "mimeType": "text/plain", "parents": ["folder-1"],
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1", "name": "folder-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let rules = [rule("folder-1", false, &[DriveOperation::Edit])];
let decision = evaluate_target(&files_api, "file-1", DriveOperation::Edit, &rules)
.await
.unwrap();
assert_eq!(decision.decision.verdict, Verdict::Allow);
}
#[tokio::test]
async fn orphan_file_uses_default_policy() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/orphan"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "orphan", "name": "orphan", "mimeType": "text/plain",
})),
)
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let decision = evaluate_target(&files_api, "orphan", DriveOperation::Read, &[])
.await
.unwrap();
assert_eq!(decision.decision.verdict, Verdict::Allow);
let decision = evaluate_target(&files_api, "orphan", DriveOperation::Edit, &[])
.await
.unwrap();
assert_eq!(decision.decision.verdict, Verdict::Deny);
}
#[tokio::test]
async fn multi_parent_file_denies_when_any_parent_denies() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/file-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "file-1", "name": "file-1", "mimeType": "text/plain",
"parents": ["allow-parent", "deny-parent"],
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/allow-parent"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "allow-parent", "name": "allow-parent", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/deny-parent"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "deny-parent", "name": "deny-parent", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let rules = [rule("allow-parent", false, &[DriveOperation::Edit])];
let decision = evaluate_target(&files_api, "file-1", DriveOperation::Edit, &rules)
.await
.unwrap();
assert_eq!(
decision.decision.verdict,
Verdict::Deny,
"deny-parent has no matching rule and edit defaults deny, which must win over allow-parent"
);
}
#[tokio::test]
async fn ancestor_chain_fetch_failure_propagates_as_err() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let result = evaluate_target(&files_api, "folder-1", DriveOperation::Read, &[]).await;
assert!(result.is_err());
}
#[test]
fn print_report_no_match_says_default_policy() {
let report = CheckReport {
target_id: "f1".to_string(),
operation: "create".to_string(),
verdict: "deny".to_string(),
decided_by_folder_id: None,
decided_by_depth: None,
decided_by_file_id: None,
evaluated_via: "folder-chain".to_string(),
requires_lease: true,
};
print_report(&report);
}
#[test]
fn print_report_renders_a_file_rule_and_the_no_visible_parents_note() {
print_report(&CheckReport {
target_id: "x1".to_string(),
operation: "sheets-write".to_string(),
verdict: "allow".to_string(),
decided_by_folder_id: None,
decided_by_depth: None,
decided_by_file_id: Some("x1".to_string()),
evaluated_via: "file-rule".to_string(),
requires_lease: true,
});
print_report(&CheckReport {
target_id: "x2".to_string(),
operation: "sheets-write".to_string(),
verdict: "deny".to_string(),
decided_by_folder_id: None,
decided_by_depth: None,
decided_by_file_id: None,
evaluated_via: "no-visible-parents".to_string(),
requires_lease: true,
});
}
#[test]
fn print_report_renders_an_allow_that_does_not_require_a_lease() {
print_report(&CheckReport {
target_id: "x3".to_string(),
operation: "create".to_string(),
verdict: "allow".to_string(),
decided_by_folder_id: Some("folder-1".to_string()),
decided_by_depth: Some(0),
decided_by_file_id: None,
evaluated_via: "folder-chain".to_string(),
requires_lease: false,
});
}
#[test]
fn the_no_visible_parents_note_is_gated_on_a_deny() {
assert!(should_note_no_visible_parents("no-visible-parents", "deny"));
assert!(!should_note_no_visible_parents(
"no-visible-parents",
"allow"
));
assert!(!should_note_no_visible_parents("folder-chain", "deny"));
assert!(!should_note_no_visible_parents("file-rule", "deny"));
}
#[test]
fn evaluated_via_maps_every_decision_source() {
assert_eq!(evaluated_via(DecisionSource::FileRule), "file-rule");
assert_eq!(evaluated_via(DecisionSource::FolderChain), "folder-chain");
assert_eq!(
evaluated_via(DecisionSource::NoVisibleParents),
"no-visible-parents"
);
}
fn mount_plain_file(id: &'static str, parents: &[&str]) -> wiremock::Mock {
let parents: Vec<String> = parents.iter().map(|p| (*p).to_string()).collect();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": "text/plain", "parents": parents,
})),
)
}
#[tokio::test]
async fn a_file_rule_decides_a_file_target_without_walking_its_parents() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_plain_file("file-1", &["never-mounted"])
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let rules = [file_rule("file-1", &[DriveOperation::SheetsWrite])];
let evaluated = evaluate_target(&files_api, "file-1", DriveOperation::SheetsWrite, &rules)
.await
.unwrap();
assert_eq!(evaluated.decision.verdict, Verdict::Allow);
assert_eq!(evaluated.source, DecisionSource::FileRule);
}
#[tokio::test]
async fn a_parentless_file_target_is_reported_as_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_plain_file("file-1", &[]).mount(&server).await;
let files_api = FilesApi::new(&client);
let evaluated = evaluate_target(&files_api, "file-1", DriveOperation::Edit, &[])
.await
.unwrap();
assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
assert_eq!(evaluated.decision.verdict, Verdict::Deny);
}
#[tokio::test]
async fn no_visible_parents_still_answers_allow_for_read() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
mount_plain_file("file-1", &[]).mount(&server).await;
let files_api = FilesApi::new(&client);
let evaluated = evaluate_target(&files_api, "file-1", DriveOperation::Read, &[])
.await
.unwrap();
assert_eq!(evaluated.decision.verdict, Verdict::Allow);
assert_eq!(evaluated.source, DecisionSource::NoVisibleParents);
}
#[tokio::test]
async fn a_file_rule_does_not_apply_to_a_folder_target() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "folder-1", "name": "folder-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
})),
)
.mount(&server)
.await;
let files_api = FilesApi::new(&client);
let rules = [file_rule("folder-1", &[DriveOperation::Create])];
let evaluated = evaluate_target(&files_api, "folder-1", DriveOperation::Create, &rules)
.await
.unwrap();
assert_eq!(
evaluated.decision.verdict,
Verdict::Deny,
"a file_id rule must not grant a folder target"
);
assert_eq!(evaluated.decision.decided_by, None);
}
#[test]
fn docs_delete_operation_arg_maps_to_the_separate_gate() {
assert_eq!(
DriveOperation::from(OperationArg::DocsDelete),
DriveOperation::DocsDelete
);
}
}