-
-
Notifications
You must be signed in to change notification settings - Fork 361
Expand file tree
/
Copy pathweb_approvals.rs
More file actions
288 lines (260 loc) · 10.1 KB
/
Copy pathweb_approvals.rs
File metadata and controls
288 lines (260 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
//! Admin visibility into, and revocation of, cached web-approval auth
//! bypasses (see [`warpgate_core::AuthStateStore`]). The cache is per-node
//! in-memory, so a clear (or the list) fans out to every other cluster node,
//! the same way session termination does in `sessions_list`.
use poem::Request;
use poem::http::StatusCode;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use time::OffsetDateTime;
use tracing::warn;
use warpgate_common::auth::WebApprovalScopeKey;
use warpgate_common::{AdminPermission, WarpgateError};
use warpgate_common_http::{AuthenticatedRequestContext, is_cluster_peer_request};
use super::ClusterOrAdminContext;
use super::cluster_proxy::{fan_out_to_peers, parse_forwarded_body};
pub struct Api;
#[derive(Object)]
struct ActiveWebApprovalInfo {
username: String,
remote_ip: String,
protocol: String,
/// Human-readable summary of what the approval covers: a target name,
/// `*` for all targets, or `sign-in` for an untargeted portal/menu login.
scope: String,
/// The target name when this approval is scoped to one target — echo
/// this back (with `all_targets: false`) to revoke just this row via
/// `clear_web_approval_scope_for_user`.
scope_target: Option<String>,
/// Whether this approval covers every target — echo this back to revoke
/// just this row.
all_targets: bool,
granted_at: OffsetDateTime,
}
#[derive(Object)]
struct ClearWebApprovalsResult {
cleared_count: u64,
}
#[derive(ApiResponse)]
enum ListWebApprovalsResponse {
#[oai(status = 200)]
Ok(Json<Vec<ActiveWebApprovalInfo>>),
}
#[derive(ApiResponse)]
enum ClearWebApprovalsResponse {
#[oai(status = 200)]
Ok(Json<ClearWebApprovalsResult>),
}
fn scope_label(scope: &WebApprovalScopeKey) -> String {
match scope {
WebApprovalScopeKey::Untargeted => "sign-in".to_string(),
WebApprovalScopeKey::Target(name) => name.clone(),
WebApprovalScopeKey::AllTargets => "*".to_string(),
}
}
fn scope_key(target: Option<String>, all_targets: bool) -> WebApprovalScopeKey {
if all_targets {
WebApprovalScopeKey::AllTargets
} else if let Some(target) = target {
WebApprovalScopeKey::Target(target)
} else {
WebApprovalScopeKey::Untargeted
}
}
#[OpenApi]
impl Api {
/// List currently active (unexpired) cached web-approval bypasses across
/// the whole cluster.
#[oai(
path = "/web-approvals",
method = "get",
operation_id = "list_web_approvals"
)]
async fn list_web_approvals(
&self,
req: &Request,
admin: ClusterOrAdminContext,
) -> Result<ListWebApprovalsResponse, WarpgateError> {
let mut result = local_web_approvals(&admin).await?;
// Peer-forwarded copies of this request must not fan out again.
if !is_cluster_peer_request(req, &admin.services().cluster_token) {
result.extend(web_approvals_from_peers(&admin, req).await);
}
Ok(ListWebApprovalsResponse::Ok(Json(result)))
}
/// Clear every cached web-approval bypass, on this node and the rest of
/// the cluster, immediately requiring re-approval for anything that was
/// relying on one.
#[oai(
path = "/web-approvals",
method = "delete",
operation_id = "clear_web_approvals"
)]
async fn clear_web_approvals(
&self,
req: &Request,
admin: ClusterOrAdminContext,
/// Clear only this node's own cache instead of the whole cluster's.
/// Set on cluster-forwarded copies of the request.
local_only: Query<Option<bool>>,
) -> Result<ClearWebApprovalsResponse, WarpgateError> {
admin.require(AdminPermission::ConfigEdit)?;
let mut cleared = admin.services().clear_web_approvals().await as u64;
if !local_only.unwrap_or(false) {
cleared = cleared.saturating_add(clear_on_peers(&admin, req).await);
}
Ok(ClearWebApprovalsResponse::Ok(Json(
ClearWebApprovalsResult {
cleared_count: cleared,
},
)))
}
/// Clear cached web-approval bypasses for a single user, on this node and
/// the rest of the cluster.
#[oai(
path = "/web-approvals/:username",
method = "delete",
operation_id = "clear_web_approvals_for_user"
)]
async fn clear_web_approvals_for_user(
&self,
req: &Request,
admin: ClusterOrAdminContext,
username: Path<String>,
/// Clear only this node's own cache instead of the whole cluster's.
/// Set on cluster-forwarded copies of the request.
local_only: Query<Option<bool>>,
) -> Result<ClearWebApprovalsResponse, WarpgateError> {
admin.require(AdminPermission::ConfigEdit)?;
let mut cleared = admin
.services()
.clear_web_approvals_for_user(&username.0)
.await as u64;
if !local_only.unwrap_or(false) {
cleared = cleared.saturating_add(clear_on_peers(&admin, req).await);
}
Ok(ClearWebApprovalsResponse::Ok(Json(
ClearWebApprovalsResult {
cleared_count: cleared,
},
)))
}
/// Clear cached web-approval bypasses for a single user, restricted to one
/// scope (a target, or every target via `all_targets`) — for revoking a
/// single row of the admin approvals list rather than the whole user.
#[oai(
path = "/web-approvals/:username/scope",
method = "delete",
operation_id = "clear_web_approval_scope_for_user"
)]
#[allow(clippy::too_many_arguments)]
async fn clear_web_approval_scope_for_user(
&self,
req: &Request,
admin: ClusterOrAdminContext,
username: Path<String>,
/// Target name to revoke. Ignored when `all_targets` is set; omitted
/// together with `all_targets: false` for the untargeted (sign-in)
/// scope.
target: Query<Option<String>>,
/// Revoke the all-targets grant instead of a single target.
all_targets: Query<Option<bool>>,
/// Clear only this node's own cache instead of the whole cluster's.
/// Set on cluster-forwarded copies of the request.
local_only: Query<Option<bool>>,
) -> Result<ClearWebApprovalsResponse, WarpgateError> {
admin.require(AdminPermission::ConfigEdit)?;
let scope = scope_key(target.0, all_targets.unwrap_or(false));
let mut cleared = admin
.services()
.clear_web_approvals_for_user_and_scope(&username.0, &scope)
.await as u64;
if !local_only.unwrap_or(false) {
cleared = cleared.saturating_add(clear_on_peers(&admin, req).await);
}
Ok(ClearWebApprovalsResponse::Ok(Json(
ClearWebApprovalsResult {
cleared_count: cleared,
},
)))
}
}
async fn local_web_approvals(
ctx: &AuthenticatedRequestContext,
) -> Result<Vec<ActiveWebApprovalInfo>, WarpgateError> {
Ok(ctx
.services()
.list_active_web_approvals()
.await?
.into_iter()
.map(|(key, age)| {
let age = time::Duration::try_from(age).unwrap_or(time::Duration::ZERO);
let scope_target = match &key.scope {
WebApprovalScopeKey::Target(name) => Some(name.clone()),
WebApprovalScopeKey::Untargeted | WebApprovalScopeKey::AllTargets => None,
};
ActiveWebApprovalInfo {
username: key.username,
remote_ip: key.remote_ip.to_string(),
protocol: key.protocol.to_string(),
scope: scope_label(&key.scope),
all_targets: matches!(key.scope, WebApprovalScopeKey::AllTargets),
scope_target,
granted_at: OffsetDateTime::now_utc() - age,
}
})
.collect())
}
/// The same list from every other node, so the admin UI sees the whole
/// cluster. Best effort: a peer that fails or answers unexpectedly
/// contributes nothing rather than failing the request.
async fn web_approvals_from_peers(
ctx: &AuthenticatedRequestContext,
req: &Request,
) -> Vec<ActiveWebApprovalInfo> {
let mut results = vec![];
for (hostname, response) in fan_out_to_peers(ctx, req, req.original_uri().path()).await {
if response.status() != StatusCode::OK {
let status = response.status();
warn!(node = %hostname, %status, "Failed to list web approvals on a cluster node");
continue;
}
match parse_forwarded_body::<Vec<ActiveWebApprovalInfo>>(response).await {
Ok(items) => results.extend(items),
Err(error) => {
warn!(node = %hostname, %error, "Malformed web approval list from a cluster node");
}
}
}
results
}
/// Forward a clear request to every other registered cluster node, summing up
/// how many entries each one cleared.
///
/// Best effort: an unreachable peer is logged, not raised — its cache expires
/// on its own once the grace period elapses.
async fn clear_on_peers(ctx: &AuthenticatedRequestContext, req: &Request) -> u64 {
// `local_only` stops the peers from fanning out again.
let separator = if req.original_uri().query().is_some() {
"&"
} else {
"?"
};
let path = format!("{}{separator}local_only=true", req.original_uri().path());
let mut total = 0u64;
for (hostname, response) in fan_out_to_peers(ctx, req, &path).await {
if response.status() != StatusCode::OK {
let status = response.status();
warn!(node = %hostname, %status, "Failed to clear web approvals on a cluster node");
continue;
}
match parse_forwarded_body::<ClearWebApprovalsResult>(response).await {
Ok(result) => total = total.saturating_add(result.cleared_count),
Err(error) => {
warn!(node = %hostname, %error, "Malformed clear-web-approvals response from a cluster node");
}
}
}
total
}