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('&', "&")
256 .replace('<', "<")
257 .replace('>', ">")
258 .replace('"', """)
259}