Skip to main content

tuff_console/
server.rs

1//! The console's HTTP API (RFC-108 D5 and D9) and the rules for where it
2//! may listen.
3
4use std::collections::HashMap;
5use std::future::Future;
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use axum::body::Bytes;
11use axum::extract::{DefaultBodyLimit, Path, Query, State};
12use axum::http::{HeaderMap, StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use axum::routing::{get, post};
15use axum::{Json, Router};
16use serde_json::json;
17use tuff_core::error::{ErrorKind, Result, TuffError};
18use tuff_core::report::{REPORT_SCHEMA, Report, normalize_remote};
19
20use crate::oidc::{OidcError, Trust, Verifier};
21use crate::store::{EventFilter, KeyGrant, Store};
22use crate::ui;
23use crate::views::{self, ProjectModel};
24
25/// Largest report body the server reads.
26const MAX_REPORT_BYTES: usize = 16 * 1024 * 1024;
27
28/// Where and how `tuff console serve` listens.
29#[derive(Debug, Clone)]
30pub struct ServeConfig {
31    pub data_dir: PathBuf,
32    pub addr: SocketAddr,
33    /// Acknowledges that viewing is not authenticated on a non-loopback
34    /// address (D5).
35    pub public_read: bool,
36    /// Sources of OIDC tokens the console accepts for publishing.
37    pub trusts: Vec<Trust>,
38    /// The URL publishers reach the console at, which OIDC tokens name as
39    /// their audience. `http://<bound address>` when unset.
40    pub public_url: Option<String>,
41    /// Serve generated sample projects from a temporary database instead of
42    /// the data folder.
43    pub demo: bool,
44}
45
46/// How the running server decides who may publish, and what the UI and
47/// `GET /api/v1/settings` tell viewers about it.
48#[derive(Clone, Default)]
49pub struct ServerOptions {
50    /// Publishing always needs a credential, even with no key created yet.
51    pub require_key: bool,
52    /// Verifies OIDC tokens when a trust is configured.
53    pub oidc: Option<Arc<Verifier>>,
54    /// The address the server listens on. [`serve`] fills it from the
55    /// listener when it is unset.
56    pub address: Option<SocketAddr>,
57    /// Whether the server was started with `--public-read`.
58    pub public_read: bool,
59    /// Whether the data is generated sample data.
60    pub demo: bool,
61}
62
63/// The bind rules of D5. A loopback address needs nothing. Any other
64/// address needs `--public-read`, because viewing is not authenticated, and
65/// at least one publish credential (a key or a trust), because publishing
66/// is authenticated. A demo needs no credential: its data is generated and
67/// in memory, and with none configured every publish is refused, so it is
68/// read only.
69pub fn check_bind(
70    addr: SocketAddr,
71    public_read: bool,
72    credential_count: u64,
73    demo: bool,
74) -> Result<()> {
75    if addr.ip().is_loopback() {
76        return Ok(());
77    }
78    if !public_read {
79        return Err(TuffError::refused(format!(
80            "{addr} is not a loopback address, and the console does not authenticate people who view it"
81        ))
82        .with_hint(
83            "put the server behind a reverse proxy that authenticates viewers and pass --public-read, or bind 127.0.0.1",
84        ));
85    }
86    if credential_count == 0 && !demo {
87        return Err(TuffError::refused(format!(
88            "{addr} is not a loopback address, and no publish key or trust exists"
89        ))
90        .with_hint("run 'tuff console key create <name>' first, or pass --trust github:<owner>"));
91    }
92    Ok(())
93}
94
95/// Open the store, apply the bind rules, and serve until interrupted.
96/// `on_ready` receives the bound address once the server accepts
97/// connections.
98pub fn run(config: ServeConfig, on_ready: impl FnOnce(SocketAddr)) -> Result<()> {
99    let store = if config.demo {
100        let store = Store::open_in_memory()?;
101        crate::demo::populate(&store)?;
102        Arc::new(store)
103    } else {
104        Arc::new(Store::open(&config.data_dir)?)
105    };
106    check_bind(
107        config.addr,
108        config.public_read,
109        store.key_count()? + config.trusts.len() as u64,
110        config.demo,
111    )?;
112    let runtime = tokio::runtime::Builder::new_multi_thread()
113        .enable_all()
114        .build()?;
115    runtime.block_on(async {
116        let listener = tokio::net::TcpListener::bind(config.addr)
117            .await
118            .map_err(|error| {
119                TuffError::of(
120                    ErrorKind::Io,
121                    format!("cannot listen on {}: {error}", config.addr),
122                )
123                .with_hint("pass --addr with a free address, for example 127.0.0.1:7475")
124            })?;
125        let bound = listener.local_addr()?;
126        let public_url = config
127            .public_url
128            .clone()
129            .unwrap_or_else(|| format!("http://{bound}"));
130        let oidc = if config.trusts.is_empty() {
131            None
132        } else {
133            Some(Arc::new(Verifier::new(config.trusts.clone(), &public_url)?))
134        };
135        on_ready(bound);
136        serve(
137            store,
138            listener,
139            ServerOptions {
140                require_key: !config.addr.ip().is_loopback(),
141                oidc,
142                address: Some(bound),
143                public_read: config.public_read,
144                demo: config.demo,
145            },
146            shutdown_signal(),
147        )
148        .await
149    })
150}
151
152async fn shutdown_signal() {
153    let interrupt = async {
154        let _ = tokio::signal::ctrl_c().await;
155    };
156    #[cfg(unix)]
157    let terminate = async {
158        if let Ok(mut signal) =
159            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
160        {
161            signal.recv().await;
162        } else {
163            std::future::pending::<()>().await;
164        }
165    };
166    #[cfg(not(unix))]
167    let terminate = std::future::pending::<()>();
168    tokio::select! {
169        () = interrupt => {},
170        () = terminate => {},
171    }
172}
173
174/// Serve `router(store, options)` on `listener` until `shutdown` completes.
175pub async fn serve(
176    store: Arc<Store>,
177    listener: tokio::net::TcpListener,
178    mut options: ServerOptions,
179    shutdown: impl Future<Output = ()> + Send + 'static,
180) -> Result<()> {
181    if options.address.is_none() {
182        options.address = listener.local_addr().ok();
183    }
184    axum::serve(listener, router(store, options))
185        .with_graceful_shutdown(shutdown)
186        .await?;
187    Ok(())
188}
189
190#[derive(Clone)]
191struct AppState {
192    store: Arc<Store>,
193    options: ServerOptions,
194}
195
196/// The API routes. Publishing needs a live key or a verified OIDC token in
197/// `Authorization: Bearer` when `require_key` is set, when a trust is
198/// configured, and whenever at least one key exists, on any address. With
199/// none of those, anyone who can connect may publish.
200pub fn router(store: Arc<Store>, options: ServerOptions) -> Router {
201    let state = AppState { store, options };
202    Router::new()
203        .route("/", get(ui::index))
204        .route("/assets/app.css", get(ui::stylesheet))
205        .route("/assets/app.js", get(ui::script))
206        .route("/healthz", get(healthz))
207        .route("/api/v1/healthz", get(healthz))
208        .route("/api/v1/reports", post(post_report))
209        .route("/api/v1/projects", get(list_projects))
210        .route("/api/v1/projects/{id}", get(get_project))
211        .route("/api/v1/projects/{id}/reports", get(project_reports))
212        .route("/api/v1/capabilities", get(list_capabilities))
213        .route("/api/v1/capabilities/{type}/{*id}", get(get_capability))
214        .route("/api/v1/harnesses", get(get_harnesses))
215        .route("/api/v1/policies", get(get_policies))
216        .route("/api/v1/events", get(list_events))
217        .route("/api/v1/settings", get(get_settings))
218        .layer(DefaultBodyLimit::max(MAX_REPORT_BYTES))
219        .layer(axum::middleware::from_fn(security_headers))
220        .with_state(state)
221}
222
223/// An error rendered in the CLI's `--json` envelope.
224struct ApiError {
225    status: StatusCode,
226    kind: &'static str,
227    message: String,
228    hint: Option<String>,
229}
230
231impl ApiError {
232    fn new(status: StatusCode, kind: &'static str, message: impl Into<String>) -> Self {
233        Self {
234            status,
235            kind,
236            message: message.into(),
237            hint: None,
238        }
239    }
240
241    fn hint(mut self, hint: impl Into<String>) -> Self {
242        self.hint = Some(hint.into());
243        self
244    }
245}
246
247impl From<TuffError> for ApiError {
248    fn from(error: TuffError) -> Self {
249        let status = match error.kind() {
250            ErrorKind::Usage => StatusCode::BAD_REQUEST,
251            ErrorKind::NotFound => StatusCode::NOT_FOUND,
252            _ => StatusCode::INTERNAL_SERVER_ERROR,
253        };
254        let mut api = Self::new(status, error.kind().as_str(), error.message());
255        api.hint = error.hint().map(str::to_string);
256        api
257    }
258}
259
260impl IntoResponse for ApiError {
261    fn into_response(self) -> Response {
262        let mut body = json!({ "error": { "kind": self.kind, "message": self.message } });
263        if let Some(hint) = self.hint {
264            body["error"]["hint"] = hint.into();
265        }
266        let mut response = (self.status, Json(body)).into_response();
267        if self.status == StatusCode::UNAUTHORIZED {
268            response.headers_mut().insert(
269                header::WWW_AUTHENTICATE,
270                header::HeaderValue::from_static("Bearer"),
271            );
272        }
273        response
274    }
275}
276
277/// Run blocking store work off the async threads.
278async fn blocking<T: Send + 'static>(
279    store: &Arc<Store>,
280    work: impl FnOnce(&Store) -> Result<T> + Send + 'static,
281) -> std::result::Result<T, ApiError> {
282    let store = Arc::clone(store);
283    tokio::task::spawn_blocking(move || work(&store))
284        .await
285        .map_err(|error| {
286            ApiError::new(
287                StatusCode::INTERNAL_SERVER_ERROR,
288                "internal",
289                format!("store task failed: {error}"),
290            )
291        })?
292        .map_err(ApiError::from)
293}
294
295async fn healthz() -> Json<serde_json::Value> {
296    Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
297}
298
299/// Who is publishing.
300enum Principal {
301    /// Nothing is configured, so nobody is asked.
302    Anonymous,
303    Key(KeyGrant),
304    /// A verified token, bound to the repository it names.
305    Oidc {
306        report_repository: String,
307    },
308}
309
310fn unauthorized(message: impl Into<String>) -> ApiError {
311    ApiError::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
312}
313
314async fn authorize(
315    state: &AppState,
316    headers: &HeaderMap,
317) -> std::result::Result<Principal, ApiError> {
318    let configured = state.options.require_key
319        || state.options.oidc.is_some()
320        || blocking(&state.store, Store::key_count).await? > 0;
321    if !configured {
322        return Ok(Principal::Anonymous);
323    }
324    let presented = headers
325        .get(header::AUTHORIZATION)
326        .and_then(|value| value.to_str().ok())
327        .and_then(|value| value.strip_prefix("Bearer "))
328        .map(str::trim)
329        .filter(|token| !token.is_empty());
330    let Some(token) = presented else {
331        return Err(unauthorized("publishing to this console needs a credential")
332            .hint("send 'Authorization: Bearer <key>', set TUFF_CONSOLE_KEY for 'tuff console publish', or publish from a trusted GitHub Actions job"));
333    };
334
335    let verifier = match state.options.oidc.as_ref() {
336        Some(verifier) if !token.starts_with(crate::store::KEY_PREFIX) => verifier,
337        _ => {
338            let key = token.to_string();
339            return match blocking(&state.store, move |store| store.verify_key(&key)).await? {
340                Some(grant) => Ok(Principal::Key(grant)),
341                None => Err(unauthorized("the key is not valid or was revoked")
342                    .hint("create one with 'tuff console key create <name>' on the server")),
343            };
344        }
345    };
346    match verifier.verify(token).await {
347        Ok(verified) => Ok(Principal::Oidc {
348            report_repository: verified.report_repository,
349        }),
350        Err(OidcError::Invalid(reason)) => Err(unauthorized(reason).hint(format!(
351            "the token's audience must be {}, and the job needs 'permissions: id-token: write'",
352            verifier.audience()
353        ))),
354        Err(OidcError::Untrusted(reason)) => {
355            Err(ApiError::new(StatusCode::FORBIDDEN, "refused", reason)
356                .hint("start the console with --trust github:<owner> for this owner"))
357        }
358        Err(OidcError::Unavailable(reason)) => {
359            Err(
360                ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "source_failed", reason)
361                    .hint("the console must reach the token issuer; retry later"),
362            )
363        }
364    }
365}
366
367/// A scoped credential publishes only for its own repository (D5).
368fn check_binding(principal: &Principal, repository: &str) -> std::result::Result<(), ApiError> {
369    let bound = match principal {
370        Principal::Anonymous
371        | Principal::Key(KeyGrant {
372            repository: None, ..
373        }) => return Ok(()),
374        Principal::Key(KeyGrant {
375            repository: Some(bound),
376            ..
377        })
378        | Principal::Oidc {
379            report_repository: bound,
380        } => bound,
381    };
382    let reported = normalize_remote(repository);
383    if normalize_remote(bound).eq_ignore_ascii_case(&reported) {
384        return Ok(());
385    }
386    Err(ApiError::new(
387        StatusCode::FORBIDDEN,
388        "refused",
389        format!("this credential may publish only for {bound}, and the report is for {reported}"),
390    )
391    .hint("publish each repository with its own credential"))
392}
393
394async fn post_report(
395    State(state): State<AppState>,
396    headers: HeaderMap,
397    body: Bytes,
398) -> std::result::Result<Response, ApiError> {
399    let principal = authorize(&state, &headers).await?;
400
401    let raw: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
402        ApiError::new(
403            StatusCode::BAD_REQUEST,
404            "usage",
405            format!("the body is not JSON: {error}"),
406        )
407    })?;
408    match raw.get("schema").and_then(serde_json::Value::as_u64) {
409        Some(schema) if schema == u64::from(REPORT_SCHEMA) => {}
410        other => {
411            let seen = other.map_or_else(|| "none".to_string(), |schema| schema.to_string());
412            return Err(ApiError::new(
413                StatusCode::UNPROCESSABLE_ENTITY,
414                "unsupported",
415                format!(
416                    "report schema {seen} is not supported, and this server reads schema {REPORT_SCHEMA}"
417                ),
418            )
419            .hint("use a tuff version that matches the server's"));
420        }
421    }
422    let report: Report = serde_json::from_value(raw.clone()).map_err(|error| {
423        ApiError::new(
424            StatusCode::UNPROCESSABLE_ENTITY,
425            "corrupt",
426            format!("the report is not valid: {error}"),
427        )
428    })?;
429    if report.project.repository.trim().is_empty() || report.project.path.trim().is_empty() {
430        return Err(ApiError::new(
431            StatusCode::UNPROCESSABLE_ENTITY,
432            "corrupt",
433            "the report's project needs a repository and a path",
434        ));
435    }
436
437    check_binding(&principal, &report.project.repository)?;
438
439    let outcome = blocking(&state.store, move |store| store.ingest(&report, &raw)).await?;
440    let status = if outcome.deduplicated {
441        StatusCode::OK
442    } else {
443        StatusCode::CREATED
444    };
445    Ok((status, Json(outcome)).into_response())
446}
447
448type ApiResult = std::result::Result<Json<serde_json::Value>, ApiError>;
449
450/// The latest report of every project, read into the shapes the views use.
451async fn models(state: &AppState) -> std::result::Result<Vec<ProjectModel>, ApiError> {
452    blocking(&state.store, |store| {
453        Ok(store
454            .latest_reports()?
455            .into_iter()
456            .map(|(row, body)| ProjectModel::new(row, &body))
457            .collect())
458    })
459    .await
460}
461
462async fn list_projects(State(state): State<AppState>) -> ApiResult {
463    Ok(Json(views::projects(&models(&state).await?)))
464}
465
466fn no_project(id: i64) -> ApiError {
467    ApiError::new(
468        StatusCode::NOT_FOUND,
469        "not_found",
470        format!("no project {id}"),
471    )
472    .hint("GET /api/v1/projects lists the ids")
473}
474
475async fn get_project(State(state): State<AppState>, Path(id): Path<i64>) -> ApiResult {
476    match blocking(&state.store, move |store| store.project(id)).await? {
477        Some((row, latest_report)) => Ok(Json(views::project(
478            &ProjectModel::new(row, &latest_report),
479            latest_report,
480        ))),
481        None => Err(no_project(id)),
482    }
483}
484
485async fn project_reports(State(state): State<AppState>, Path(id): Path<i64>) -> ApiResult {
486    let reports = blocking(&state.store, move |store| {
487        if store.project(id)?.is_none() {
488            return Ok(None);
489        }
490        Ok(Some(store.report_history(id, 500)?))
491    })
492    .await?;
493    match reports {
494        Some(reports) => Ok(Json(json!({ "projectId": id, "reports": reports }))),
495        None => Err(no_project(id)),
496    }
497}
498
499async fn list_capabilities(
500    State(state): State<AppState>,
501    Query(query): Query<HashMap<String, String>>,
502) -> ApiResult {
503    let models = models(&state).await?;
504    Ok(Json(views::capabilities(
505        &models,
506        query
507            .get("type")
508            .map(String::as_str)
509            .filter(|t| !t.is_empty()),
510    )))
511}
512
513async fn get_capability(
514    State(state): State<AppState>,
515    Path((capability_type, id)): Path<(String, String)>,
516) -> ApiResult {
517    let models = models(&state).await?;
518    match views::capability(&models, &capability_type, &id) {
519        Some(value) => Ok(Json(value)),
520        None => Err(ApiError::new(
521            StatusCode::NOT_FOUND,
522            "not_found",
523            format!("no project uses {capability_type} '{id}'"),
524        )
525        .hint("GET /api/v1/capabilities lists what is in use")),
526    }
527}
528
529async fn get_harnesses(State(state): State<AppState>) -> ApiResult {
530    Ok(Json(views::harnesses(&models(&state).await?)))
531}
532
533async fn get_policies(State(state): State<AppState>) -> ApiResult {
534    Ok(Json(views::policies(&models(&state).await?)))
535}
536
537/// The most events one request returns.
538const MAX_EVENTS: u32 = 1000;
539
540async fn list_events(
541    State(state): State<AppState>,
542    Query(query): Query<HashMap<String, String>>,
543) -> ApiResult {
544    let text = |key: &str| query.get(key).filter(|value| !value.is_empty()).cloned();
545    let usage = |message: String| {
546        ApiError::new(StatusCode::BAD_REQUEST, "usage", message).hint(
547            "filters are project (an id), capability, kind, since (a date or time), before (an event id), and limit",
548        )
549    };
550    let project_id = match text("project") {
551        Some(value) => Some(
552            value
553                .parse::<i64>()
554                .map_err(|_| usage(format!("project '{value}' is not a project id")))?,
555        ),
556        None => None,
557    };
558    let limit = match text("limit") {
559        Some(value) => value
560            .parse::<u32>()
561            .map_err(|_| usage(format!("limit '{value}' is not a number")))?
562            .clamp(1, MAX_EVENTS),
563        None => 200,
564    };
565    let before = match text("before") {
566        Some(value) => Some(
567            value
568                .parse::<i64>()
569                .map_err(|_| usage(format!("before '{value}' is not an event id")))?,
570        ),
571        None => None,
572    };
573    let kind = text("kind");
574    if let Some(kind) = &kind
575        && !crate::events::kind::ALL.contains(&kind.as_str())
576    {
577        return Err(usage(format!("'{kind}' is not an event kind")));
578    }
579    let since = text("since");
580    if let Some(since) = &since
581        && !since.starts_with(|c: char| c.is_ascii_digit())
582    {
583        return Err(usage(format!("since '{since}' is not a date or time")));
584    }
585    let filter = EventFilter {
586        project_id,
587        capability: text("capability"),
588        kind,
589        since,
590        before,
591        limit: Some(limit),
592    };
593    let (events, projects) = blocking(&state.store, move |store| {
594        Ok((store.events(&filter)?, store.projects()?))
595    })
596    .await?;
597    let events: Vec<serde_json::Value> = events
598        .into_iter()
599        .map(|event| {
600            let mut value = serde_json::to_value(&event).unwrap_or_default();
601            if let Some(project) = projects.iter().find(|p| p.id == event.project_id) {
602                value["projectName"] = json!(project.name);
603                value["repository"] = json!(project.repository);
604                value["path"] = json!(project.path);
605            }
606            value
607        })
608        .collect();
609    // A full page may have more behind it: the id to pass as `before`.
610    let next_before = (events.len() == limit as usize)
611        .then(|| events.last().and_then(|event| event["id"].as_i64()))
612        .flatten();
613    Ok(Json(
614        json!({ "events": events, "kinds": crate::events::kind::ALL, "nextBefore": next_before }),
615    ))
616}
617
618async fn get_settings(State(state): State<AppState>) -> ApiResult {
619    let keys = blocking(&state.store, Store::keys).await?;
620    let options = &state.options;
621    let trusts: Vec<serde_json::Value> = options
622        .oidc
623        .iter()
624        .flat_map(|verifier| verifier.trusts())
625        .map(|trust| json!({ "provider": trust.provider, "owner": trust.owner }))
626        .collect();
627    let requires_auth = options.require_key || options.oidc.is_some() || !keys.is_empty();
628    let loopback = options
629        .address
630        .is_none_or(|address| address.ip().is_loopback());
631    Ok(Json(json!({
632        "server": {
633            "version": env!("CARGO_PKG_VERSION"),
634            "address": options.address.map(|address| address.to_string()),
635            "loopback": loopback,
636            "publicRead": options.public_read,
637            "publishRequiresAuth": requires_auth,
638            "demo": options.demo,
639            "audience": options.oidc.as_ref().map(|verifier| verifier.audience()),
640        },
641        "trusts": trusts,
642        "keys": keys,
643    })))
644}
645
646/// Headers for every response. The UI is static files that load nothing
647/// from elsewhere, and the content security policy says so to the browser.
648async fn security_headers(
649    request: axum::extract::Request,
650    next: axum::middleware::Next,
651) -> Response {
652    let path = request.uri().path().to_string();
653    let mut response = next.run(request).await;
654    let headers = response.headers_mut();
655    headers.insert(
656        header::X_CONTENT_TYPE_OPTIONS,
657        header::HeaderValue::from_static("nosniff"),
658    );
659    headers.insert(
660        header::REFERRER_POLICY,
661        header::HeaderValue::from_static("no-referrer"),
662    );
663    if !path.starts_with("/api/") && path != "/healthz" {
664        headers.insert(
665            header::CONTENT_SECURITY_POLICY,
666            header::HeaderValue::from_static(
667                "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
668            ),
669        );
670    }
671    response
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    fn addr(text: &str) -> SocketAddr {
679        text.parse().unwrap()
680    }
681
682    #[test]
683    fn loopback_binds_need_nothing() {
684        for text in ["127.0.0.1:7474", "127.0.0.1:0", "[::1]:7474"] {
685            check_bind(addr(text), false, 0, false).unwrap();
686        }
687    }
688
689    #[test]
690    fn a_public_bind_needs_public_read() {
691        for text in ["0.0.0.0:7474", "192.168.1.20:7474", "[::]:7474"] {
692            let error = check_bind(addr(text), false, 1, false).unwrap_err();
693            assert_eq!(error.kind(), ErrorKind::Refused, "{text}");
694            assert!(error.hint().unwrap().contains("--public-read"));
695        }
696    }
697
698    #[test]
699    fn a_public_bind_needs_a_key() {
700        let error = check_bind(addr("0.0.0.0:7474"), true, 0, false).unwrap_err();
701        assert_eq!(error.kind(), ErrorKind::Refused);
702        assert!(error.hint().unwrap().contains("key create"));
703        check_bind(addr("0.0.0.0:7474"), true, 1, false).unwrap();
704    }
705
706    #[test]
707    fn a_public_demo_needs_public_read_but_no_key() {
708        check_bind(addr("0.0.0.0:7474"), true, 0, true).unwrap();
709        let error = check_bind(addr("0.0.0.0:7474"), false, 0, true).unwrap_err();
710        assert!(error.hint().unwrap().contains("--public-read"));
711    }
712}