Skip to main content

omni_dev/drive/
permissions_api.rs

1//! Drive Permissions API wrapper.
2//!
3//! Fetches the raw permission snapshots `crate::drive::visibility` diffs to
4//! detect a move's effect on a file's visibility — this module does no
5//! diffing itself, just auto-paginated fetching, mirroring `FilesApi`'s
6//! shape.
7
8use anyhow::Result;
9use serde::Deserialize;
10use url::Url;
11
12use crate::drive::client::DriveClient;
13use crate::drive::types::DrivePermission;
14
15/// Safety cap on total permissions accumulated by [`PermissionsApi::list_all`].
16///
17/// A real file's permission list is normally tiny (a handful of grants);
18/// this exists only to bound a runaway loop against a misbehaving or
19/// malicious `nextPageToken` response, mirroring
20/// `crate::drive::files_api::HARD_CAP`'s rationale. Unlike `files.list`,
21/// `permissions.list` gives callers no `limit` to pass through, so there's
22/// no caller-facing truncation-visibility concern to signal back (compare
23/// `FilesApi::paginate`'s `next_page_token`/`incomplete_search` clearing) —
24/// this is purely a defensive backstop.
25const MAX_PERMISSIONS: usize = 10_000;
26
27/// `fields` value for `permissions.list` — everything
28/// `crate::drive::visibility::Principal`/`principal_set` needs, plus `role`
29/// for informational logging (see `DrivePermission::role`'s doc).
30const LIST_FIELDS: &str = "nextPageToken,permissions(id,type,role,emailAddress,domain)";
31
32/// Permissions API façade.
33#[derive(Debug)]
34pub struct PermissionsApi<'a> {
35    client: &'a DriveClient,
36}
37
38impl<'a> PermissionsApi<'a> {
39    /// Wraps an existing [`DriveClient`] for permission operations.
40    #[must_use]
41    pub fn new(client: &'a DriveClient) -> Self {
42        Self { client }
43    }
44
45    /// Fetches every permission on `file_or_folder_id`, auto-paginating —
46    /// `permissions.list` doesn't distinguish a file from a folder, so this
47    /// works for both (the `move` engine calls it on the file being moved,
48    /// its current parent(s), and the destination folder alike).
49    pub async fn list_all(&self, file_or_folder_id: &str) -> Result<Vec<DrivePermission>> {
50        let mut acc: Vec<DrivePermission> = Vec::new();
51        let mut page_token: Option<String> = None;
52        loop {
53            let page = self
54                .list_page(file_or_folder_id, page_token.as_deref())
55                .await?;
56            acc.extend(page.permissions);
57            if acc.len() >= MAX_PERMISSIONS || page.next_page_token.is_none() {
58                break;
59            }
60            page_token = page.next_page_token;
61        }
62        acc.truncate(MAX_PERMISSIONS);
63        Ok(acc)
64    }
65
66    async fn list_page(
67        &self,
68        file_or_folder_id: &str,
69        page_token: Option<&str>,
70    ) -> Result<PermissionListResponse> {
71        let url =
72            build_permissions_list_url(self.client.base_url(), file_or_folder_id, page_token)?;
73        self.client
74            .get_parsed(url.as_str(), "Failed to parse permissions.list response")
75            .await
76    }
77}
78
79/// Response envelope for `GET /drive/v3/files/{fileId}/permissions`.
80#[derive(Debug, Clone, Default, Deserialize)]
81struct PermissionListResponse {
82    #[serde(default)]
83    permissions: Vec<DrivePermission>,
84    #[serde(default, rename = "nextPageToken")]
85    next_page_token: Option<String>,
86}
87
88/// No explicit `pageSize` is sent — unlike `files.list`'s `search`,
89/// `list_all` takes no caller-supplied limit to translate into a per-page
90/// size, so pagination is driven purely by `nextPageToken` against
91/// whatever page size Drive chooses by default.
92fn build_permissions_list_url(
93    base_url: &str,
94    file_or_folder_id: &str,
95    page_token: Option<&str>,
96) -> Result<Url> {
97    let mut url = DriveClient::api_url(
98        base_url,
99        &format!("/drive/v3/files/{file_or_folder_id}/permissions"),
100    )?;
101    {
102        let mut pairs = url.query_pairs_mut();
103        pairs.append_pair("fields", LIST_FIELDS);
104        pairs.append_pair("supportsAllDrives", "true");
105        if let Some(token) = page_token {
106            pairs.append_pair("pageToken", token);
107        }
108    }
109    Ok(url)
110}
111
112#[cfg(test)]
113#[allow(clippy::unwrap_used, clippy::expect_used)]
114mod tests {
115    use super::*;
116    use crate::drive::auth::{DriveCredentials, DriveScope};
117    use crate::utils::secret::Secret;
118
119    fn test_credentials() -> DriveCredentials {
120        DriveCredentials {
121            client_id: "client-1".to_string(),
122            client_secret: Secret::new("secret-1"),
123            refresh_token: Secret::new("refresh-1"),
124            scope: DriveScope::Metadata,
125        }
126    }
127
128    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
129        wiremock::Mock::given(wiremock::matchers::method("POST"))
130            .and(wiremock::matchers::path("/token"))
131            .respond_with(
132                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
133                    "access_token": "test-token",
134                    "expires_in": 3600,
135                })),
136            )
137            .mount(server)
138            .await;
139
140        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
141        crate::drive::client::test_support::replace_session(
142            &mut client,
143            &test_credentials(),
144            &format!("{}/token", server.uri()),
145        );
146        client
147    }
148
149    #[tokio::test]
150    async fn list_all_returns_a_single_page_verbatim() {
151        let server = wiremock::MockServer::start().await;
152        let client = client_with_bootstrapped_token(&server).await;
153        wiremock::Mock::given(wiremock::matchers::method("GET"))
154            .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
155            .and(wiremock::matchers::query_param("fields", LIST_FIELDS))
156            .respond_with(
157                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
158                    "permissions": [
159                        {"id": "p1", "type": "user", "role": "reader", "emailAddress": "alice@example.com"},
160                    ],
161                })),
162            )
163            .expect(1)
164            .mount(&server)
165            .await;
166
167        let perms = PermissionsApi::new(&client).list_all("f1").await.unwrap();
168        assert_eq!(perms.len(), 1);
169        assert_eq!(perms[0].email_address.as_deref(), Some("alice@example.com"));
170    }
171
172    #[tokio::test]
173    async fn list_all_follows_next_page_token_to_exhaustion() {
174        let server = wiremock::MockServer::start().await;
175        let client = client_with_bootstrapped_token(&server).await;
176
177        wiremock::Mock::given(wiremock::matchers::method("GET"))
178            .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
179            .and(wiremock::matchers::query_param_is_missing("pageToken"))
180            .respond_with(
181                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
182                    "permissions": [
183                        {"id": "p1", "type": "user", "role": "reader", "emailAddress": "alice@example.com"},
184                    ],
185                    "nextPageToken": "page-2",
186                })),
187            )
188            .expect(1)
189            .mount(&server)
190            .await;
191        wiremock::Mock::given(wiremock::matchers::method("GET"))
192            .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
193            .and(wiremock::matchers::query_param("pageToken", "page-2"))
194            .respond_with(
195                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
196                    "permissions": [
197                        {"id": "p2", "type": "user", "role": "reader", "emailAddress": "bob@example.com"},
198                    ],
199                })),
200            )
201            .expect(1)
202            .mount(&server)
203            .await;
204
205        let perms = PermissionsApi::new(&client).list_all("f1").await.unwrap();
206        assert_eq!(perms.len(), 2);
207        assert_eq!(perms[0].email_address.as_deref(), Some("alice@example.com"));
208        assert_eq!(perms[1].email_address.as_deref(), Some("bob@example.com"));
209    }
210
211    #[tokio::test]
212    async fn list_all_returns_empty_for_a_permission_less_response() {
213        let server = wiremock::MockServer::start().await;
214        let client = client_with_bootstrapped_token(&server).await;
215        wiremock::Mock::given(wiremock::matchers::method("GET"))
216            .and(wiremock::matchers::path("/drive/v3/files/f1/permissions"))
217            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
218            .mount(&server)
219            .await;
220
221        let perms = PermissionsApi::new(&client).list_all("f1").await.unwrap();
222        assert!(perms.is_empty());
223    }
224
225    #[tokio::test]
226    async fn list_all_propagates_api_errors() {
227        let server = wiremock::MockServer::start().await;
228        let client = client_with_bootstrapped_token(&server).await;
229        wiremock::Mock::given(wiremock::matchers::method("GET"))
230            .and(wiremock::matchers::path(
231                "/drive/v3/files/missing/permissions",
232            ))
233            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
234            .mount(&server)
235            .await;
236
237        let err = PermissionsApi::new(&client)
238            .list_all("missing")
239            .await
240            .unwrap_err();
241        assert!(err.to_string().contains("404"));
242    }
243
244    #[tokio::test]
245    async fn list_all_works_for_a_folder_id_the_same_as_a_file_id() {
246        let server = wiremock::MockServer::start().await;
247        let client = client_with_bootstrapped_token(&server).await;
248        wiremock::Mock::given(wiremock::matchers::method("GET"))
249            .and(wiremock::matchers::path(
250                "/drive/v3/files/folder1/permissions",
251            ))
252            .respond_with(
253                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
254                    "permissions": [
255                        {"id": "p1", "type": "domain", "role": "reader", "domain": "example.com"},
256                    ],
257                })),
258            )
259            .mount(&server)
260            .await;
261
262        let perms = PermissionsApi::new(&client)
263            .list_all("folder1")
264            .await
265            .unwrap();
266        assert_eq!(perms[0].domain.as_deref(), Some("example.com"));
267    }
268}