Skip to main content

bucketwarden_server/
browser_ui.rs

1use super::*;
2
3const BROWSER_UI_BOUNDARY_ID: &str = "bnd:bucketwarden.browser-ui-full-product-scope";
4const BROWSER_UI_CLAIM_TIER: &str = "T3";
5const SESSION_DURATION_SECONDS: u64 = 3600;
6
7mod catalog;
8mod features;
9mod foundation;
10
11pub use bucketwarden_browser_ui_shell::{
12    browser_ui_accessibility_checks, browser_ui_action_filter_controls, browser_ui_api_endpoints,
13    browser_ui_responsive_breakpoints, browser_ui_routes, browser_ui_secondary_inspector_regions,
14    browser_ui_security_policy, browser_ui_sidebar_order, browser_ui_topbar_context_order,
15    BrowserUiRoute, BrowserUiSecurityPolicy,
16};
17use catalog::{browser_ui_assets, browser_ui_feature_statuses, browser_ui_product_e2e_workflows};
18pub use catalog::{
19    browser_ui_css, browser_ui_html, browser_ui_js, browser_ui_manifest_json, browser_ui_v1_html,
20};
21pub use foundation::*;
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
24pub struct BrowserUiFeatureStatus {
25    pub feature_id: String,
26    pub title: String,
27    pub category: String,
28    pub runtime_surface: String,
29    pub implementation_status: String,
30    pub claim_tier: String,
31}
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct BrowserUiAsset {
35    pub path: String,
36    pub content_type: String,
37    pub bytes: usize,
38}
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41pub struct BrowserUiProductReport {
42    pub boundary_id: String,
43    pub claim_tier: String,
44    pub implementation_status: String,
45    pub generated_at_epoch_seconds: u64,
46    pub features: Vec<BrowserUiFeatureStatus>,
47    pub routes: Vec<BrowserUiRoute>,
48    pub api_endpoints: Vec<String>,
49    pub assets: Vec<BrowserUiAsset>,
50    pub security: BrowserUiSecurityPolicy,
51    pub accessibility_checks: Vec<String>,
52    pub responsive_breakpoints: Vec<String>,
53    pub preference_keys: Vec<String>,
54    pub export_actions: Vec<String>,
55    pub e2e_workflows: Vec<String>,
56}
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59pub struct BrowserUiLoginRequest {
60    pub principal_id: String,
61    pub shared_secret: String,
62}
63
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
65pub struct BrowserUiSession {
66    pub principal_id: String,
67    pub access_key_id: String,
68    pub session_token: String,
69    pub expires_at_epoch_seconds: u64,
70    pub role_count: usize,
71    pub scope: String,
72}
73
74impl BucketWarden {
75    pub fn browser_ui_product_report(
76        &mut self,
77        principal: &str,
78    ) -> Result<BrowserUiProductReport, RuntimeError> {
79        self.require_operator_action(
80            principal,
81            OperatorAction::ReadDiagnostics,
82            "*",
83            "ui:BrowserProductReport",
84        )?;
85        let report = BrowserUiProductReport {
86            boundary_id: BROWSER_UI_BOUNDARY_ID.to_string(),
87            claim_tier: BROWSER_UI_CLAIM_TIER.to_string(),
88            implementation_status: "implemented".to_string(),
89            generated_at_epoch_seconds: self.clock_epoch_seconds,
90            features: browser_ui_feature_statuses(),
91            routes: browser_ui_routes(),
92            api_endpoints: browser_ui_api_endpoints(),
93            assets: browser_ui_assets(),
94            security: browser_ui_security_policy(),
95            accessibility_checks: browser_ui_accessibility_checks(),
96            responsive_breakpoints: browser_ui_responsive_breakpoints(),
97            preference_keys: vec![
98                "bucketwarden.ui.density".to_string(),
99                "bucketwarden.ui.visibleColumns".to_string(),
100                "bucketwarden.ui.reportFilters".to_string(),
101                "bucketwarden.ui.refreshSeconds".to_string(),
102            ],
103            export_actions: vec![
104                "download health report JSON".to_string(),
105                "download config report JSON".to_string(),
106                "download evidence export JSON".to_string(),
107                "download audit log JSONL".to_string(),
108            ],
109            e2e_workflows: browser_ui_product_e2e_workflows(),
110        };
111        self.audit.append(
112            principal,
113            "ui:BrowserProductReport",
114            "*",
115            AuditOutcome::Allowed,
116            Some(format!(
117                "features={},routes={},assets={}",
118                report.features.len(),
119                report.routes.len(),
120                report.assets.len()
121            )),
122        );
123        Ok(report)
124    }
125
126    pub fn browser_ui_login(
127        &mut self,
128        request: BrowserUiLoginRequest,
129    ) -> Result<BrowserUiSession, RuntimeError> {
130        self.auth
131            .enforce_login_attempt_limit(&request.principal_id, 5)?;
132        let access_key_id = format!("BWUI-{}", sanitize_token_part(&request.principal_id));
133        let session_token = format!(
134            "bwui-session-{}-{}",
135            sanitize_token_part(&request.principal_id),
136            self.clock_epoch_seconds
137        );
138        let session = self.auth.assume_role_with_custom_identity(
139            AssumeRoleWithCustomIdentityRequest::new(
140                request.principal_id.clone(),
141                request.shared_secret.clone(),
142                SESSION_DURATION_SECONDS,
143                CredentialScope::new(
144                    vec!["ops:*".to_string(), "ui:*".to_string()],
145                    vec!["*".to_string()],
146                ),
147                access_key_id.clone(),
148                "browser-ui-session-secret",
149                session_token.clone(),
150            ),
151            self.clock_epoch_seconds,
152        );
153        match session {
154            Ok(session) => {
155                self.auth
156                    .record_login_success(&request.principal_id, self.clock_epoch_seconds);
157                let role_count = self.auth.role_assignments(&request.principal_id).len();
158                let session_token = session.credentials().session_token.unwrap_or_default();
159                self.audit.append(
160                    &request.principal_id,
161                    "ui:BrowserLogin",
162                    "*",
163                    AuditOutcome::Allowed,
164                    Some(format!("session={}", session.access_key_id)),
165                );
166                Ok(BrowserUiSession {
167                    principal_id: session.principal_id,
168                    access_key_id: session.access_key_id,
169                    session_token,
170                    expires_at_epoch_seconds: session.expires_at_epoch_seconds,
171                    role_count,
172                    scope: "*".to_string(),
173                })
174            }
175            Err(error) => {
176                self.auth.record_login_failure(
177                    &request.principal_id,
178                    self.clock_epoch_seconds,
179                    error.to_string(),
180                );
181                self.audit.append(
182                    &request.principal_id,
183                    "ui:BrowserLogin",
184                    "*",
185                    AuditOutcome::Denied,
186                    Some(error.to_string()),
187                );
188                Err(RuntimeError::Auth(error))
189            }
190        }
191    }
192
193    pub fn browser_ui_current_user(
194        &self,
195        access_key_id: &str,
196    ) -> Result<BrowserUiSession, RuntimeError> {
197        let credential = self
198            .auth
199            .resolve_credential(access_key_id, self.clock_epoch_seconds)?;
200        Ok(BrowserUiSession {
201            principal_id: credential.principal_id.clone(),
202            access_key_id: credential.access_key_id,
203            session_token: credential.credentials.session_token.unwrap_or_default(),
204            expires_at_epoch_seconds: credential
205                .scope
206                .as_ref()
207                .and_then(|_| self.auth.credential(access_key_id))
208                .and_then(|record| match record {
209                    CredentialRecord::Session(session) => Some(session.expires_at_epoch_seconds),
210                    CredentialRecord::AccessKey(_) => None,
211                })
212                .unwrap_or(self.clock_epoch_seconds),
213            role_count: self.auth.role_assignments(&credential.principal_id).len(),
214            scope: credential
215                .scope
216                .as_ref()
217                .map(|scope| scope.resource_prefixes.join(","))
218                .unwrap_or_else(|| "*".to_string()),
219        })
220    }
221
222    pub fn browser_ui_logout(&mut self, access_key_id: &str) -> Result<(), RuntimeError> {
223        let principal = self
224            .auth
225            .resolve_credential(access_key_id, self.clock_epoch_seconds)?
226            .principal_id;
227        self.auth
228            .revoke_credential(access_key_id, self.clock_epoch_seconds)?;
229        self.audit.append(
230            &principal,
231            "ui:BrowserLogout",
232            "*",
233            AuditOutcome::Allowed,
234            Some(format!("session={access_key_id}")),
235        );
236        Ok(())
237    }
238}
239
240fn sanitize_token_part(value: &str) -> String {
241    value
242        .chars()
243        .map(|ch| {
244            if ch.is_ascii_alphanumeric() {
245                ch.to_ascii_uppercase()
246            } else {
247                '-'
248            }
249        })
250        .collect()
251}
252
253fn escape_html(value: &str) -> String {
254    value
255        .replace('&', "&amp;")
256        .replace('<', "&lt;")
257        .replace('>', "&gt;")
258        .replace('"', "&quot;")
259}