agentguard_server/
grpc.rs

1//! gRPC AccessEvaluation service.
2//!
3//! Mirrors the HTTP `/access/v1/evaluation` endpoint so a PEP can
4//! pick its transport. Same Authorizer, same DecisionLog, same
5//! auth_layer — the gRPC handler delegates to the shared
6//! `evaluation_request_to_agent` helper to keep principal/action/
7//! resource semantics identical across transports.
8
9use crate::auth_layer::AuthenticationFailure;
10use crate::authzen::{evaluation_request_for_caller, AppState};
11use crate::proto::agentguard::v1::{
12    access_evaluation_server::{AccessEvaluation, AccessEvaluationServer},
13    EvaluationRequest as PbRequest, EvaluationResponse as PbResponse,
14};
15use agentguard_core::Effect;
16use axum::http::StatusCode;
17use std::sync::Arc;
18use tonic::{Request, Response, Status};
19
20/// Keep the protobuf transport's request budget aligned with the HTTP
21/// interface. The limit applies before protobuf decoding at the server.
22pub const MAX_GRPC_REQUEST_BYTES: usize = 64 * 1024;
23
24/// The service implementation. Cheap to clone (internally Arc).
25#[derive(Clone)]
26pub struct AccessEvaluationService {
27    state: Arc<AppState>,
28}
29
30impl AccessEvaluationService {
31    pub fn new(state: Arc<AppState>) -> Self {
32        Self { state }
33    }
34}
35
36#[tonic::async_trait]
37impl AccessEvaluation for AccessEvaluationService {
38    #[tracing::instrument(
39        skip_all,
40        fields(
41            subject_id = tracing::field::Empty,
42            action_id = tracing::field::Empty,
43            resource_id = tracing::field::Empty,
44        )
45    )]
46    async fn evaluation(
47        &self,
48        request: Request<PbRequest>,
49    ) -> Result<Response<PbResponse>, Status> {
50        let caller = self
51            .state
52            .auth
53            .authenticate_bearer_async(
54                request
55                    .metadata()
56                    .get("authorization")
57                    .and_then(|value| value.to_str().ok()),
58                "authorize",
59                true,
60            )
61            .await
62            .map_err(|failure| match failure {
63                AuthenticationFailure::Unauthenticated => Status::unauthenticated("unauthorized"),
64                AuthenticationFailure::Forbidden => Status::permission_denied("forbidden"),
65                AuthenticationFailure::Unavailable => {
66                    Status::unavailable("authentication capacity exhausted")
67                }
68            })?;
69        let req = request.into_inner();
70        let request_bytes = req
71            .subject
72            .as_ref()
73            .map_or(0, |entity| {
74                entity.r#type.len().saturating_add(entity.id.len())
75            })
76            .saturating_add(req.action.as_ref().map_or(0, |entity| {
77                entity.r#type.len().saturating_add(entity.id.len())
78            }))
79            .saturating_add(req.resource.as_ref().map_or(0, |entity| {
80                entity.r#type.len().saturating_add(entity.id.len())
81            }))
82            .saturating_add(req.context_json.len())
83            .saturating_add(req.entities_json.len());
84        if request_bytes > MAX_GRPC_REQUEST_BYTES {
85            return Err(Status::resource_exhausted(
86                "evaluation request exceeds 64 KiB",
87            ));
88        }
89        let subject = req
90            .subject
91            .ok_or_else(|| Status::invalid_argument("missing subject"))?;
92        let action = req
93            .action
94            .ok_or_else(|| Status::invalid_argument("missing action"))?;
95        let resource = req
96            .resource
97            .ok_or_else(|| Status::invalid_argument("missing resource"))?;
98
99        // Parse context + entities once. Previously the entities JSON
100        // was decoded twice per request — once into the AuthZEN
101        // request shape and once into the cedar Entities builder —
102        // which doubled the CPU on the validation path.
103        let context: serde_json::Value = if req.context_json.is_empty() {
104            serde_json::json!({})
105        } else {
106            serde_json::from_str(&req.context_json)
107                .map_err(|e| Status::invalid_argument(format!("context_json: {e}")))?
108        };
109        let per_request_entities: Vec<serde_json::Value> = if req.entities_json.is_empty() {
110            Vec::new()
111        } else {
112            serde_json::from_str(&req.entities_json)
113                .map_err(|e| Status::invalid_argument(format!("entities_json: {e}")))?
114        };
115
116        // Convert proto EntityRef -> AuthZEN JSON shape -> AgentRequest.
117        let http_style = crate::authzen::EvaluationRequest {
118            subject: crate::authzen::EntityRef {
119                entity_type: subject.r#type,
120                id: subject.id,
121            },
122            action: crate::authzen::EntityRef {
123                entity_type: action.r#type,
124                id: action.id,
125            },
126            resource: crate::authzen::EntityRef {
127                entity_type: resource.r#type,
128                id: resource.id,
129            },
130            context: context.clone(),
131            entities: per_request_entities.clone(),
132        };
133
134        let agent_req = evaluation_request_for_caller(http_style, caller.as_ref()).map_err(
135            |error| match error {
136                crate::authzen::EvaluationMappingError::IdentityMismatch => {
137                    Status::permission_denied("subject or tenant does not match API key")
138                }
139                crate::authzen::EvaluationMappingError::InvalidRequest(message) => {
140                    Status::invalid_argument(message)
141                }
142            },
143        )?;
144        // Fill the tracing span fields now that we have the parsed request.
145        tracing::Span::current().record("subject_id", agent_req.principal.id().to_string());
146        tracing::Span::current().record("action_id", format!("{}", agent_req.action));
147        tracing::Span::current().record("resource_id", agent_req.resource.uid.to_string());
148        let action_label = format!("{}", agent_req.action);
149
150        let (decision, elapsed) = crate::authzen::authorize_and_persist_decision(
151            self.state.pdp_work_slots.clone(),
152            self.state.authorizer().snapshot(),
153            agent_req,
154            per_request_entities,
155            self.state.audit_handle(),
156            crate::authzen::authenticated_actor(caller.as_ref()),
157        )
158        .await
159        .map_err(|error| {
160            let (status, message) = crate::authzen::report_pdp_work_failure(&self.state, error);
161            match status {
162                StatusCode::SERVICE_UNAVAILABLE => Status::unavailable(message),
163                StatusCode::BAD_REQUEST => Status::invalid_argument(message),
164                _ => Status::internal(message),
165            }
166        })?;
167
168        let effect_label = match decision.effect {
169            Effect::Allow => "allow",
170            Effect::Deny => "deny",
171        };
172        let policy_id = decision
173            .policies
174            .first()
175            .cloned()
176            .unwrap_or_else(|| "none".into());
177        self.state
178            .metrics()
179            .record_decision(effect_label, &policy_id, &action_label, "", elapsed);
180        if decision.from_cache {
181            self.state.metrics().record_cache_hit();
182        } else {
183            self.state.metrics().record_cache_miss();
184        }
185
186        Ok(Response::new(PbResponse {
187            decision: matches!(decision.effect, Effect::Allow),
188            reason: decision.reasons.first().cloned().unwrap_or_default(),
189            context_json: String::new(),
190        }))
191    }
192}
193
194/// Return the tonic-wrapped service ready to mount on a `Server`.
195pub fn service(state: Arc<AppState>) -> AccessEvaluationServer<AccessEvaluationService> {
196    AccessEvaluationServer::new(AccessEvaluationService::new(state))
197        .max_decoding_message_size(MAX_GRPC_REQUEST_BYTES)
198}