1use super::command_check::CommandCheckResult;
44use super::Session;
45use crate::components::ApprovalRequest;
46use orcs_auth::{GrantPolicy, PermissionPolicy};
47use orcs_types::SignalScope;
48
49pub trait PermissionChecker: PermissionPolicy {
99 fn check_command(
121 &self,
122 session: &Session,
123 grants: &dyn GrantPolicy,
124 cmd: &str,
125 ) -> CommandCheckResult {
126 let granted = grants.is_granted(cmd).unwrap_or_else(|e| {
127 tracing::error!("grant check failed in default check_command: {e}");
128 false
129 });
130 if self.can_execute_command(session, cmd) || granted {
131 CommandCheckResult::Allowed
132 } else {
133 CommandCheckResult::Denied("permission denied".to_string())
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, Default)]
162pub struct DefaultPolicy;
163
164impl PermissionPolicy for DefaultPolicy {
165 fn can_signal(&self, session: &Session, scope: &SignalScope) -> bool {
166 let allowed = match scope {
167 SignalScope::Global => session.is_elevated(),
168 SignalScope::Channel(_) | SignalScope::WithChildren(_) => true,
169 };
170
171 if allowed {
173 tracing::debug!(
174 principal = ?session.principal(),
175 elevated = session.is_elevated(),
176 scope = ?scope,
177 "signal allowed"
178 );
179 } else {
180 tracing::warn!(
181 principal = ?session.principal(),
182 elevated = session.is_elevated(),
183 scope = ?scope,
184 "signal denied: requires elevation"
185 );
186 }
187
188 allowed
189 }
190
191 fn can_destructive(&self, session: &Session, action: &str) -> bool {
192 let allowed = session.is_elevated();
193
194 if allowed {
196 tracing::info!(
197 principal = ?session.principal(),
198 action = action,
199 "destructive operation allowed"
200 );
201 } else {
202 tracing::warn!(
203 principal = ?session.principal(),
204 action = action,
205 "destructive operation denied: requires elevation"
206 );
207 }
208
209 allowed
210 }
211
212 fn can_execute_command(&self, session: &Session, cmd: &str) -> bool {
213 let allowed = session.is_elevated();
214
215 if allowed {
216 tracing::debug!(
217 principal = ?session.principal(),
218 cmd = cmd,
219 "command allowed"
220 );
221 } else {
222 tracing::warn!(
223 principal = ?session.principal(),
224 cmd = cmd,
225 "command denied: requires elevation"
226 );
227 }
228
229 allowed
230 }
231
232 fn can_spawn_child(&self, session: &Session) -> bool {
233 let allowed = session.is_elevated();
234
235 if allowed {
237 tracing::debug!(
238 principal = ?session.principal(),
239 "spawn_child allowed"
240 );
241 } else {
242 tracing::warn!(
243 principal = ?session.principal(),
244 "spawn_child denied: requires elevation"
245 );
246 }
247
248 allowed
249 }
250
251 fn can_spawn_runner(&self, session: &Session) -> bool {
252 let allowed = session.is_elevated();
253
254 if allowed {
256 tracing::debug!(
257 principal = ?session.principal(),
258 "spawn_runner allowed"
259 );
260 } else {
261 tracing::warn!(
262 principal = ?session.principal(),
263 "spawn_runner denied: requires elevation"
264 );
265 }
266
267 allowed
268 }
269}
270
271impl PermissionChecker for DefaultPolicy {
272 fn check_command(
283 &self,
284 session: &Session,
285 grants: &dyn GrantPolicy,
286 cmd: &str,
287 ) -> CommandCheckResult {
288 let cmd_trimmed = cmd.trim();
289 if cmd_trimmed.is_empty() {
290 tracing::debug!("command denied: empty command");
291 return CommandCheckResult::Denied("empty command".to_string());
292 }
293
294 let granted = grants.is_granted(cmd).unwrap_or_else(|e| {
296 tracing::error!("grant check failed: {e}");
297 false
298 });
299 if granted {
300 tracing::debug!(
301 principal = ?session.principal(),
302 cmd = cmd,
303 "command allowed: previously granted"
304 );
305 return CommandCheckResult::Allowed;
306 }
307
308 if self.can_execute_command(session, cmd) {
310 return CommandCheckResult::Allowed;
311 }
312
313 let cmd_base = cmd.split_whitespace().next().unwrap_or(cmd);
315 tracing::info!(
316 principal = ?session.principal(),
317 cmd = cmd,
318 pattern = cmd_base,
319 "command requires approval (non-elevated session)"
320 );
321
322 let request = ApprovalRequest::new(
323 "exec",
324 format!("Execute command: {}", cmd),
325 serde_json::json!({
326 "command": cmd,
327 "pattern": cmd_base,
328 }),
329 );
330
331 CommandCheckResult::RequiresApproval {
332 request,
333 grant_pattern: cmd_base.to_string(),
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::auth::DefaultGrantStore;
342 use orcs_auth::CommandGrant;
343 use orcs_types::{ChannelId, Principal, PrincipalId};
344 use std::time::Duration;
345
346 fn standard_session() -> Session {
347 Session::new(Principal::User(PrincipalId::new()))
348 }
349
350 fn elevated_session() -> Session {
351 standard_session().elevate(Duration::from_secs(60))
352 }
353
354 fn empty_grants() -> DefaultGrantStore {
355 DefaultGrantStore::new()
356 }
357
358 #[test]
359 fn standard_cannot_signal_global() {
360 let policy = DefaultPolicy;
361 let session = standard_session();
362
363 assert!(!policy.can_signal(&session, &SignalScope::Global));
364 }
365
366 #[test]
367 fn standard_can_signal_channel() {
368 let policy = DefaultPolicy;
369 let session = standard_session();
370 let channel = ChannelId::new();
371
372 assert!(policy.can_signal(&session, &SignalScope::Channel(channel)));
373 }
374
375 #[test]
376 fn elevated_can_signal_global() {
377 let policy = DefaultPolicy;
378 let session = elevated_session();
379
380 assert!(policy.can_signal(&session, &SignalScope::Global));
381 }
382
383 #[test]
384 fn elevated_can_signal_channel() {
385 let policy = DefaultPolicy;
386 let session = elevated_session();
387 let channel = ChannelId::new();
388
389 assert!(policy.can_signal(&session, &SignalScope::Channel(channel)));
390 }
391
392 #[test]
393 fn standard_cannot_destructive() {
394 let policy = DefaultPolicy;
395 let session = standard_session();
396
397 assert!(!policy.can_destructive(&session, "git reset --hard"));
398 assert!(!policy.can_destructive(&session, "rm -rf"));
399 }
400
401 #[test]
402 fn elevated_can_destructive() {
403 let policy = DefaultPolicy;
404 let session = elevated_session();
405
406 assert!(policy.can_destructive(&session, "git reset --hard"));
407 assert!(policy.can_destructive(&session, "rm -rf"));
408 }
409
410 #[test]
411 fn system_session_standard() {
412 let policy = DefaultPolicy;
413 let session = Session::new(Principal::System);
414
415 assert!(!policy.can_signal(&session, &SignalScope::Global));
417 assert!(policy.can_signal(&session, &SignalScope::Channel(ChannelId::new())));
418 }
419
420 #[test]
421 fn dropped_privilege_cannot_global() {
422 let policy = DefaultPolicy;
423 let session = elevated_session().drop_privilege();
424
425 assert!(!policy.can_signal(&session, &SignalScope::Global));
426 }
427
428 #[test]
429 fn standard_cannot_execute_command() {
430 let policy = DefaultPolicy;
431 let session = standard_session();
432
433 assert!(!policy.can_execute_command(&session, "ls -la"));
434 assert!(!policy.can_execute_command(&session, "rm -rf ./temp"));
435 }
436
437 #[test]
438 fn elevated_can_execute_any_command() {
439 let policy = DefaultPolicy;
440 let session = elevated_session();
441
442 assert!(policy.can_execute_command(&session, "ls -la"));
444 assert!(policy.can_execute_command(&session, "rm -rf ./target"));
445 assert!(policy.can_execute_command(&session, "rm -rf /"));
446 assert!(policy.can_execute_command(&session, "git push --force"));
447 }
448
449 #[test]
450 fn standard_cannot_spawn_child() {
451 let policy = DefaultPolicy;
452 let session = standard_session();
453
454 assert!(!policy.can_spawn_child(&session));
455 }
456
457 #[test]
458 fn elevated_can_spawn_child() {
459 let policy = DefaultPolicy;
460 let session = elevated_session();
461
462 assert!(policy.can_spawn_child(&session));
463 }
464
465 #[test]
466 fn standard_cannot_spawn_runner() {
467 let policy = DefaultPolicy;
468 let session = standard_session();
469
470 assert!(!policy.can_spawn_runner(&session));
471 }
472
473 #[test]
474 fn elevated_can_spawn_runner() {
475 let policy = DefaultPolicy;
476 let session = elevated_session();
477
478 assert!(policy.can_spawn_runner(&session));
479 }
480
481 #[test]
486 fn check_command_requires_approval_when_not_elevated() {
487 let policy = DefaultPolicy;
488 let session = standard_session();
489 let grants = empty_grants();
490
491 let result = policy.check_command(&session, &grants, "ls -la");
492 assert!(result.requires_approval());
493 assert_eq!(result.grant_pattern(), Some("ls"));
494 }
495
496 #[test]
497 fn check_command_elevated_session_allowed() {
498 let policy = DefaultPolicy;
499 let session = elevated_session();
500 let grants = empty_grants();
501
502 let result = policy.check_command(&session, &grants, "rm -rf ./temp");
504 assert!(result.is_allowed());
505
506 let result = policy.check_command(&session, &grants, "rm -rf /");
507 assert!(result.is_allowed());
508 }
509
510 #[test]
511 fn check_command_granted_command_allowed() {
512 let policy = DefaultPolicy;
513 let session = standard_session();
514 let grants = DefaultGrantStore::new();
515
516 grants
517 .grant(CommandGrant::persistent("rm -rf"))
518 .expect("grant persistent for check_command test");
519
520 let result = policy.check_command(&session, &grants, "rm -rf ./temp");
521 assert!(result.is_allowed());
522 }
523
524 #[test]
525 fn check_command_empty_returns_denied() {
526 let policy = DefaultPolicy;
527 let session = elevated_session();
528 let grants = empty_grants();
529
530 let result = policy.check_command(&session, &grants, " ");
531 assert!(result.is_denied());
532 }
533
534 #[test]
535 fn check_command_any_cmd_requires_approval_for_standard() {
536 let policy = DefaultPolicy;
537 let session = standard_session();
538 let grants = empty_grants();
539
540 let result = policy.check_command(&session, &grants, "git push --force origin main");
542 assert!(result.requires_approval());
543 assert_eq!(result.grant_pattern(), Some("git"));
544 }
545}