Skip to main content

adk_computer_use/
auth.rs

1//! Coarse `computer:*` scope authorization bound to an adk-auth identity.
2//!
3//! [`ScopeAuthorizer`] is a *gate*, not an approval: it confirms the caller
4//! holds the entitlement for the requested [`ExecutionMode`] and that the
5//! action's principal/tenant match the identity already verified by adk-auth.
6//! The runtime policy engine still evaluates the exact action independently.
7
8use crate::{ActionClass, ExecutionMode};
9use adk_auth::check_scopes;
10use thiserror::Error;
11
12/// Authenticated identity and exact operation context forwarded to the runtime.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ComputerUseAuthContext {
15    /// Authenticated principal proposing the action.
16    pub principal_id: String,
17    /// Authenticated tenant, when multi-tenant.
18    pub tenant_id: Option<String>,
19    /// The session the action belongs to.
20    pub session_id: String,
21    /// Execution group for multi-agent coordination.
22    pub execution_group_id: String,
23    /// The execution mode being requested.
24    pub requested_mode: ExecutionMode,
25    /// The operation-aware action class.
26    pub action_class: ActionClass,
27    /// Target application/bundle identifier, when applicable.
28    pub target_app: Option<String>,
29    /// Target window identifier, when applicable.
30    pub target_window: Option<String>,
31    /// Digest of the policy under which the action is proposed.
32    pub policy_digest: String,
33}
34
35/// Coarse ADK scope gate. The runtime policy engine still evaluates the exact action.
36#[derive(Debug, Clone, Default)]
37pub struct ScopeAuthorizer {
38    scopes: Vec<String>,
39    principal_id: Option<String>,
40    tenant_id: Option<String>,
41}
42
43/// Reason a [`ScopeAuthorizer`] rejected a [`ComputerUseAuthContext`].
44#[derive(Debug, Error, PartialEq, Eq)]
45pub enum AuthorizationError {
46    /// The caller lacks the scope required for the requested mode.
47    #[error("missing required computer-use scope: {0}")]
48    MissingScope(&'static str),
49    /// The action's principal does not match the verified ADK identity.
50    #[error("principal does not match the verified ADK identity")]
51    PrincipalMismatch,
52    /// The action's tenant does not match the verified ADK identity.
53    #[error("tenant does not match the verified ADK identity")]
54    TenantMismatch,
55}
56
57impl ScopeAuthorizer {
58    /// Construct a scope gate from verified JWT/OIDC request scopes.
59    pub fn new(scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
60        Self {
61            scopes: scopes.into_iter().map(Into::into).collect(),
62            principal_id: None,
63            tenant_id: None,
64        }
65    }
66
67    /// Construct from identity already verified by adk-auth JWT/OIDC middleware.
68    pub fn from_verified_identity(
69        principal_id: impl Into<String>,
70        tenant_id: Option<String>,
71        scopes: impl IntoIterator<Item = impl Into<String>>,
72    ) -> Self {
73        Self {
74            scopes: scopes.into_iter().map(Into::into).collect(),
75            principal_id: Some(principal_id.into()),
76            tenant_id,
77        }
78    }
79
80    /// Tenant identity already verified by adk-auth middleware. Graph/model
81    /// state is never consulted for this value.
82    pub fn verified_tenant_id(&self) -> Option<&str> {
83        self.tenant_id.as_deref()
84    }
85
86    /// Verify coarse entitlement without treating it as runtime action approval.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`AuthorizationError::PrincipalMismatch`] or
91    /// [`AuthorizationError::TenantMismatch`] when the context identity differs
92    /// from the verified identity, or [`AuthorizationError::MissingScope`] when
93    /// the required `computer:*` scope for the requested mode is absent.
94    pub fn authorize(&self, context: &ComputerUseAuthContext) -> Result<(), AuthorizationError> {
95        if self.principal_id.as_deref().is_some_and(|id| id != context.principal_id) {
96            return Err(AuthorizationError::PrincipalMismatch);
97        }
98        if self.tenant_id.is_some() && self.tenant_id != context.tenant_id {
99            return Err(AuthorizationError::TenantMismatch);
100        }
101        let required = match context.requested_mode {
102            ExecutionMode::Shadow => "computer:plan",
103            ExecutionMode::Background => "computer:execute:background",
104            ExecutionMode::Foreground => "computer:execute:foreground",
105        };
106        check_scopes(&[required], &self.scopes)
107            .map_err(|_| AuthorizationError::MissingScope(required))
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn foreground_scope_does_not_follow_from_background_scope() {
117        let authorizer = ScopeAuthorizer::new(["computer:execute:background"]);
118        let context = ComputerUseAuthContext {
119            principal_id: "p".into(),
120            tenant_id: None,
121            session_id: "s".into(),
122            execution_group_id: "g".into(),
123            requested_mode: ExecutionMode::Foreground,
124            action_class: ActionClass::Navigate,
125            target_app: None,
126            target_window: None,
127            policy_digest: "d".into(),
128        };
129        assert_eq!(
130            authorizer.authorize(&context),
131            Err(AuthorizationError::MissingScope("computer:execute:foreground"))
132        );
133    }
134
135    #[test]
136    fn verified_identity_must_match_runtime_principal_and_tenant() {
137        let authorizer = ScopeAuthorizer::from_verified_identity(
138            "verified",
139            Some("tenant-a".into()),
140            ["computer:execute:background"],
141        );
142        let mut context = ComputerUseAuthContext {
143            principal_id: "attacker".into(),
144            tenant_id: Some("tenant-a".into()),
145            session_id: "s".into(),
146            execution_group_id: "g".into(),
147            requested_mode: ExecutionMode::Background,
148            action_class: ActionClass::Navigate,
149            target_app: None,
150            target_window: None,
151            policy_digest: "d".into(),
152        };
153        assert_eq!(authorizer.authorize(&context), Err(AuthorizationError::PrincipalMismatch));
154        context.principal_id = "verified".into();
155        context.tenant_id = Some("tenant-b".into());
156        assert_eq!(authorizer.authorize(&context), Err(AuthorizationError::TenantMismatch));
157    }
158}