Skip to main content

pgroles_operator/
request_index.rs

1//! Watch-fed indexes for ephemeral access requests.
2//!
3//! The request controller and this index consume the same watcher stream. A
4//! request therefore enters the index before its reconcile can activate access,
5//! while finalizers keep deleted requests present until revocation completes.
6
7use std::collections::{HashMap, HashSet};
8use std::sync::Arc;
9use std::sync::RwLock;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::time::Duration;
12
13use kube::ResourceExt;
14use kube::runtime::reflector::ObjectRef;
15use kube::runtime::watcher::Event;
16use tokio::sync::Notify;
17
18use crate::crd::EphemeralAccessRequest;
19
20type NamespacedKey = (String, String);
21
22/// How long a lookup waits for the initial watch sync before giving up.
23///
24/// `compose_effective_graph` calls into this index from the PostgresPolicy
25/// reconciler *after* both database locks are held. An unbounded wait would
26/// therefore keep a PostgreSQL advisory lock for as long as the request watch
27/// stayed broken, stalling every replica and every policy sharing that
28/// database rather than only the ephemeral paths. Failing the lookup instead
29/// lets the reconcile unwind, drop its locks, and requeue with backoff.
30const READY_TIMEOUT: Duration = Duration::from_secs(30);
31
32/// The request watch had not completed its initial sync in time.
33#[derive(Debug, thiserror::Error)]
34#[error("ephemeral request index did not sync within {waited:?}")]
35pub struct IndexNotReady {
36    waited: Duration,
37}
38
39#[derive(Default)]
40struct IndexState {
41    objects: HashMap<ObjectRef<EphemeralAccessRequest>, Arc<EphemeralAccessRequest>>,
42    by_access_policy_name: HashMap<NamespacedKey, HashSet<ObjectRef<EphemeralAccessRequest>>>,
43    by_access_policy_uid: HashMap<NamespacedKey, HashSet<ObjectRef<EphemeralAccessRequest>>>,
44    by_target_policy_uid: HashMap<NamespacedKey, HashSet<ObjectRef<EphemeralAccessRequest>>>,
45}
46
47impl IndexState {
48    fn remove(&mut self, object_ref: &ObjectRef<EphemeralAccessRequest>) {
49        let Some(request) = self.objects.remove(object_ref) else {
50            return;
51        };
52        remove_ref(
53            &mut self.by_access_policy_name,
54            &namespaced_key(&request, &request.spec.access_policy_ref.name),
55            object_ref,
56        );
57        if let Some(resolved) = request
58            .status
59            .as_ref()
60            .and_then(|status| status.resolved_access.as_ref())
61        {
62            remove_ref(
63                &mut self.by_access_policy_uid,
64                &namespaced_key(&request, &resolved.access_policy_uid),
65                object_ref,
66            );
67            remove_ref(
68                &mut self.by_target_policy_uid,
69                &namespaced_key(&request, &resolved.target_policy_uid),
70                object_ref,
71            );
72        }
73    }
74
75    fn upsert(&mut self, request: &EphemeralAccessRequest) {
76        let object_ref = ObjectRef::from_obj(request);
77        self.remove(&object_ref);
78        let request = Arc::new(request.clone());
79        self.by_access_policy_name
80            .entry(namespaced_key(
81                &request,
82                &request.spec.access_policy_ref.name,
83            ))
84            .or_default()
85            .insert(object_ref.clone());
86        if let Some(resolved) = request
87            .status
88            .as_ref()
89            .and_then(|status| status.resolved_access.as_ref())
90        {
91            self.by_access_policy_uid
92                .entry(namespaced_key(&request, &resolved.access_policy_uid))
93                .or_default()
94                .insert(object_ref.clone());
95            self.by_target_policy_uid
96                .entry(namespaced_key(&request, &resolved.target_policy_uid))
97                .or_default()
98                .insert(object_ref.clone());
99        }
100        self.objects.insert(object_ref, request);
101    }
102
103    fn values_for(
104        &self,
105        index: &HashMap<NamespacedKey, HashSet<ObjectRef<EphemeralAccessRequest>>>,
106        key: &NamespacedKey,
107    ) -> Vec<Arc<EphemeralAccessRequest>> {
108        index
109            .get(key)
110            .into_iter()
111            .flatten()
112            .filter_map(|object_ref| self.objects.get(object_ref).cloned())
113            .collect()
114    }
115}
116
117fn namespaced_key(request: &EphemeralAccessRequest, value: &str) -> NamespacedKey {
118    (request.namespace().unwrap_or_default(), value.to_string())
119}
120
121fn remove_ref(
122    index: &mut HashMap<NamespacedKey, HashSet<ObjectRef<EphemeralAccessRequest>>>,
123    key: &NamespacedKey,
124    object_ref: &ObjectRef<EphemeralAccessRequest>,
125) {
126    let remove_key = if let Some(refs) = index.get_mut(key) {
127        refs.remove(object_ref);
128        refs.is_empty()
129    } else {
130        false
131    };
132    if remove_key {
133        index.remove(key);
134    }
135}
136
137/// An atomically refreshed, watch-fed request index.
138#[derive(Clone, Default)]
139pub struct RequestIndex {
140    live: Arc<RwLock<IndexState>>,
141    initializing: Arc<RwLock<Option<IndexState>>>,
142    ready: Arc<AtomicBool>,
143    ready_notify: Arc<Notify>,
144}
145
146impl RequestIndex {
147    /// Observe one event before it is passed to the controller reflector.
148    pub fn observe(&self, event: &Event<EphemeralAccessRequest>) {
149        match event {
150            Event::Apply(request) => self
151                .live
152                .write()
153                .expect("request index poisoned")
154                .upsert(request),
155            Event::Delete(request) => self
156                .live
157                .write()
158                .expect("request index poisoned")
159                .remove(&ObjectRef::from_obj(request)),
160            Event::Init => {
161                *self.initializing.write().expect("request index poisoned") =
162                    Some(IndexState::default());
163            }
164            Event::InitApply(request) => {
165                if let Some(buffer) = self
166                    .initializing
167                    .write()
168                    .expect("request index poisoned")
169                    .as_mut()
170                {
171                    buffer.upsert(request);
172                }
173            }
174            Event::InitDone => {
175                if let Some(buffer) = self
176                    .initializing
177                    .write()
178                    .expect("request index poisoned")
179                    .take()
180                {
181                    *self.live.write().expect("request index poisoned") = buffer;
182                }
183                self.ready.store(true, Ordering::Release);
184                self.ready_notify.notify_waiters();
185            }
186        }
187    }
188
189    async fn wait_ready(&self) -> Result<(), IndexNotReady> {
190        let synced = async {
191            while !self.ready.load(Ordering::Acquire) {
192                // Register interest before re-checking, so an InitDone landing
193                // between the check and the await is not a lost wakeup.
194                let notified = self.ready_notify.notified();
195                if self.ready.load(Ordering::Acquire) {
196                    break;
197                }
198                notified.await;
199            }
200        };
201        tokio::time::timeout(READY_TIMEOUT, synced)
202            .await
203            .map_err(|_| IndexNotReady {
204                waited: READY_TIMEOUT,
205            })
206    }
207
208    pub async fn for_access_policy_name(
209        &self,
210        namespace: &str,
211        name: &str,
212    ) -> Result<Vec<Arc<EphemeralAccessRequest>>, IndexNotReady> {
213        self.wait_ready().await?;
214        let state = self.live.read().expect("request index poisoned");
215        Ok(state.values_for(
216            &state.by_access_policy_name,
217            &(namespace.to_string(), name.to_string()),
218        ))
219    }
220
221    pub async fn for_access_policy_uid(
222        &self,
223        namespace: &str,
224        uid: &str,
225    ) -> Result<Vec<Arc<EphemeralAccessRequest>>, IndexNotReady> {
226        self.wait_ready().await?;
227        let state = self.live.read().expect("request index poisoned");
228        Ok(state.values_for(
229            &state.by_access_policy_uid,
230            &(namespace.to_string(), uid.to_string()),
231        ))
232    }
233
234    pub async fn for_target_policy_uid(
235        &self,
236        namespace: &str,
237        uid: &str,
238    ) -> Result<Vec<Arc<EphemeralAccessRequest>>, IndexNotReady> {
239        self.wait_ready().await?;
240        let state = self.live.read().expect("request index poisoned");
241        Ok(state.values_for(
242            &state.by_target_policy_uid,
243            &(namespace.to_string(), uid.to_string()),
244        ))
245    }
246
247    pub fn len(&self) -> usize {
248        self.live
249            .read()
250            .expect("request index poisoned")
251            .objects
252            .len()
253    }
254
255    pub fn is_empty(&self) -> bool {
256        self.len() == 0
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::crd::{
264        EphemeralAccessActor, EphemeralAccessRequestSpec, EphemeralAccessRequestStatus,
265        EphemeralAccessSubject, LocalObjectReference, ResolvedEphemeralAccess,
266    };
267
268    fn request(
269        name: &str,
270        access_name: &str,
271        access_uid: &str,
272        target_uid: &str,
273    ) -> EphemeralAccessRequest {
274        let mut request = EphemeralAccessRequest::new(
275            name,
276            EphemeralAccessRequestSpec {
277                access_policy_ref: LocalObjectReference {
278                    name: access_name.into(),
279                },
280                subject: EphemeralAccessSubject {
281                    role: "alice".into(),
282                },
283                requested_by: EphemeralAccessActor {
284                    username: "user".into(),
285                    uid: None,
286                    groups: vec![],
287                },
288                requested_duration: None,
289                justification: None,
290            },
291        );
292        request.metadata.namespace = Some("ns".into());
293        if !access_uid.is_empty() {
294            request.status = Some(EphemeralAccessRequestStatus {
295                resolved_access: Some(ResolvedEphemeralAccess {
296                    access_policy_uid: access_uid.into(),
297                    access_policy_generation: 1,
298                    target_policy_uid: target_uid.into(),
299                    target_policy_generation: 1,
300                    target_database_fingerprint: "sha256:test".into(),
301                    granted_duration: "1h".into(),
302                    bundle_encoding: "test".into(),
303                    bundle_hash: "sha256:test".into(),
304                    memberships: vec![],
305                }),
306                ..Default::default()
307            });
308        }
309        request
310    }
311
312    #[tokio::test]
313    async fn indexes_names_and_resolved_uids_and_replaces_on_restart() {
314        let index = RequestIndex::default();
315        let first = request("one", "access", "access-uid", "target-uid");
316        index.observe(&Event::Init);
317        index.observe(&Event::InitApply(first.clone()));
318        index.observe(&Event::InitDone);
319        assert_eq!(
320            index
321                .for_access_policy_name("ns", "access")
322                .await
323                .unwrap()
324                .len(),
325            1
326        );
327        assert_eq!(
328            index
329                .for_access_policy_uid("ns", "access-uid")
330                .await
331                .unwrap()
332                .len(),
333            1
334        );
335        assert_eq!(
336            index
337                .for_target_policy_uid("ns", "target-uid")
338                .await
339                .unwrap()
340                .len(),
341            1
342        );
343
344        index.observe(&Event::Init);
345        index.observe(&Event::InitDone);
346        assert_eq!(index.len(), 0);
347    }
348
349    #[tokio::test(start_paused = true)]
350    async fn lookups_fail_instead_of_hanging_when_the_watch_never_syncs() {
351        // A request watch that never reaches InitDone previously blocked every
352        // lookup forever. compose_effective_graph runs with both database locks
353        // held, so that hang stranded a PostgreSQL advisory lock and wedged the
354        // other replicas rather than failing this one reconcile.
355        let index = RequestIndex::default();
356        index.observe(&Event::Init);
357        assert!(
358            index
359                .for_target_policy_uid("ns", "target-uid")
360                .await
361                .is_err()
362        );
363    }
364
365    #[tokio::test(start_paused = true)]
366    async fn lookups_unblock_as_soon_as_the_watch_syncs() {
367        let index = RequestIndex::default();
368        let waiter = {
369            let index = index.clone();
370            tokio::spawn(async move { index.for_access_policy_name("ns", "access").await })
371        };
372        tokio::task::yield_now().await;
373        index.observe(&Event::Init);
374        index.observe(&Event::InitApply(request("one", "access", "", "")));
375        index.observe(&Event::InitDone);
376        assert_eq!(waiter.await.expect("waiter panicked").unwrap().len(), 1);
377    }
378
379    #[tokio::test]
380    async fn unresolved_requests_are_indexed_by_policy_name() {
381        let index = RequestIndex::default();
382        index.observe(&Event::Init);
383        index.observe(&Event::InitApply(request("one", "access", "", "")));
384        index.observe(&Event::InitDone);
385        assert_eq!(
386            index
387                .for_access_policy_name("ns", "access")
388                .await
389                .unwrap()
390                .len(),
391            1
392        );
393        assert!(
394            index
395                .for_access_policy_uid("ns", "access-uid")
396                .await
397                .unwrap()
398                .is_empty()
399        );
400    }
401
402    #[tokio::test]
403    async fn lookup_ignores_irrelevant_requests_and_forged_labels() {
404        let index = RequestIndex::default();
405        index.observe(&Event::Init);
406        for sequence in 0..1_000 {
407            let mut irrelevant = request(
408                &format!("irrelevant-{sequence}"),
409                "other-access",
410                "other-access-uid",
411                "other-target-uid",
412            );
413            irrelevant.metadata.namespace = Some(if sequence % 2 == 0 {
414                "ns".into()
415            } else {
416                "other-ns".into()
417            });
418            index.observe(&Event::InitApply(irrelevant));
419        }
420        let mut relevant = request("relevant", "access", "access-uid", "target-uid");
421        relevant.labels_mut().insert(
422            crate::crd::LABEL_TARGET_POLICY_UID.into(),
423            "forged-uid".into(),
424        );
425        index.observe(&Event::InitApply(relevant));
426        index.observe(&Event::InitDone);
427
428        let matches = index
429            .for_target_policy_uid("ns", "target-uid")
430            .await
431            .unwrap();
432        assert_eq!(matches.len(), 1);
433        assert_eq!(matches[0].name_any(), "relevant");
434        assert!(
435            index
436                .for_target_policy_uid("ns", "forged-uid")
437                .await
438                .unwrap()
439                .is_empty()
440        );
441    }
442}