Skip to main content

pgroles_operator/
ephemeral.rs

1//! Ephemeral PostgreSQL membership access reconciliation.
2//!
3//! Durable database policy remains in `PostgresPolicy`. These controllers
4//! resolve immutable, bounded membership bundles and apply only the scoped
5//! membership edges while sharing the ordinary database locks.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9use std::time::{Duration, SystemTime};
10
11use kube::api::{DeleteParams, ListParams, Patch, PatchParams, PostParams};
12use kube::runtime::controller::Action;
13use kube::runtime::finalizer::{self, Event as FinalizerEvent};
14use kube::{Api, Resource, ResourceExt};
15
16use crate::context::{ContextError, OperatorContext};
17use crate::crd::{
18    ChangeSummary, DatabaseIdentity, EPHEMERAL_BUNDLE_ENCODING_V1, EphemeralAccessCondition,
19    EphemeralAccessPolicy, EphemeralAccessPolicyStatus, EphemeralAccessRequest,
20    EphemeralAccessRequestPhase, EphemeralAccessRequestStatus, EphemeralApprovalMode,
21    LABEL_ACCESS_POLICY_UID, LABEL_POLICY, LABEL_TARGET_POLICY_UID, PlanOrigin, PlanPhase,
22    PlanScope, PolicyCondition, PolicyMode, PolicyPlanRef, PostgresPolicy, PostgresPolicyPlan,
23    PostgresPolicyPlanSpec, PostgresPolicyPlanStatus, ResolvedEphemeralAccess,
24    ResolvedEphemeralMembership, ScopedPlanOperation,
25};
26use crate::k8s_names::LabelValue;
27use crate::reconciler::ReconcileError;
28
29const ACCESS_POLICY_FINALIZER: &str = "ephemeralaccesspolicy.pgroles.io/finalizer";
30const ACCESS_REQUEST_FINALIZER: &str = "ephemeralaccessrequest.pgroles.io/finalizer";
31const DEFAULT_CLUSTER_MAXIMUM: &str = "24h";
32const DEFAULT_CLUSTER_PENDING_MAXIMUM: &str = "1h";
33const RETRY_DELAY: Duration = Duration::from_secs(5);
34
35/// Delete every ephemeral access policy attached to a target policy.
36///
37/// Called from the target's finalizer so the target remains available while
38/// access-policy and request finalizers perform scoped revocation.
39pub(crate) async fn delete_access_policies_for_target(
40    target: &PostgresPolicy,
41    ctx: &OperatorContext,
42) -> Result<usize, ReconcileError> {
43    let namespace = target.namespace().ok_or(ReconcileError::NoNamespace)?;
44    let access_policies: Api<EphemeralAccessPolicy> =
45        Api::namespaced(ctx.kube_client.clone(), &namespace);
46    let target_name = target.name_any();
47    let attached: Vec<_> = access_policies
48        .list(&ListParams::default())
49        .await?
50        .into_iter()
51        .filter(|policy| policy.spec.postgres_policy_ref.name == target_name)
52        .collect();
53
54    for policy in &attached {
55        if policy.meta().deletion_timestamp.is_none() {
56            match access_policies
57                .delete(&policy.name_any(), &DeleteParams::default())
58                .await
59            {
60                Ok(_) => {}
61                Err(kube::Error::Api(error)) if error.code == 404 => {}
62                Err(error) => return Err(error.into()),
63            }
64        }
65    }
66
67    Ok(attached.len())
68}
69
70#[derive(Debug, thiserror::Error)]
71pub enum EphemeralError {
72    #[error("Kubernetes API error: {0}")]
73    Kube(#[from] kube::Error),
74    #[error("database/controller error: {0}")]
75    Reconcile(#[from] ReconcileError),
76    #[error("context error: {0}")]
77    Context(#[from] Box<ContextError>),
78    #[error("invalid ephemeral access resource: {0}")]
79    Invalid(String),
80    #[error("waiting for {0} request(s) to be revoked")]
81    PendingCleanup(usize),
82    #[error("ephemeral request index unavailable: {0}")]
83    RequestIndexNotReady(#[from] crate::request_index::IndexNotReady),
84}
85
86pub async fn reconcile_access_policy(
87    resource: Arc<EphemeralAccessPolicy>,
88    ctx: Arc<OperatorContext>,
89) -> Result<Action, finalizer::Error<EphemeralError>> {
90    let _metrics = ctx
91        .observability
92        .start_ephemeral_reconcile("access_policy", ctx.request_index.len());
93    let namespace = resource
94        .namespace()
95        .unwrap_or_else(|| "default".to_string());
96    let api: Api<EphemeralAccessPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
97    finalizer::finalizer(&api, ACCESS_POLICY_FINALIZER, resource, |event| async {
98        match event {
99            FinalizerEvent::Apply(policy) => reconcile_access_policy_apply(&policy, &ctx).await,
100            FinalizerEvent::Cleanup(policy) => reconcile_access_policy_cleanup(&policy, &ctx).await,
101        }
102    })
103    .await
104}
105
106pub fn access_policy_error_policy(
107    _resource: Arc<EphemeralAccessPolicy>,
108    error: &finalizer::Error<EphemeralError>,
109    _ctx: Arc<OperatorContext>,
110) -> Action {
111    tracing::warn!(%error, "ephemeral access policy reconciliation failed");
112    Action::requeue(RETRY_DELAY)
113}
114
115async fn reconcile_access_policy_apply(
116    policy: &EphemeralAccessPolicy,
117    ctx: &OperatorContext,
118) -> Result<Action, EphemeralError> {
119    let namespace = policy
120        .namespace()
121        .ok_or_else(|| EphemeralError::Invalid("resource has no namespace".to_string()))?;
122    let maximum = match parse_duration(&policy.spec.maximum_duration) {
123        Ok(duration) => duration,
124        Err(error) => {
125            return update_access_policy_failure(
126                policy,
127                ctx,
128                "InvalidDuration",
129                &error.to_string(),
130            )
131            .await;
132        }
133    };
134    let cluster_maximum =
135        cluster_duration("EPHEMERAL_ACCESS_MAXIMUM_DURATION", DEFAULT_CLUSTER_MAXIMUM)?;
136    if maximum > cluster_maximum {
137        return update_access_policy_failure(
138            policy,
139            ctx,
140            "DurationExceedsClusterMaximum",
141            &format!(
142                "maximumDuration {} exceeds cluster maximum {}",
143                policy.spec.maximum_duration,
144                format_duration(cluster_maximum)
145            ),
146        )
147        .await;
148    }
149    let pending_ttl = match parse_duration(&policy.spec.pending_request_ttl) {
150        Ok(duration) => duration,
151        Err(error) => {
152            return update_access_policy_failure(
153                policy,
154                ctx,
155                "InvalidDuration",
156                &error.to_string(),
157            )
158            .await;
159        }
160    };
161    let cluster_pending_maximum = cluster_duration(
162        "EPHEMERAL_ACCESS_MAX_PENDING_TTL",
163        DEFAULT_CLUSTER_PENDING_MAXIMUM,
164    )?;
165    if pending_ttl > cluster_pending_maximum {
166        return update_access_policy_failure(
167            policy,
168            ctx,
169            "DurationExceedsClusterMaximum",
170            "pendingRequestTTL exceeds the cluster maximum",
171        )
172        .await;
173    }
174    if let Some(default_duration) = &policy.spec.default_duration {
175        let default_duration = match parse_duration(default_duration) {
176            Ok(duration) => duration,
177            Err(error) => {
178                return update_access_policy_failure(
179                    policy,
180                    ctx,
181                    "InvalidDuration",
182                    &error.to_string(),
183                )
184                .await;
185            }
186        };
187        if default_duration > maximum {
188            return update_access_policy_failure(
189                policy,
190                ctx,
191                "InvalidDuration",
192                "defaultDuration must not exceed maximumDuration",
193            )
194            .await;
195        }
196    }
197    if policy.spec.memberships.is_empty() {
198        return update_access_policy_failure(
199            policy,
200            ctx,
201            "EmptyBundle",
202            "at least one membership is required",
203        )
204        .await;
205    }
206
207    let policies: Api<PostgresPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
208    let target = match policies.get(&policy.spec.postgres_policy_ref.name).await {
209        Ok(target) => target,
210        Err(kube::Error::Api(error)) if error.code == 404 => {
211            return update_access_policy_failure(
212                policy,
213                ctx,
214                "TargetNotFound",
215                "referenced PostgresPolicy does not exist",
216            )
217            .await;
218        }
219        Err(error) => return Err(error.into()),
220    };
221    if target.spec.mode != PolicyMode::Apply {
222        return update_access_policy_failure(
223            policy,
224            ctx,
225            "TargetNotApplyMode",
226            "ephemeral access requires a PostgresPolicy in apply mode",
227        )
228        .await;
229    }
230
231    let expanded = pgroles_core::manifest::expand_manifest(&target.spec.to_policy_manifest())
232        .map_err(ReconcileError::from)?;
233    let roles: BTreeMap<_, _> = expanded
234        .roles
235        .iter()
236        .map(|role| (role.name.as_str(), role))
237        .collect();
238    let mut resolved_roles = BTreeSet::new();
239    for membership in &policy.spec.memberships {
240        let Some(role) = roles.get(membership.role.as_str()) else {
241            return update_access_policy_failure(
242                policy,
243                ctx,
244                "RoleNotFound",
245                &format!(
246                    "role {} is not in the expanded target policy",
247                    membership.role
248                ),
249            )
250            .await;
251        };
252        if role.external {
253            return update_access_policy_failure(
254                policy,
255                ctx,
256                "ExternalTargetRole",
257                &format!("role {} is externally managed", membership.role),
258            )
259            .await;
260        }
261        if !resolved_roles.insert(membership.role.clone()) {
262            return update_access_policy_failure(
263                policy,
264                ctx,
265                "DuplicateMembership",
266                &format!("role {} appears more than once", membership.role),
267            )
268            .await;
269        }
270    }
271
272    let mut status = policy.status.clone().unwrap_or_default();
273    status.observed_generation = policy.metadata.generation;
274    status.resolved_roles = resolved_roles.into_iter().collect();
275    set_condition(
276        &mut status.conditions,
277        access_condition("Accepted", true, "Accepted", "Access policy is valid"),
278    );
279    set_condition(
280        &mut status.conditions,
281        access_condition("ResolvedRefs", true, "Resolved", "Target roles resolved"),
282    );
283    let suspended = policy.spec.suspend || target.spec.suspend;
284    set_condition(
285        &mut status.conditions,
286        access_condition(
287            "Suspended",
288            suspended,
289            if policy.spec.suspend {
290                "AccessPolicySuspended"
291            } else if target.spec.suspend {
292                "TargetPolicySuspended"
293            } else {
294                "Active"
295            },
296            if suspended {
297                "New ephemeral access activation is suspended"
298            } else {
299                "Ephemeral access activation is enabled"
300            },
301        ),
302    );
303    patch_access_policy_status(policy, ctx, &status).await?;
304    Ok(Action::requeue(Duration::from_secs(300)))
305}
306
307async fn update_access_policy_failure(
308    policy: &EphemeralAccessPolicy,
309    ctx: &OperatorContext,
310    reason: &str,
311    message: &str,
312) -> Result<Action, EphemeralError> {
313    let mut status = policy.status.clone().unwrap_or_default();
314    status.observed_generation = policy.metadata.generation;
315    status.resolved_roles.clear();
316    set_condition(
317        &mut status.conditions,
318        access_condition("Accepted", false, reason, message),
319    );
320    patch_access_policy_status(policy, ctx, &status).await?;
321    Ok(Action::requeue(Duration::from_secs(60)))
322}
323
324async fn patch_access_policy_status(
325    policy: &EphemeralAccessPolicy,
326    ctx: &OperatorContext,
327    status: &EphemeralAccessPolicyStatus,
328) -> Result<(), kube::Error> {
329    patch_access_policy_status_with_client(policy, &ctx.kube_client, status).await
330}
331
332async fn patch_access_policy_status_with_client(
333    policy: &EphemeralAccessPolicy,
334    client: &kube::Client,
335    status: &EphemeralAccessPolicyStatus,
336) -> Result<(), kube::Error> {
337    let namespace = policy.namespace().unwrap_or_else(|| "default".to_string());
338    let api: Api<EphemeralAccessPolicy> = Api::namespaced(client.clone(), &namespace);
339    api.patch_status(
340        &policy.name_any(),
341        &PatchParams::apply("pgroles-operator"),
342        &Patch::Merge(serde_json::json!({
343            "metadata": { "resourceVersion": policy.resource_version() },
344            "status": status,
345        })),
346    )
347    .await?;
348    Ok(())
349}
350
351async fn reconcile_access_policy_cleanup(
352    policy: &EphemeralAccessPolicy,
353    ctx: &OperatorContext,
354) -> Result<Action, EphemeralError> {
355    let namespace = policy
356        .namespace()
357        .ok_or_else(|| EphemeralError::Invalid("resource has no namespace".to_string()))?;
358    let policy_uid = policy.uid().unwrap_or_default();
359    let mut indexed = ctx
360        .request_index
361        .for_access_policy_name(&namespace, &policy.name_any())
362        .await?;
363    indexed.extend(
364        ctx.request_index
365            .for_access_policy_uid(&namespace, &policy_uid)
366            .await?,
367    );
368    indexed.sort_by_key(|request| request.uid().unwrap_or_default());
369    indexed.dedup_by_key(|request| request.uid().unwrap_or_default());
370    ctx.observability
371        .record_ephemeral_relevant_requests("access_policy_cleanup", indexed.len());
372    let requests: Api<EphemeralAccessRequest> =
373        Api::namespaced(ctx.kube_client.clone(), &namespace);
374    let mut remaining = 0usize;
375    for request in indexed {
376        let resolved_match = request
377            .status
378            .as_ref()
379            .and_then(|status| status.resolved_access.as_ref())
380            .is_some_and(|resolved| resolved.access_policy_uid == policy_uid);
381        let unresolved_match = request
382            .status
383            .as_ref()
384            .and_then(|status| status.resolved_access.as_ref())
385            .is_none()
386            && request.spec.access_policy_ref.name == policy.name_any();
387        if resolved_match || unresolved_match {
388            remaining += 1;
389            if request.meta().deletion_timestamp.is_none() {
390                match requests
391                    .delete(&request.name_any(), &DeleteParams::default())
392                    .await
393                {
394                    Ok(_) => {}
395                    Err(kube::Error::Api(error)) if error.code == 404 => {}
396                    Err(error) => return Err(error.into()),
397                }
398            }
399        }
400    }
401    if remaining > 0 {
402        return Err(EphemeralError::PendingCleanup(remaining));
403    }
404    Ok(Action::await_change())
405}
406
407fn access_condition(
408    condition_type: &str,
409    status: bool,
410    reason: &str,
411    message: &str,
412) -> EphemeralAccessCondition {
413    EphemeralAccessCondition {
414        condition_type: condition_type.to_string(),
415        status: if status { "True" } else { "False" }.to_string(),
416        reason: Some(reason.to_string()),
417        message: Some(truncate_utf8(message, 2048)),
418        last_transition_time: Some(crate::crd::now_rfc3339()),
419        bundle_hash: None,
420        granted_duration: None,
421    }
422}
423
424fn truncate_utf8(value: &str, max_bytes: usize) -> String {
425    if value.len() <= max_bytes {
426        return value.to_string();
427    }
428    let mut end = max_bytes;
429    while !value.is_char_boundary(end) {
430        end -= 1;
431    }
432    value[..end].to_string()
433}
434
435fn set_condition(
436    conditions: &mut Vec<EphemeralAccessCondition>,
437    mut condition: EphemeralAccessCondition,
438) {
439    if let Some(existing) = conditions
440        .iter()
441        .find(|existing| existing.condition_type == condition.condition_type)
442        && existing.status == condition.status
443        && existing.reason == condition.reason
444        && existing.message == condition.message
445        && existing.bundle_hash == condition.bundle_hash
446        && existing.granted_duration == condition.granted_duration
447    {
448        condition.last_transition_time = existing.last_transition_time.clone();
449    }
450    conditions.retain(|existing| existing.condition_type != condition.condition_type);
451    conditions.push(condition);
452}
453
454fn parse_duration(value: &str) -> Result<Duration, EphemeralError> {
455    let value = value.trim();
456    if value.is_empty() {
457        return Err(EphemeralError::Invalid(
458            "duration must not be empty".to_string(),
459        ));
460    }
461    let mut total = 0u64;
462    let mut number = String::new();
463    for character in value.chars() {
464        if character.is_ascii_digit() {
465            number.push(character);
466            continue;
467        }
468        let amount: u64 = number
469            .parse()
470            .map_err(|_| EphemeralError::Invalid(format!("invalid duration {value:?}")))?;
471        number.clear();
472        let multiplier = match character {
473            'h' => 3600,
474            'm' => 60,
475            's' => 1,
476            _ => {
477                return Err(EphemeralError::Invalid(format!(
478                    "invalid duration unit {character:?}"
479                )));
480            }
481        };
482        let seconds = amount
483            .checked_mul(multiplier)
484            .ok_or_else(|| EphemeralError::Invalid("duration is too large".to_string()))?;
485        total = total
486            .checked_add(seconds)
487            .ok_or_else(|| EphemeralError::Invalid("duration is too large".to_string()))?;
488    }
489    if !number.is_empty() {
490        return Err(EphemeralError::Invalid(format!(
491            "duration {value:?} must end in s, m, or h"
492        )));
493    }
494    if total == 0 {
495        return Err(EphemeralError::Invalid(
496            "duration must be greater than zero".to_string(),
497        ));
498    }
499    Ok(Duration::from_secs(total))
500}
501
502fn cluster_duration(variable: &str, default: &str) -> Result<Duration, EphemeralError> {
503    parse_duration(&std::env::var(variable).unwrap_or_else(|_| default.to_string()))
504}
505
506fn format_duration(duration: Duration) -> String {
507    format!("{}s", duration.as_secs())
508}
509
510fn now_epoch_secs() -> u64 {
511    SystemTime::now()
512        .duration_since(SystemTime::UNIX_EPOCH)
513        .unwrap_or_default()
514        .as_secs()
515}
516
517fn timestamp_from_epoch(seconds: u64) -> Result<String, EphemeralError> {
518    let seconds = i64::try_from(seconds)
519        .map_err(|_| EphemeralError::Invalid("timestamp exceeds the supported range".into()))?;
520    jiff::Timestamp::from_second(seconds)
521        .map(|timestamp| timestamp.to_string())
522        .map_err(|error| EphemeralError::Invalid(format!("invalid timestamp: {error}")))
523}
524
525fn parse_timestamp(value: &str) -> Option<u64> {
526    value
527        .parse::<jiff::Timestamp>()
528        .ok()
529        .and_then(|timestamp| u64::try_from(timestamp.as_second()).ok())
530}
531
532pub async fn reconcile_access_request(
533    resource: Arc<EphemeralAccessRequest>,
534    ctx: Arc<OperatorContext>,
535) -> Result<Action, finalizer::Error<EphemeralError>> {
536    let _metrics = ctx
537        .observability
538        .start_ephemeral_reconcile("access_request", ctx.request_index.len());
539    let namespace = resource
540        .namespace()
541        .unwrap_or_else(|| "default".to_string());
542    let api: Api<EphemeralAccessRequest> = Api::namespaced(ctx.kube_client.clone(), &namespace);
543    finalizer::finalizer(&api, ACCESS_REQUEST_FINALIZER, resource, |event| async {
544        match event {
545            FinalizerEvent::Apply(request) => {
546                match reconcile_access_request_apply(&request, &ctx).await {
547                    Err(EphemeralError::Invalid(message)) => {
548                        update_request_validation_error(&request, &ctx, &message).await
549                    }
550                    result => result,
551                }
552            }
553            FinalizerEvent::Cleanup(request) => {
554                reconcile_access_request_cleanup(&request, &ctx).await
555            }
556        }
557    })
558    .await
559}
560
561pub fn access_request_error_policy(
562    _resource: Arc<EphemeralAccessRequest>,
563    error: &finalizer::Error<EphemeralError>,
564    _ctx: Arc<OperatorContext>,
565) -> Action {
566    tracing::warn!(%error, "ephemeral access request reconciliation failed");
567    if matches!(
568        error,
569        finalizer::Error::ApplyFailed(EphemeralError::Reconcile(ReconcileError::LockContention(
570            _,
571            _
572        ))) | finalizer::Error::CleanupFailed(EphemeralError::Reconcile(
573            ReconcileError::LockContention(_, _)
574        ))
575    ) {
576        let delay = ephemeral_lock_retry_delay();
577        tracing::debug!(
578            delay_millis = delay.as_millis(),
579            "requeuing ephemeral access after lock contention with jitter"
580        );
581        return Action::requeue(delay);
582    }
583    Action::requeue(RETRY_DELAY)
584}
585
586fn ephemeral_lock_retry_delay() -> Duration {
587    // A short, sub-second component prevents phase-locking with target policies
588    // that reconcile on whole-second intervals without consuming a material
589    // part of a short access grant's lifetime.
590    let nanos = std::time::SystemTime::now()
591        .duration_since(std::time::UNIX_EPOCH)
592        .unwrap_or_default()
593        .subsec_nanos();
594    Duration::from_millis(750 + u64::from(nanos % 2_251))
595}
596
597async fn reconcile_access_request_apply(
598    request: &EphemeralAccessRequest,
599    ctx: &OperatorContext,
600) -> Result<Action, EphemeralError> {
601    let mut status = request.status.clone().unwrap_or_default();
602
603    if status.resolved_access.is_none() {
604        let (resolved, approval_deadline, mode, suspended) =
605            match resolve_request(request, ctx).await {
606                Ok(resolved) => resolved,
607                Err(EphemeralError::Invalid(message)) => {
608                    return update_request_resolution_failure(request, ctx, &message).await;
609                }
610                Err(error) => return Err(error),
611            };
612        if suspended {
613            return update_request_resolution_failure(
614                request,
615                ctx,
616                "access policy or target PostgresPolicy is suspended",
617            )
618            .await;
619        }
620        status.resolved_access = Some(resolved.clone());
621        status.last_error = None;
622        status.approval_expires_at = Some(timestamp_from_epoch(approval_deadline)?);
623        status.phase = match mode {
624            EphemeralApprovalMode::Automatic => EphemeralAccessRequestPhase::Applying,
625            EphemeralApprovalMode::Required => EphemeralAccessRequestPhase::PendingApproval,
626        };
627        set_condition(
628            &mut status.conditions,
629            access_condition(
630                "Resolved",
631                true,
632                "AccessPolicyResolved",
633                "Immutable access bundle resolved",
634            ),
635        );
636        set_condition(
637            &mut status.conditions,
638            access_condition("Ready", true, "Resolved", "Request is ready to progress"),
639        );
640        if mode == EphemeralApprovalMode::Automatic {
641            persist_activation_deadline(&mut status, &resolved)?;
642        }
643        patch_request_index_labels(request, ctx, &resolved).await;
644        patch_request_status(request, ctx, &status).await?;
645        audit_transition(request, ctx, None, &status, "Resolved");
646        publish_request_event_best_effort(request, ctx, &status, "Resolved").await;
647        return Ok(Action::requeue(Duration::ZERO));
648    }
649
650    let resolved = status
651        .resolved_access
652        .clone()
653        .ok_or_else(|| EphemeralError::Invalid("resolved access disappeared".to_string()))?;
654    if !resolved.has_valid_bundle_hash() {
655        return Err(EphemeralError::Invalid(
656            "resolved bundle hash does not match its canonical payload".to_string(),
657        ));
658    }
659
660    if matches!(
661        status.phase,
662        EphemeralAccessRequestPhase::Ended
663            | EphemeralAccessRequestPhase::Revoked
664            | EphemeralAccessRequestPhase::Cancelled
665            | EphemeralAccessRequestPhase::Denied
666            | EphemeralAccessRequestPhase::ApprovalExpired
667            | EphemeralAccessRequestPhase::Failed
668    ) {
669        return Ok(Action::await_change());
670    }
671
672    if decision_condition(&status, "Denied").is_some() {
673        if status.phase == EphemeralAccessRequestPhase::Active
674            || (status.phase == EphemeralAccessRequestPhase::Applying
675                && request_has_scoped_activation_plan(&ctx.kube_client, request, &resolved, true)
676                    .await?)
677        {
678            status.retained_memberships =
679                apply_scoped_memberships(request, ctx, &resolved, ScopedPlanOperation::Revoke)
680                    .await?;
681            ctx.observability
682                .record_ephemeral_retained_memberships(status.retained_memberships.len());
683            status.ended_at = Some(crate::crd::now_rfc3339());
684        }
685        transition_request(
686            request,
687            ctx,
688            &mut status,
689            EphemeralAccessRequestPhase::Denied,
690            "Denied",
691        )
692        .await?;
693        return Ok(Action::await_change());
694    }
695
696    let (mode, suspended) = if matches!(
697        status.phase,
698        EphemeralAccessRequestPhase::PendingApproval | EphemeralAccessRequestPhase::Applying
699    ) {
700        let (current_resolved, _, mode, suspended) = resolve_request(request, ctx).await?;
701        let snapshot_mode = if status.phase == EphemeralAccessRequestPhase::PendingApproval
702            || decision_condition(&status, "Approved").is_some()
703            || decision_condition(&status, "Denied").is_some()
704        {
705            EphemeralApprovalMode::Required
706        } else {
707            EphemeralApprovalMode::Automatic
708        };
709        let changed = current_resolved.access_policy_uid != resolved.access_policy_uid
710            || current_resolved.target_policy_uid != resolved.target_policy_uid
711            || current_resolved.compute_bundle_hash() != resolved.bundle_hash
712            || current_resolved.granted_duration != resolved.granted_duration
713            || mode != snapshot_mode;
714        if changed || (suspended && status.phase == EphemeralAccessRequestPhase::Applying) {
715            if status.phase == EphemeralAccessRequestPhase::Applying
716                && request_has_scoped_activation_plan(&ctx.kube_client, request, &resolved, true)
717                    .await?
718            {
719                status.retained_memberships =
720                    apply_scoped_memberships(request, ctx, &resolved, ScopedPlanOperation::Revoke)
721                        .await?;
722                ctx.observability
723                    .record_ephemeral_retained_memberships(status.retained_memberships.len());
724                status.ended_at = Some(crate::crd::now_rfc3339());
725            }
726            transition_request(
727                request,
728                ctx,
729                &mut status,
730                EphemeralAccessRequestPhase::Cancelled,
731                if changed {
732                    "AccessPolicyChanged"
733                } else {
734                    "AccessPolicySuspended"
735                },
736            )
737            .await?;
738            return Ok(Action::await_change());
739        }
740        (mode, suspended)
741    } else {
742        (EphemeralApprovalMode::Automatic, false)
743    };
744
745    if status.phase == EphemeralAccessRequestPhase::PendingApproval {
746        let approval_deadline = status
747            .approval_expires_at
748            .as_deref()
749            .and_then(parse_timestamp)
750            .ok_or_else(|| EphemeralError::Invalid("invalid approval deadline".to_string()))?;
751        if now_epoch_secs() >= approval_deadline {
752            transition_request(
753                request,
754                ctx,
755                &mut status,
756                EphemeralAccessRequestPhase::ApprovalExpired,
757                "ApprovalExpired",
758            )
759            .await?;
760            return Ok(Action::await_change());
761        }
762        if suspended {
763            return Ok(Action::requeue(Duration::from_secs(
764                approval_deadline
765                    .saturating_sub(now_epoch_secs())
766                    .min(RETRY_DELAY.as_secs())
767                    .max(1),
768            )));
769        }
770        let Some(approved) = decision_condition(&status, "Approved") else {
771            return Ok(Action::requeue(Duration::from_secs(
772                approval_deadline
773                    .saturating_sub(now_epoch_secs())
774                    .min(RETRY_DELAY.as_secs())
775                    .max(1),
776            )));
777        };
778        if mode != EphemeralApprovalMode::Required
779            || approved.bundle_hash.as_deref() != Some(resolved.bundle_hash.as_str())
780            || approved.granted_duration.as_deref() != Some(resolved.granted_duration.as_str())
781        {
782            return Err(EphemeralError::Invalid(
783                "approval does not attest to the resolved bundle hash and duration".to_string(),
784            ));
785        }
786        persist_activation_deadline(&mut status, &resolved)?;
787        transition_request(
788            request,
789            ctx,
790            &mut status,
791            EphemeralAccessRequestPhase::Applying,
792            "ApprovedBundleApplying",
793        )
794        .await?;
795        return Ok(Action::requeue(Duration::ZERO));
796    }
797
798    if status.phase == EphemeralAccessRequestPhase::Applying
799        && mode == EphemeralApprovalMode::Required
800    {
801        let approved = decision_condition(&status, "Approved").ok_or_else(|| {
802            EphemeralError::Invalid(
803                "Required request cannot apply without Approved=True".to_string(),
804            )
805        })?;
806        if approved.bundle_hash.as_deref() != Some(resolved.bundle_hash.as_str())
807            || approved.granted_duration.as_deref() != Some(resolved.granted_duration.as_str())
808        {
809            return Err(EphemeralError::Invalid(
810                "approval does not attest to the resolved bundle hash and duration".to_string(),
811            ));
812        }
813    }
814
815    match status.phase {
816        EphemeralAccessRequestPhase::Applying => {
817            let expires_at = status
818                .expires_at
819                .as_deref()
820                .and_then(parse_timestamp)
821                .ok_or_else(|| EphemeralError::Invalid("invalid access expiry".to_string()))?;
822            if expires_at <= now_epoch_secs() {
823                ctx.observability
824                    .record_ephemeral_expiry_lag(Duration::from_secs(
825                        now_epoch_secs().saturating_sub(expires_at),
826                    ));
827                let activation_started =
828                    request_has_scoped_activation_plan(&ctx.kube_client, request, &resolved, true)
829                        .await?;
830                if activation_started {
831                    status.retained_memberships = apply_scoped_memberships(
832                        request,
833                        ctx,
834                        &resolved,
835                        ScopedPlanOperation::Revoke,
836                    )
837                    .await?;
838                    status.ended_at = Some(crate::crd::now_rfc3339());
839                }
840                transition_request(
841                    request,
842                    ctx,
843                    &mut status,
844                    if activation_started {
845                        EphemeralAccessRequestPhase::Ended
846                    } else {
847                        EphemeralAccessRequestPhase::Cancelled
848                    },
849                    if activation_started {
850                        "ExpiredDuringActivation"
851                    } else {
852                        "ExpiredBeforeActivation"
853                    },
854                )
855                .await?;
856                return Ok(Action::await_change());
857            }
858            apply_scoped_memberships(request, ctx, &resolved, ScopedPlanOperation::Activate)
859                .await?;
860            set_condition(
861                &mut status.conditions,
862                access_condition(
863                    "Applied",
864                    true,
865                    "MembershipsGranted",
866                    "Ephemeral memberships are active",
867                ),
868            );
869            transition_request(
870                request,
871                ctx,
872                &mut status,
873                EphemeralAccessRequestPhase::Active,
874                "MembershipsGranted",
875            )
876            .await?;
877            let expires_at = status
878                .expires_at
879                .as_deref()
880                .and_then(parse_timestamp)
881                .unwrap_or_else(now_epoch_secs);
882            Ok(Action::requeue(Duration::from_secs(
883                expires_at.saturating_sub(now_epoch_secs()).max(1),
884            )))
885        }
886        EphemeralAccessRequestPhase::Active => {
887            let expires_at = status
888                .expires_at
889                .as_deref()
890                .and_then(parse_timestamp)
891                .ok_or_else(|| EphemeralError::Invalid("invalid access expiry".to_string()))?;
892            if now_epoch_secs() < expires_at {
893                return Ok(Action::requeue(Duration::from_secs(
894                    expires_at.saturating_sub(now_epoch_secs()).max(1),
895                )));
896            }
897            transition_request(
898                request,
899                ctx,
900                &mut status,
901                EphemeralAccessRequestPhase::Revoking,
902                "AccessExpired",
903            )
904            .await?;
905            Ok(Action::requeue(Duration::ZERO))
906        }
907        EphemeralAccessRequestPhase::Revoking => {
908            if let Some(expires_at) = status.expires_at.as_deref().and_then(parse_timestamp) {
909                ctx.observability
910                    .record_ephemeral_expiry_lag(Duration::from_secs(
911                        now_epoch_secs().saturating_sub(expires_at),
912                    ));
913            }
914            let retained =
915                apply_scoped_memberships(request, ctx, &resolved, ScopedPlanOperation::Revoke)
916                    .await?;
917            status.retained_memberships = retained;
918            ctx.observability
919                .record_ephemeral_retained_memberships(status.retained_memberships.len());
920            status.ended_at = Some(crate::crd::now_rfc3339());
921            let reason = if status.retained_memberships.is_empty() {
922                "MembershipsRevoked"
923            } else {
924                "MembershipBecamePermanent"
925            };
926            transition_request(
927                request,
928                ctx,
929                &mut status,
930                EphemeralAccessRequestPhase::Ended,
931                reason,
932            )
933            .await?;
934            Ok(Action::await_change())
935        }
936        _ => Ok(Action::requeue(RETRY_DELAY)),
937    }
938}
939
940/// Stamp the resolved UIDs onto the request as labels, for server-side routing
941/// and `kubectl` inspection.
942///
943/// Best-effort by design. These labels are never consulted for authorization or
944/// ownership — every such decision reads the immutable UIDs in
945/// `status.resolvedAccess`, and the index is keyed from that same status rather
946/// than from the labels. Failing activation because a cosmetic patch was
947/// rejected would make a convenience feature load-bearing, so a failure is
948/// logged and the reconcile continues.
949async fn patch_request_index_labels(
950    request: &EphemeralAccessRequest,
951    ctx: &OperatorContext,
952    resolved: &ResolvedEphemeralAccess,
953) {
954    let namespace = request.namespace().unwrap_or_else(|| "default".to_string());
955    let api: Api<EphemeralAccessRequest> = Api::namespaced(ctx.kube_client.clone(), &namespace);
956    let patched = api
957        .patch(
958            &request.name_any(),
959            &PatchParams::apply("pgroles-operator"),
960            &Patch::Merge(serde_json::json!({
961                "metadata": {
962                    "labels": {
963                        (LABEL_ACCESS_POLICY_UID): resolved.access_policy_uid,
964                        (LABEL_TARGET_POLICY_UID): resolved.target_policy_uid,
965                    }
966                }
967            })),
968        )
969        .await;
970    if let Err(error) = patched {
971        tracing::warn!(
972            %error,
973            %namespace,
974            request = %request.name_any(),
975            "could not label request with resolved UIDs; routing falls back to the status index",
976        );
977    }
978}
979
980fn persist_activation_deadline(
981    status: &mut EphemeralAccessRequestStatus,
982    resolved: &ResolvedEphemeralAccess,
983) -> Result<(), EphemeralError> {
984    if status.activated_at.is_some() && status.expires_at.is_some() {
985        return Ok(());
986    }
987    let now = now_epoch_secs();
988    let duration = parse_duration(&resolved.granted_duration)?;
989    let expiry = now.checked_add(duration.as_secs()).ok_or_else(|| {
990        EphemeralError::Invalid("access expiry exceeds the supported range".to_string())
991    })?;
992    status.activated_at = Some(timestamp_from_epoch(now)?);
993    status.expires_at = Some(timestamp_from_epoch(expiry)?);
994    Ok(())
995}
996
997fn decision_condition<'a>(
998    status: &'a EphemeralAccessRequestStatus,
999    condition_type: &str,
1000) -> Option<&'a EphemeralAccessCondition> {
1001    status
1002        .conditions
1003        .iter()
1004        .find(|condition| condition.condition_type == condition_type && condition.status == "True")
1005}
1006
1007async fn resolve_request(
1008    request: &EphemeralAccessRequest,
1009    ctx: &OperatorContext,
1010) -> Result<(ResolvedEphemeralAccess, u64, EphemeralApprovalMode, bool), EphemeralError> {
1011    let namespace = request
1012        .namespace()
1013        .ok_or_else(|| EphemeralError::Invalid("resource has no namespace".to_string()))?;
1014    let policies: Api<EphemeralAccessPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
1015    let policy = policies.get(&request.spec.access_policy_ref.name).await?;
1016    let accepted = policy.status.as_ref().is_some_and(|status| {
1017        status.observed_generation == policy.metadata.generation
1018            && ["Accepted", "ResolvedRefs"].iter().all(|condition_type| {
1019                status.conditions.iter().any(|condition| {
1020                    condition.condition_type == *condition_type && condition.status == "True"
1021                })
1022            })
1023    });
1024    if !accepted {
1025        return Err(EphemeralError::Invalid(
1026            "access policy current generation is not Accepted and Resolved".to_string(),
1027        ));
1028    }
1029    if policy.spec.justification.required
1030        && request
1031            .spec
1032            .justification
1033            .as_deref()
1034            .is_none_or(|justification| justification.trim().is_empty())
1035    {
1036        return Err(EphemeralError::Invalid(
1037            "justification is required".to_string(),
1038        ));
1039    }
1040
1041    let target_api: Api<PostgresPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
1042    let target = target_api
1043        .get(&policy.spec.postgres_policy_ref.name)
1044        .await?;
1045    if target.spec.mode != PolicyMode::Apply {
1046        return Err(EphemeralError::Invalid(
1047            "target PostgresPolicy must be in apply mode".to_string(),
1048        ));
1049    }
1050    let expanded_target =
1051        pgroles_core::manifest::expand_manifest(&target.spec.to_policy_manifest())
1052            .map_err(ReconcileError::from)?;
1053    let target_roles: BTreeMap<_, _> = expanded_target
1054        .roles
1055        .iter()
1056        .map(|role| (role.name.as_str(), role))
1057        .collect();
1058    let mut unique_memberships = BTreeSet::new();
1059    for membership in &policy.spec.memberships {
1060        let Some(role) = target_roles.get(membership.role.as_str()) else {
1061            return Err(EphemeralError::Invalid(format!(
1062                "access policy role {} is not in the current expanded target policy",
1063                membership.role
1064            )));
1065        };
1066        if role.external {
1067            return Err(EphemeralError::Invalid(format!(
1068                "access policy role {} is externally managed",
1069                membership.role
1070            )));
1071        }
1072        if !unique_memberships.insert(membership.role.as_str()) {
1073            return Err(EphemeralError::Invalid(format!(
1074                "access policy role {} appears more than once",
1075                membership.role
1076            )));
1077        }
1078    }
1079    if !expanded_target
1080        .roles
1081        .iter()
1082        .any(|role| role.name == request.spec.subject.role)
1083    {
1084        return Err(EphemeralError::Invalid(format!(
1085            "subject role {} is not in the expanded target policy",
1086            request.spec.subject.role
1087        )));
1088    }
1089    let requested = match request
1090        .spec
1091        .requested_duration
1092        .as_ref()
1093        .or(policy.spec.default_duration.as_ref())
1094    {
1095        Some(duration) => parse_duration(duration)?,
1096        None => {
1097            return Err(EphemeralError::Invalid(
1098                "requestedDuration or defaultDuration is required".to_string(),
1099            ));
1100        }
1101    };
1102    if requested > parse_duration(&policy.spec.maximum_duration)? {
1103        return Err(EphemeralError::Invalid(
1104            "requested duration exceeds policy maximum".to_string(),
1105        ));
1106    }
1107
1108    let target_database_fingerprint = ctx
1109        .resolve_database_target_fingerprint(&namespace, &target.spec.connection)
1110        .await
1111        .map_err(Box::new)?;
1112    let memberships = policy
1113        .spec
1114        .memberships
1115        .iter()
1116        .map(|membership| ResolvedEphemeralMembership {
1117            role: membership.role.clone(),
1118            member: request.spec.subject.role.clone(),
1119            inherit: membership.inherit,
1120        })
1121        .collect::<Vec<_>>();
1122    let mut resolved = ResolvedEphemeralAccess {
1123        access_policy_uid: policy.uid().unwrap_or_default(),
1124        access_policy_generation: policy.metadata.generation.unwrap_or(0),
1125        target_policy_uid: target.uid().unwrap_or_default(),
1126        target_policy_generation: target.metadata.generation.unwrap_or(0),
1127        target_database_fingerprint,
1128        granted_duration: format_duration(requested),
1129        bundle_encoding: EPHEMERAL_BUNDLE_ENCODING_V1.to_string(),
1130        bundle_hash: String::new(),
1131        memberships,
1132    };
1133    resolved.bundle_hash = resolved.compute_bundle_hash();
1134    let pending_deadline = request
1135        .status
1136        .as_ref()
1137        .and_then(|status| status.approval_expires_at.as_deref())
1138        .and_then(parse_timestamp)
1139        .unwrap_or(now_epoch_secs() + parse_duration(&policy.spec.pending_request_ttl)?.as_secs());
1140    Ok((
1141        resolved,
1142        pending_deadline,
1143        policy.spec.approval.mode,
1144        policy.spec.suspend || target.spec.suspend,
1145    ))
1146}
1147
1148async fn patch_request_status(
1149    request: &EphemeralAccessRequest,
1150    ctx: &OperatorContext,
1151    status: &EphemeralAccessRequestStatus,
1152) -> Result<(), kube::Error> {
1153    let namespace = request.namespace().unwrap_or_else(|| "default".to_string());
1154    let api: Api<EphemeralAccessRequest> = Api::namespaced(ctx.kube_client.clone(), &namespace);
1155    let status_patch = request_status_patch_value(status);
1156    api.patch_status(
1157        &request.name_any(),
1158        &PatchParams::apply("pgroles-operator"),
1159        &Patch::Merge(serde_json::json!({
1160            "metadata": { "resourceVersion": request.resource_version() },
1161            "status": status_patch,
1162        })),
1163    )
1164    .await?;
1165    Ok(())
1166}
1167
1168fn request_status_patch_value(status: &EphemeralAccessRequestStatus) -> serde_json::Value {
1169    let mut status_patch = serde_json::json!(status);
1170    // Merge Patch treats an omitted field as "leave unchanged". The status
1171    // schema omits None values for clean reads, so explicitly send null when a
1172    // successful reconcile clears a previously surfaced validation error.
1173    if status.last_error.is_none()
1174        && let Some(fields) = status_patch.as_object_mut()
1175    {
1176        fields.insert("lastError".to_string(), serde_json::Value::Null);
1177    }
1178    status_patch
1179}
1180
1181async fn update_request_resolution_failure(
1182    request: &EphemeralAccessRequest,
1183    ctx: &OperatorContext,
1184    message: &str,
1185) -> Result<Action, EphemeralError> {
1186    let mut status = request.status.clone().unwrap_or_default();
1187    status.last_error = Some(truncate_utf8(message, 4096));
1188    let reason = if message.contains("suspended") {
1189        "Suspended"
1190    } else {
1191        "InvalidRequest"
1192    };
1193    set_condition(
1194        &mut status.conditions,
1195        access_condition("Resolved", false, reason, message),
1196    );
1197    patch_request_status(request, ctx, &status).await?;
1198    Ok(Action::requeue(Duration::from_secs(60)))
1199}
1200
1201async fn update_request_validation_error(
1202    request: &EphemeralAccessRequest,
1203    ctx: &OperatorContext,
1204    message: &str,
1205) -> Result<Action, EphemeralError> {
1206    let mut status = request.status.clone().unwrap_or_default();
1207    status.last_error = Some(truncate_utf8(message, 4096));
1208    set_condition(
1209        &mut status.conditions,
1210        access_condition("Ready", false, "InvalidRequestState", message),
1211    );
1212    patch_request_status(request, ctx, &status).await?;
1213    Ok(Action::requeue(Duration::from_secs(60)))
1214}
1215
1216async fn transition_request(
1217    request: &EphemeralAccessRequest,
1218    ctx: &OperatorContext,
1219    status: &mut EphemeralAccessRequestStatus,
1220    phase: EphemeralAccessRequestPhase,
1221    reason: &str,
1222) -> Result<(), EphemeralError> {
1223    let previous = status.phase;
1224    status.phase = phase;
1225    status.last_error = None;
1226    set_condition(
1227        &mut status.conditions,
1228        access_condition(
1229            "Ready",
1230            true,
1231            &phase.to_string(),
1232            "Request lifecycle is progressing normally",
1233        ),
1234    );
1235    patch_request_status(request, ctx, status).await?;
1236    audit_transition(request, ctx, Some(previous), status, reason);
1237    publish_request_event_best_effort(request, ctx, status, reason).await;
1238    Ok(())
1239}
1240
1241fn audit_transition(
1242    request: &EphemeralAccessRequest,
1243    ctx: &OperatorContext,
1244    previous: Option<EphemeralAccessRequestPhase>,
1245    status: &EphemeralAccessRequestStatus,
1246    reason: &str,
1247) {
1248    ctx.observability
1249        .record_ephemeral_transition(&status.phase.to_string(), reason);
1250    let resolved = status.resolved_access.as_ref();
1251    let decision = ["Approved", "Denied"]
1252        .into_iter()
1253        .find(|condition_type| decision_condition(status, condition_type).is_some())
1254        .unwrap_or("");
1255    let decided_by = status.decided_by.as_ref();
1256    tracing::info!(
1257        audit_event = "pgroles.ephemeral_access.lifecycle",
1258        request_name = %request.name_any(),
1259        request_uid = %request.uid().unwrap_or_default(),
1260        access_policy_uid = %resolved.map(|value| value.access_policy_uid.as_str()).unwrap_or(""),
1261        target_policy_uid = %resolved.map(|value| value.target_policy_uid.as_str()).unwrap_or(""),
1262        bundle_hash = %resolved.map(|value| value.bundle_hash.as_str()).unwrap_or(""),
1263        subject = %request.spec.subject.role,
1264        requester = %request.spec.requested_by.username,
1265        requester_uid = %request.spec.requested_by.uid.as_deref().unwrap_or(""),
1266        requester_groups = ?request.spec.requested_by.groups,
1267        decision,
1268        decision_maker = %decided_by.map(|actor| actor.username.as_str()).unwrap_or(""),
1269        decision_maker_uid = %decided_by.and_then(|actor| actor.uid.as_deref()).unwrap_or(""),
1270        decision_maker_groups = ?decided_by.map(|actor| actor.groups.as_slice()).unwrap_or(&[]),
1271        previous_phase = %previous.map(|value| value.to_string()).unwrap_or_default(),
1272        phase = %status.phase,
1273        reason,
1274        activated_at = %status.activated_at.as_deref().unwrap_or(""),
1275        expires_at = %status.expires_at.as_deref().unwrap_or(""),
1276        ended_at = %status.ended_at.as_deref().unwrap_or(""),
1277        "ephemeral access lifecycle transition"
1278    );
1279}
1280
1281async fn publish_request_event_best_effort(
1282    request: &EphemeralAccessRequest,
1283    ctx: &OperatorContext,
1284    status: &EphemeralAccessRequestStatus,
1285    reason: &str,
1286) {
1287    let note = format!(
1288        "Request {} entered {} ({reason})",
1289        request.name_any(),
1290        status.phase
1291    );
1292    if let Err(error) = crate::events::publish_ephemeral_request_event(
1293        &ctx.event_recorder,
1294        request,
1295        status.phase,
1296        reason,
1297        note,
1298    )
1299    .await
1300    {
1301        tracing::warn!(request = %request.name_any(), %error, "failed to publish ephemeral access Event");
1302    }
1303}
1304
1305async fn reconcile_access_request_cleanup(
1306    request: &EphemeralAccessRequest,
1307    ctx: &OperatorContext,
1308) -> Result<Action, EphemeralError> {
1309    if let Some(status) = request.status.as_ref()
1310        && let Some(resolved) = status.resolved_access.as_ref()
1311        && matches!(
1312            status.phase,
1313            EphemeralAccessRequestPhase::Applying
1314                | EphemeralAccessRequestPhase::Active
1315                | EphemeralAccessRequestPhase::Revoking
1316        )
1317    {
1318        if !resolved.has_valid_bundle_hash() {
1319            return Err(EphemeralError::Invalid(
1320                "refusing finalizer cleanup for a non-canonical resolved bundle".to_string(),
1321            ));
1322        }
1323        let activation_started =
1324            request_has_scoped_activation_plan(&ctx.kube_client, request, resolved, true).await?;
1325        let retained = if activation_started {
1326            apply_scoped_memberships(request, ctx, resolved, ScopedPlanOperation::Revoke).await?
1327        } else {
1328            tracing::warn!(
1329                request = %request.name_any(),
1330                request_uid = %request.uid().unwrap_or_default(),
1331                "request cleanup skipped SQL because no request-owned activation plan exists"
1332            );
1333            Vec::new()
1334        };
1335        ctx.observability
1336            .record_ephemeral_retained_memberships(retained.len());
1337        let mut ended = status.clone();
1338        ended.phase = EphemeralAccessRequestPhase::Revoked;
1339        ended.ended_at = Some(crate::crd::now_rfc3339());
1340        ended.retained_memberships = retained;
1341        // Deletion is the initial early-revocation API. This phase is emitted
1342        // to the durable audit stream but deliberately is not persisted to an
1343        // object whose finalizer is about to be removed.
1344        audit_transition(request, ctx, Some(status.phase), &ended, "RequestDeleted");
1345        publish_request_event_best_effort(request, ctx, &ended, "RequestDeleted").await;
1346    }
1347    Ok(Action::await_change())
1348}
1349
1350type MembershipKey = (String, String);
1351
1352fn membership_key(membership: &ResolvedEphemeralMembership) -> MembershipKey {
1353    (membership.role.clone(), membership.member.clone())
1354}
1355
1356fn graph_membership_key(membership: &pgroles_core::model::MembershipEdge) -> MembershipKey {
1357    (membership.role.clone(), membership.member.clone())
1358}
1359
1360/// Merge currently owned ephemeral edges into an ordinary policy's desired
1361/// graph. Callers must hold both the in-process and PostgreSQL advisory locks.
1362pub async fn compose_effective_graph(
1363    ctx: &OperatorContext,
1364    policy: &PostgresPolicy,
1365    desired: &mut pgroles_core::model::RoleGraph,
1366) -> Result<BTreeSet<String>, ReconcileError> {
1367    let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
1368    // An absent UID must not degrade to an empty-string lookup key. That key
1369    // matches nothing, so the overlay would come back empty and authoritative
1370    // reconciliation would revoke every active ephemeral membership as drift.
1371    // A persisted object always carries a UID, so failing here is strictly
1372    // better than silently composing an incomplete graph.
1373    let policy_uid = policy.uid().ok_or_else(|| {
1374        ReconcileError::InvalidSpec(
1375            "target policy has no metadata.uid; refusing to compose an ephemeral overlay that \
1376             could not be scoped to it"
1377                .to_string(),
1378        )
1379    })?;
1380    let indexed = ctx
1381        .request_index
1382        .for_target_policy_uid(&namespace, &policy_uid)
1383        .await?;
1384    ctx.observability
1385        .record_ephemeral_relevant_requests("effective_graph", indexed.len());
1386    let requests: Vec<_> = indexed
1387        .iter()
1388        .map(|request| request.as_ref().clone())
1389        .collect();
1390    let scoped_plans = list_scoped_plans(&ctx.kube_client, &namespace, &policy.name_any()).await?;
1391    let target_database_fingerprint = ctx
1392        .resolve_database_target_fingerprint(&namespace, &policy.spec.connection)
1393        .await
1394        .map_err(Box::new)?;
1395    compose_effective_graph_from_resources(
1396        ctx,
1397        policy,
1398        desired,
1399        &requests,
1400        &scoped_plans,
1401        &target_database_fingerprint,
1402    )
1403    .await
1404}
1405
1406async fn compose_effective_graph_from_resources(
1407    ctx: &OperatorContext,
1408    policy: &PostgresPolicy,
1409    desired: &mut pgroles_core::model::RoleGraph,
1410    requests: &[EphemeralAccessRequest],
1411    scoped_plans: &[PostgresPolicyPlan],
1412    target_database_fingerprint: &str,
1413) -> Result<BTreeSet<String>, ReconcileError> {
1414    let policy_uid = policy.uid().unwrap_or_default();
1415    let mut additional_roles = BTreeSet::new();
1416    let mut overlays: BTreeMap<MembershipKey, pgroles_core::model::MembershipEdge> =
1417        BTreeMap::new();
1418
1419    for request in requests {
1420        let Some(status) = request.status.as_ref() else {
1421            continue;
1422        };
1423        if !matches!(
1424            status.phase,
1425            EphemeralAccessRequestPhase::Applying | EphemeralAccessRequestPhase::Active
1426        ) {
1427            continue;
1428        }
1429        let Some(resolved) = status.resolved_access.as_ref() else {
1430            continue;
1431        };
1432        // Status is mutable control-plane data; a request-owned activation
1433        // plan is the durable provenance anchor proving the operator began
1434        // this exact bundle for this request UID. Require it for both Applying
1435        // and Active so a forged phase/snapshot can never enter the graph.
1436        if !scoped_plans
1437            .iter()
1438            .any(|plan| plan_authorizes_activation(plan, request, resolved, true))
1439        {
1440            continue;
1441        }
1442        if resolved.target_policy_uid != policy_uid || !resolved.has_valid_bundle_hash() {
1443            continue;
1444        }
1445        if resolved.target_database_fingerprint != target_database_fingerprint {
1446            return Err(ReconcileError::InvalidSpec(format!(
1447                "active ephemeral request {} ({}) targets a different resolved database; restore the original target before reconciliation",
1448                request.name_any(),
1449                request.uid().unwrap_or_default(),
1450            )));
1451        }
1452        for membership in &resolved.memberships {
1453            let missing_granted_role = !desired.roles.contains_key(&membership.role);
1454            let missing_subject = !desired.roles.contains_key(&membership.member);
1455            if missing_granted_role || missing_subject {
1456                let relationship = match (missing_granted_role, missing_subject) {
1457                    (true, true) => "granted role and subject",
1458                    (true, false) => "granted role",
1459                    (false, true) => "subject",
1460                    (false, false) => unreachable!(),
1461                };
1462                ctx.observability.record_ephemeral_role_retirement_blocked();
1463                set_role_retirement_blocked(
1464                    &ctx.kube_client,
1465                    request,
1466                    resolved,
1467                    membership,
1468                    relationship,
1469                )
1470                .await?;
1471                return Err(ReconcileError::InvalidSpec(format!(
1472                    "active ephemeral request {} ({}) blocks removal of {}: membership {} -> {}",
1473                    request.name_any(),
1474                    request.uid().unwrap_or_default(),
1475                    relationship,
1476                    membership.member,
1477                    membership.role
1478                )));
1479            }
1480            additional_roles.insert(membership.role.clone());
1481            additional_roles.insert(membership.member.clone());
1482            let edge = pgroles_core::model::MembershipEdge {
1483                role: membership.role.clone(),
1484                member: membership.member.clone(),
1485                inherit: membership.inherit,
1486                admin: false,
1487            };
1488            let key = membership_key(membership);
1489            if let Some(durable) = desired
1490                .memberships
1491                .iter()
1492                .find(|candidate| graph_membership_key(candidate) == key)
1493            {
1494                if durable.inherit != edge.inherit || durable.admin {
1495                    tracing::warn!(
1496                        role = %edge.role,
1497                        member = %edge.member,
1498                        "durable membership now owns an ephemeral key with different options"
1499                    );
1500                }
1501                continue;
1502            }
1503            if let Some(existing) = overlays.get(&key)
1504                && existing != &edge
1505            {
1506                return Err(ReconcileError::InvalidSpec(format!(
1507                    "active ephemeral requests conflict on membership {} -> {}",
1508                    edge.member, edge.role
1509                )));
1510            }
1511            overlays.insert(key, edge);
1512        }
1513    }
1514    clear_role_retirement_blocked(&ctx.kube_client, policy).await?;
1515    desired.memberships.extend(overlays.into_values());
1516    Ok(additional_roles)
1517}
1518
1519async fn list_scoped_plans(
1520    client: &kube::Client,
1521    namespace: &str,
1522    target_policy_name: &str,
1523) -> Result<Vec<PostgresPolicyPlan>, kube::Error> {
1524    let plans: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), namespace);
1525    let label_value = LabelValue::sanitize(target_policy_name).into_string();
1526    Ok(plans
1527        .list(&ListParams::default().labels(&format!("{LABEL_POLICY}={label_value}")))
1528        .await?
1529        .items)
1530}
1531
1532async fn set_role_retirement_blocked(
1533    client: &kube::Client,
1534    request: &EphemeralAccessRequest,
1535    resolved: &ResolvedEphemeralAccess,
1536    membership: &ResolvedEphemeralMembership,
1537    relationship: &str,
1538) -> Result<(), kube::Error> {
1539    let namespace = request.namespace().unwrap_or_else(|| "default".to_string());
1540    let policies: Api<EphemeralAccessPolicy> = Api::namespaced(client.clone(), &namespace);
1541    let Ok(policy) = policies.get(&request.spec.access_policy_ref.name).await else {
1542        return Ok(());
1543    };
1544    if policy.uid().as_deref() != Some(resolved.access_policy_uid.as_str()) {
1545        return Ok(());
1546    }
1547    let mut status = policy.status.clone().unwrap_or_default();
1548    let previous = status.conditions.clone();
1549    set_condition(
1550        &mut status.conditions,
1551        access_condition(
1552            "RoleRetirementBlocked",
1553            true,
1554            "ActiveRequest",
1555            &format!(
1556                "request {} ({}) references {} as {}; membership {} -> {}",
1557                request.name_any(),
1558                request.uid().unwrap_or_default(),
1559                if relationship == "subject" {
1560                    &membership.member
1561                } else {
1562                    &membership.role
1563                },
1564                relationship,
1565                membership.member,
1566                membership.role
1567            ),
1568        ),
1569    );
1570    if status.conditions != previous {
1571        patch_access_policy_status_with_client(&policy, client, &status).await?;
1572    }
1573    Ok(())
1574}
1575
1576async fn clear_role_retirement_blocked(
1577    client: &kube::Client,
1578    target: &PostgresPolicy,
1579) -> Result<(), kube::Error> {
1580    let namespace = target.namespace().unwrap_or_else(|| "default".to_string());
1581    let policies: Api<EphemeralAccessPolicy> = Api::namespaced(client.clone(), &namespace);
1582    for policy in policies.list(&ListParams::default()).await? {
1583        if policy.spec.postgres_policy_ref.name != target.name_any() {
1584            continue;
1585        }
1586        let Some(existing_status) = policy.status.as_ref() else {
1587            continue;
1588        };
1589        if !existing_status
1590            .conditions
1591            .iter()
1592            .any(|condition| condition.condition_type == "RoleRetirementBlocked")
1593        {
1594            continue;
1595        }
1596        let mut status = existing_status.clone();
1597        let previous = status.conditions.clone();
1598        set_condition(
1599            &mut status.conditions,
1600            access_condition(
1601                "RoleRetirementBlocked",
1602                false,
1603                "NoBlockingRequests",
1604                "No active request blocks role retirement",
1605            ),
1606        );
1607        if status.conditions != previous {
1608            patch_access_policy_status_with_client(&policy, client, &status).await?;
1609        }
1610    }
1611    Ok(())
1612}
1613
1614async fn apply_scoped_memberships(
1615    request: &EphemeralAccessRequest,
1616    ctx: &OperatorContext,
1617    resolved: &ResolvedEphemeralAccess,
1618    operation: ScopedPlanOperation,
1619) -> Result<Vec<ResolvedEphemeralMembership>, EphemeralError> {
1620    if !resolved.has_valid_bundle_hash() {
1621        return Err(EphemeralError::Invalid(
1622            "resolved bundle hash does not match its canonical payload".to_string(),
1623        ));
1624    }
1625    if operation == ScopedPlanOperation::Revoke
1626        && !request_has_scoped_activation_plan(&ctx.kube_client, request, resolved, true).await?
1627    {
1628        return Err(EphemeralError::Invalid(
1629            "refusing scoped revocation without a matching request-owned activation plan"
1630                .to_string(),
1631        ));
1632    }
1633    let namespace = request
1634        .namespace()
1635        .ok_or_else(|| EphemeralError::Invalid("resource has no namespace".to_string()))?;
1636    let access_policies: Api<EphemeralAccessPolicy> =
1637        Api::namespaced(ctx.kube_client.clone(), &namespace);
1638    let access_policy = access_policies
1639        .get(&request.spec.access_policy_ref.name)
1640        .await?;
1641    if access_policy.uid().as_deref() != Some(resolved.access_policy_uid.as_str()) {
1642        return Err(EphemeralError::Invalid(
1643            "access policy UID no longer matches resolved access".to_string(),
1644        ));
1645    }
1646    let policies: Api<PostgresPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
1647    let target = policies
1648        .get(&access_policy.spec.postgres_policy_ref.name)
1649        .await?;
1650    if target.uid().as_deref() != Some(resolved.target_policy_uid.as_str()) {
1651        return Err(EphemeralError::Invalid(
1652            "target policy UID no longer matches resolved access".to_string(),
1653        ));
1654    }
1655    let target_database_fingerprint = ctx
1656        .resolve_database_target_fingerprint(&namespace, &target.spec.connection)
1657        .await
1658        .map_err(Box::new)?;
1659    if target_database_fingerprint != resolved.target_database_fingerprint {
1660        return Err(EphemeralError::Invalid(
1661            "target database changed after request resolution; restore the original host, port, and database before activation or revocation"
1662                .to_string(),
1663        ));
1664    }
1665    let identity = DatabaseIdentity::from_connection(&namespace, &target.spec.connection);
1666    let pool = ctx
1667        .get_or_create_pool(&namespace, &target.spec.connection)
1668        .await
1669        .map_err(Box::new)?;
1670    let _database_lock = ctx
1671        .try_lock_database(identity.as_str())
1672        .await
1673        .ok_or_else(|| {
1674            ReconcileError::LockContention(
1675                identity.as_str().to_string(),
1676                "in-process lock held by another reconcile".to_string(),
1677            )
1678        })?;
1679    let advisory_lock = crate::advisory::try_acquire(&pool, identity.as_str())
1680        .await
1681        .map_err(ReconcileError::from)?
1682        .ok_or_else(|| {
1683            ReconcileError::LockContention(
1684                identity.as_str().to_string(),
1685                "PostgreSQL advisory lock held by another reconcile".to_string(),
1686            )
1687        })?;
1688
1689    let result = apply_scoped_memberships_under_lock(
1690        request,
1691        ctx,
1692        resolved,
1693        operation,
1694        &target,
1695        ScopedDatabase {
1696            pool: &pool,
1697            lock_identity: identity.as_str(),
1698            target_fingerprint: &target_database_fingerprint,
1699        },
1700    )
1701    .await;
1702    advisory_lock.release().await;
1703    result
1704}
1705
1706struct ScopedDatabase<'a> {
1707    pool: &'a sqlx::PgPool,
1708    lock_identity: &'a str,
1709    target_fingerprint: &'a str,
1710}
1711
1712async fn apply_scoped_memberships_under_lock(
1713    request: &EphemeralAccessRequest,
1714    ctx: &OperatorContext,
1715    resolved: &ResolvedEphemeralAccess,
1716    operation: ScopedPlanOperation,
1717    target: &PostgresPolicy,
1718    database: ScopedDatabase<'_>,
1719) -> Result<Vec<ResolvedEphemeralMembership>, EphemeralError> {
1720    let manifest = target.spec.to_policy_manifest();
1721    let expanded =
1722        pgroles_core::manifest::expand_manifest(&manifest).map_err(ReconcileError::from)?;
1723    let mut durable =
1724        pgroles_core::model::RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref())
1725            .map_err(ReconcileError::from)?;
1726    let durable_memberships = durable.memberships.clone();
1727    let namespace = target.namespace().ok_or(ReconcileError::NoNamespace)?;
1728    // Keep Kubernetes API calls bounded while the database locks are held:
1729    // one indexed request snapshot and one server-side narrowed plan LIST feed
1730    // every ownership and provenance check below.
1731    let indexed = ctx
1732        .request_index
1733        .for_target_policy_uid(&namespace, &resolved.target_policy_uid)
1734        .await?;
1735    ctx.observability
1736        .record_ephemeral_relevant_requests("scoped_membership", indexed.len());
1737    let requests: Vec<_> = indexed
1738        .iter()
1739        .map(|request| request.as_ref().clone())
1740        .collect();
1741    let scoped_plans = list_scoped_plans(&ctx.kube_client, &namespace, &target.name_any()).await?;
1742    let mut additional_roles = compose_effective_graph_from_resources(
1743        ctx,
1744        target,
1745        &mut durable,
1746        &requests,
1747        &scoped_plans,
1748        database.target_fingerprint,
1749    )
1750    .await?;
1751    for membership in &resolved.memberships {
1752        additional_roles.insert(membership.role.clone());
1753        additional_roles.insert(membership.member.clone());
1754    }
1755    let has_database_grants = expanded
1756        .grants
1757        .iter()
1758        .any(|grant| grant.object.object_type == pgroles_core::manifest::ObjectType::Database);
1759    let inspect_config =
1760        pgroles_inspect::InspectConfig::from_expanded(&expanded, has_database_grants)
1761            .with_additional_roles(additional_roles);
1762    let inspection = pgroles_inspect::inspect_with_diagnostics(database.pool, &inspect_config)
1763        .await
1764        .map_err(ReconcileError::from)?;
1765    let current = inspection.graph;
1766
1767    let request_keys: BTreeSet<_> = resolved.memberships.iter().map(membership_key).collect();
1768    let current_by_key: BTreeMap<_, _> = current
1769        .memberships
1770        .iter()
1771        .map(|edge| (graph_membership_key(edge), edge))
1772        .collect();
1773    let durable_by_key: BTreeMap<_, _> = durable_memberships
1774        .iter()
1775        .map(|edge| (graph_membership_key(edge), edge))
1776        .collect();
1777    let other_owners = active_owner_keys_from_resources(
1778        target,
1779        &requests,
1780        &scoped_plans,
1781        Some(request.uid().unwrap_or_default().as_str()),
1782    );
1783    let request_has_activation_plan = scoped_plans
1784        .iter()
1785        .any(|plan| plan_authorizes_activation(plan, request, resolved, true));
1786
1787    let mut changes = Vec::new();
1788    let mut retained = Vec::new();
1789    match operation {
1790        ScopedPlanOperation::Activate => {
1791            for membership in &resolved.memberships {
1792                let key = membership_key(membership);
1793                if !current.roles.contains_key(&membership.role)
1794                    || !current.roles.contains_key(&membership.member)
1795                {
1796                    return Err(EphemeralError::Invalid(format!(
1797                        "membership roles {} and {} must already exist in PostgreSQL",
1798                        membership.member, membership.role
1799                    )));
1800                }
1801                if durable_by_key.contains_key(&key) {
1802                    return Err(EphemeralError::Invalid(format!(
1803                        "membership {} -> {} is already durable",
1804                        membership.member, membership.role
1805                    )));
1806                }
1807                if let Some(existing) = current_by_key.get(&key) {
1808                    if (other_owners.contains(&key) || request_has_activation_plan)
1809                        && existing.inherit == membership.inherit
1810                        && !existing.admin
1811                    {
1812                        continue;
1813                    }
1814                    return Err(EphemeralError::Invalid(format!(
1815                        "membership {} -> {} already exists without matching ephemeral ownership",
1816                        membership.member, membership.role
1817                    )));
1818                }
1819                changes.push(pgroles_core::diff::Change::AddMember {
1820                    role: membership.role.clone(),
1821                    member: membership.member.clone(),
1822                    inherit: membership.inherit,
1823                    admin: false,
1824                });
1825            }
1826        }
1827        ScopedPlanOperation::Revoke => {
1828            for membership in &resolved.memberships {
1829                let key = membership_key(membership);
1830                if durable_by_key.contains_key(&key) {
1831                    retained.push(membership.clone());
1832                    continue;
1833                }
1834                if other_owners.contains(&key) {
1835                    continue;
1836                }
1837                if current_by_key.contains_key(&key) {
1838                    changes.push(pgroles_core::diff::Change::RemoveMember {
1839                        role: membership.role.clone(),
1840                        member: membership.member.clone(),
1841                    });
1842                }
1843            }
1844        }
1845    }
1846
1847    for change in &changes {
1848        let key = match change {
1849            pgroles_core::diff::Change::AddMember { role, member, .. }
1850            | pgroles_core::diff::Change::RemoveMember { role, member } => {
1851                (role.clone(), member.clone())
1852            }
1853            _ => {
1854                return Err(EphemeralError::Invalid(
1855                    "scoped plan contained a non-membership change".to_string(),
1856                ));
1857            }
1858        };
1859        if !request_keys.contains(&key) {
1860            return Err(EphemeralError::Invalid(
1861                "scoped plan escaped the approved bundle".to_string(),
1862            ));
1863        }
1864    }
1865
1866    let sql_context = crate::reconciler::detect_sql_context(database.pool, &inspect_config).await?;
1867    if operation == ScopedPlanOperation::Activate {
1868        validate_membership_semantics(&sql_context, &current, resolved)?;
1869    }
1870    let plan = create_scoped_plan(
1871        request,
1872        ctx,
1873        target,
1874        resolved,
1875        operation,
1876        ScopedPlanExecution {
1877            database_identity: database.lock_identity,
1878            changes: &changes,
1879            sql_context: &sql_context,
1880        },
1881    )
1882    .await?;
1883    if plan
1884        .status
1885        .as_ref()
1886        .is_some_and(|status| status.phase == PlanPhase::Applied)
1887    {
1888        return Ok(retained);
1889    }
1890    match crate::plan::execute_changes_in_transaction(database.pool, &changes, &sql_context).await {
1891        Ok(statements) => {
1892            finish_scoped_plan(ctx, &plan, PlanPhase::Applied, None).await?;
1893            tracing::info!(
1894                request_uid = %request.uid().unwrap_or_default(),
1895                bundle_hash = %resolved.bundle_hash,
1896                operation = ?operation,
1897                statements,
1898                "scoped ephemeral membership plan applied"
1899            );
1900        }
1901        Err(error) => {
1902            finish_scoped_plan(ctx, &plan, PlanPhase::Failed, Some(error.to_string())).await?;
1903            return Err(error.into());
1904        }
1905    }
1906    Ok(retained)
1907}
1908
1909fn active_owner_keys_from_resources(
1910    target: &PostgresPolicy,
1911    requests: &[EphemeralAccessRequest],
1912    scoped_plans: &[PostgresPolicyPlan],
1913    exclude_request_uid: Option<&str>,
1914) -> BTreeSet<MembershipKey> {
1915    let target_uid = target.uid().unwrap_or_default();
1916    let mut keys = BTreeSet::new();
1917    for request in requests {
1918        if exclude_request_uid.is_some_and(|uid| request.uid().as_deref() == Some(uid)) {
1919            continue;
1920        }
1921        let Some(status) = request.status.as_ref() else {
1922            continue;
1923        };
1924        if !matches!(
1925            status.phase,
1926            EphemeralAccessRequestPhase::Applying | EphemeralAccessRequestPhase::Active
1927        ) {
1928            continue;
1929        }
1930        let Some(resolved) = status.resolved_access.as_ref() else {
1931            continue;
1932        };
1933        if !scoped_plans
1934            .iter()
1935            .any(|plan| plan_authorizes_activation(plan, request, resolved, true))
1936        {
1937            continue;
1938        }
1939        if resolved.target_policy_uid == target_uid && resolved.has_valid_bundle_hash() {
1940            keys.extend(resolved.memberships.iter().map(membership_key));
1941        }
1942    }
1943    keys
1944}
1945
1946fn validate_membership_semantics(
1947    sql_context: &pgroles_core::sql::SqlContext,
1948    current: &pgroles_core::model::RoleGraph,
1949    resolved: &ResolvedEphemeralAccess,
1950) -> Result<(), EphemeralError> {
1951    if sql_context.supports_grant_with_options() {
1952        return Ok(());
1953    }
1954    for membership in &resolved.memberships {
1955        let subject = current.roles.get(&membership.member).ok_or_else(|| {
1956            EphemeralError::Invalid(format!(
1957                "subject role {} is not present in PostgreSQL",
1958                membership.member
1959            ))
1960        })?;
1961        if subject.inherit != membership.inherit {
1962            return Err(EphemeralError::Invalid(format!(
1963                "PostgreSQL {} cannot encode per-membership INHERIT {}; subject role {} has global INHERIT {}",
1964                sql_context.pg_major_version,
1965                membership.inherit,
1966                membership.member,
1967                subject.inherit,
1968            )));
1969        }
1970    }
1971    Ok(())
1972}
1973
1974fn plan_authorizes_activation(
1975    plan: &PostgresPolicyPlan,
1976    request: &EphemeralAccessRequest,
1977    resolved: &ResolvedEphemeralAccess,
1978    include_applying: bool,
1979) -> bool {
1980    let request_uid = request.uid().unwrap_or_default();
1981    let owned_by_request = plan
1982        .metadata
1983        .owner_references
1984        .as_ref()
1985        .is_some_and(|owners| {
1986            owners.iter().any(|owner| {
1987                owner.controller == Some(true)
1988                    && owner.kind == "EphemeralAccessRequest"
1989                    && owner.uid == request_uid
1990            })
1991        });
1992    let matching_origin =
1993        plan.spec.origin.as_ref().is_some_and(|origin| {
1994            origin.kind == "EphemeralAccessRequest" && origin.uid == request_uid
1995        });
1996    let matching_scope = plan.spec.scope.as_ref().is_some_and(|scope| {
1997        scope.operation == ScopedPlanOperation::Activate
1998            && scope.bundle_hash == resolved.bundle_hash
1999    });
2000    let usable_phase = plan.status.as_ref().map_or(include_applying, |status| {
2001        status.phase == PlanPhase::Applied
2002            || (include_applying && status.phase == PlanPhase::Applying)
2003    });
2004    owned_by_request && matching_origin && matching_scope && usable_phase
2005}
2006
2007async fn request_has_scoped_activation_plan(
2008    client: &kube::Client,
2009    request: &EphemeralAccessRequest,
2010    resolved: &ResolvedEphemeralAccess,
2011    include_applying: bool,
2012) -> Result<bool, kube::Error> {
2013    let namespace = request.namespace().unwrap_or_else(|| "default".to_string());
2014    let plans: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
2015    Ok(plans
2016        .list(&ListParams::default())
2017        .await?
2018        .items
2019        .iter()
2020        .any(|plan| plan_authorizes_activation(plan, request, resolved, include_applying)))
2021}
2022
2023struct ScopedPlanExecution<'a> {
2024    database_identity: &'a str,
2025    changes: &'a [pgroles_core::diff::Change],
2026    sql_context: &'a pgroles_core::sql::SqlContext,
2027}
2028
2029async fn create_scoped_plan(
2030    request: &EphemeralAccessRequest,
2031    ctx: &OperatorContext,
2032    target: &PostgresPolicy,
2033    resolved: &ResolvedEphemeralAccess,
2034    operation: ScopedPlanOperation,
2035    execution: ScopedPlanExecution<'_>,
2036) -> Result<PostgresPolicyPlan, EphemeralError> {
2037    let namespace = request.namespace().unwrap_or_else(|| "default".to_string());
2038    let operation_name = match operation {
2039        ScopedPlanOperation::Activate => "activate",
2040        ScopedPlanOperation::Revoke => "revoke",
2041    };
2042    let bundle_digest = resolved
2043        .bundle_hash
2044        .strip_prefix("sha256:")
2045        .ok_or_else(|| {
2046            EphemeralError::Invalid("bundle hash must use the sha256 encoding".to_string())
2047        })?;
2048    let suffix = bundle_digest.get(..12).ok_or_else(|| {
2049        EphemeralError::Invalid("bundle hash is too short for a scoped plan name".to_string())
2050    })?;
2051    let request_uid = request.uid().filter(|uid| !uid.is_empty()).ok_or_else(|| {
2052        EphemeralError::Invalid("request has no UID for scoped plan ownership".to_string())
2053    })?;
2054    let uid_suffix = request_uid
2055        .chars()
2056        .filter(|character| character.is_ascii_alphanumeric())
2057        .take(8)
2058        .collect::<String>();
2059    if uid_suffix.len() != 8 {
2060        return Err(EphemeralError::Invalid(
2061            "request UID is too short for a scoped plan name".to_string(),
2062        ));
2063    }
2064    let prefix = crate::k8s_names::sanitize_dns_label_segment(&request.name_any(), "request");
2065    let max_prefix =
2066        253usize.saturating_sub(uid_suffix.len() + operation_name.len() + suffix.len() + 3);
2067    let prefix = crate::k8s_names::truncate_name_prefix(&prefix, max_prefix);
2068    let name = format!("{prefix}-{uid_suffix}-{operation_name}-{suffix}");
2069    let mut plan = PostgresPolicyPlan::new(
2070        &name,
2071        PostgresPolicyPlanSpec {
2072            policy_ref: PolicyPlanRef {
2073                name: target.name_any(),
2074            },
2075            policy_generation: target.metadata.generation.unwrap_or(0),
2076            reconciliation_mode: target.spec.reconciliation_mode,
2077            owned_roles: resolved
2078                .memberships
2079                .iter()
2080                .flat_map(|membership| [membership.role.clone(), membership.member.clone()])
2081                .collect(),
2082            owned_schemas: Vec::new(),
2083            managed_database_identity: execution.database_identity.to_string(),
2084            origin: Some(PlanOrigin {
2085                kind: "EphemeralAccessRequest".to_string(),
2086                name: request.name_any(),
2087                uid: request.uid().unwrap_or_default(),
2088            }),
2089            scope: Some(PlanScope {
2090                kind: "MembershipBundle".to_string(),
2091                operation,
2092                bundle_hash: resolved.bundle_hash.clone(),
2093            }),
2094        },
2095    );
2096    plan.metadata.namespace = Some(namespace.clone());
2097    plan.metadata.owner_references = request.controller_owner_ref(&()).map(|owner| vec![owner]);
2098    plan.metadata.labels = Some(BTreeMap::from([(
2099        LABEL_POLICY.to_string(),
2100        LabelValue::sanitize(&target.name_any()).into_string(),
2101    )]));
2102    let initial_status = PostgresPolicyPlanStatus {
2103        phase: PlanPhase::Applying,
2104        conditions: vec![PolicyCondition {
2105            condition_type: "Scoped".to_string(),
2106            status: "True".to_string(),
2107            reason: Some(operation_name.to_string()),
2108            message: Some("Authorized by EphemeralAccessRequest lifecycle".to_string()),
2109            last_transition_time: Some(crate::crd::now_rfc3339()),
2110        }],
2111        change_summary: Some(scoped_change_summary(execution.changes)),
2112        sql_inline: Some(pgroles_core::sql::render_all_with_context(
2113            execution.changes,
2114            execution.sql_context,
2115        )),
2116        computed_at: Some(crate::crd::now_rfc3339()),
2117        applying_since: Some(crate::crd::now_rfc3339()),
2118        sql_hash: Some(crate::plan::compute_sql_hash(
2119            &pgroles_core::sql::render_all_with_context(execution.changes, execution.sql_context),
2120        )),
2121        sql_statements: Some(execution.changes.len() as i64),
2122        ..Default::default()
2123    };
2124    let plans: Api<PostgresPolicyPlan> = Api::namespaced(ctx.kube_client.clone(), &namespace);
2125    match plans.create(&PostParams::default(), &plan).await {
2126        Ok(created) => Ok(plans
2127            .patch_status(
2128                &created.name_any(),
2129                &PatchParams::apply("pgroles-operator"),
2130                &Patch::Merge(serde_json::json!({ "status": initial_status })),
2131            )
2132            .await?),
2133        Err(kube::Error::Api(error)) if error.code == 409 => {
2134            let existing = plans.get(&name).await?;
2135            validate_existing_scoped_plan(
2136                &existing,
2137                request,
2138                &target.name_any(),
2139                resolved,
2140                operation,
2141                execution.database_identity,
2142            )?;
2143            Ok(existing)
2144        }
2145        Err(error) => Err(error.into()),
2146    }
2147}
2148
2149fn validate_existing_scoped_plan(
2150    plan: &PostgresPolicyPlan,
2151    request: &EphemeralAccessRequest,
2152    target_name: &str,
2153    resolved: &ResolvedEphemeralAccess,
2154    operation: ScopedPlanOperation,
2155    database_identity: &str,
2156) -> Result<(), EphemeralError> {
2157    let request_uid = request.uid().unwrap_or_default();
2158    let owned_by_request = plan
2159        .metadata
2160        .owner_references
2161        .as_deref()
2162        .unwrap_or_default()
2163        .iter()
2164        .any(|owner| {
2165            owner.controller == Some(true)
2166                && owner.kind == "EphemeralAccessRequest"
2167                && owner.uid == request_uid
2168        });
2169    let origin_matches = plan.spec.origin.as_ref().is_some_and(|origin| {
2170        origin.kind == "EphemeralAccessRequest"
2171            && origin.name == request.name_any()
2172            && origin.uid == request_uid
2173    });
2174    let scope_matches = plan.spec.scope.as_ref().is_some_and(|scope| {
2175        scope.kind == "MembershipBundle"
2176            && scope.operation == operation
2177            && scope.bundle_hash == resolved.bundle_hash
2178    });
2179    if !owned_by_request
2180        || !origin_matches
2181        || !scope_matches
2182        || plan.spec.policy_ref.name != target_name
2183        || plan.spec.managed_database_identity != database_identity
2184    {
2185        return Err(EphemeralError::Invalid(format!(
2186            "existing scoped plan {} does not belong to request UID {} and its resolved scope",
2187            plan.name_any(),
2188            request_uid
2189        )));
2190    }
2191    Ok(())
2192}
2193
2194fn scoped_change_summary(changes: &[pgroles_core::diff::Change]) -> ChangeSummary {
2195    let mut summary = ChangeSummary::default();
2196    for change in changes {
2197        match change {
2198            pgroles_core::diff::Change::AddMember { .. } => summary.members_added += 1,
2199            pgroles_core::diff::Change::RemoveMember { .. } => summary.members_removed += 1,
2200            _ => {}
2201        }
2202    }
2203    summary.total = changes.len() as i32;
2204    summary
2205}
2206
2207async fn finish_scoped_plan(
2208    ctx: &OperatorContext,
2209    plan: &PostgresPolicyPlan,
2210    phase: PlanPhase,
2211    error: Option<String>,
2212) -> Result<(), kube::Error> {
2213    let namespace = plan.namespace().unwrap_or_else(|| "default".to_string());
2214    let plans: Api<PostgresPolicyPlan> = Api::namespaced(ctx.kube_client.clone(), &namespace);
2215    let mut status = plan.status.clone().unwrap_or_default();
2216    status.phase = phase.clone();
2217    status.last_error = error;
2218    if phase == PlanPhase::Applied {
2219        status.applied_at = Some(crate::crd::now_rfc3339());
2220    } else if phase == PlanPhase::Failed {
2221        status.failed_at = Some(crate::crd::now_rfc3339());
2222    }
2223    plans
2224        .patch_status(
2225            &plan.name_any(),
2226            &PatchParams::apply("pgroles-operator"),
2227            &Patch::Merge(serde_json::json!({ "status": status })),
2228        )
2229        .await?;
2230    Ok(())
2231}
2232
2233#[cfg(test)]
2234mod tests {
2235    use super::*;
2236
2237    #[test]
2238    fn duration_parser_accepts_compound_values_and_canonicalizes() {
2239        let duration = parse_duration("1h30m15s").expect("duration should parse");
2240        assert_eq!(duration, Duration::from_secs(5_415));
2241        assert_eq!(format_duration(duration), "5415s");
2242    }
2243
2244    #[test]
2245    fn duration_parser_rejects_zero_and_unknown_units() {
2246        assert!(parse_duration("0s").is_err());
2247        assert!(parse_duration("5d").is_err());
2248        assert!(parse_duration("").is_err());
2249        assert!(parse_duration("18446744073709551615h").is_err());
2250        assert!(parse_duration("30").is_err());
2251    }
2252
2253    #[test]
2254    fn request_status_patch_explicitly_clears_last_error() {
2255        let mut status = EphemeralAccessRequestStatus {
2256            last_error: Some("invalid request".to_string()),
2257            ..Default::default()
2258        };
2259        assert_eq!(
2260            request_status_patch_value(&status)["lastError"],
2261            "invalid request"
2262        );
2263
2264        status.last_error = None;
2265        assert!(request_status_patch_value(&status)["lastError"].is_null());
2266    }
2267
2268    #[test]
2269    fn ephemeral_lock_retry_uses_short_bounded_jitter() {
2270        for _ in 0..32 {
2271            let delay = ephemeral_lock_retry_delay();
2272            assert!(delay >= Duration::from_millis(750));
2273            assert!(delay <= Duration::from_millis(3_000));
2274        }
2275    }
2276
2277    #[test]
2278    fn timestamp_round_trips_epoch_seconds() {
2279        let timestamp = timestamp_from_epoch(1_735_689_845).expect("valid timestamp");
2280        assert_eq!(parse_timestamp(&timestamp), Some(1_735_689_845));
2281    }
2282
2283    fn membership_semantics_fixture(inherit: bool) -> ResolvedEphemeralAccess {
2284        ResolvedEphemeralAccess {
2285            access_policy_uid: "access-uid".into(),
2286            access_policy_generation: 1,
2287            target_policy_uid: "target-uid".into(),
2288            target_policy_generation: 1,
2289            target_database_fingerprint: "sha256:database".into(),
2290            granted_duration: "30s".into(),
2291            bundle_encoding: crate::crd::EPHEMERAL_BUNDLE_ENCODING_V1.into(),
2292            bundle_hash: String::new(),
2293            memberships: vec![ResolvedEphemeralMembership {
2294                role: "editor".into(),
2295                member: "alice".into(),
2296                inherit,
2297            }],
2298        }
2299    }
2300
2301    #[test]
2302    fn postgres_15_rejects_unrepresentable_membership_inherit() {
2303        let mut current = pgroles_core::model::RoleGraph::default();
2304        current.roles.insert(
2305            "alice".into(),
2306            pgroles_core::model::RoleState {
2307                inherit: true,
2308                ..Default::default()
2309            },
2310        );
2311        let resolved = membership_semantics_fixture(false);
2312        let context = pgroles_core::sql::SqlContext::from_version_num(150_000);
2313
2314        let error = validate_membership_semantics(&context, &current, &resolved)
2315            .expect_err("PG15 cannot represent per-membership NOINHERIT");
2316        assert!(error.to_string().contains("global INHERIT true"));
2317    }
2318
2319    #[test]
2320    fn postgres_15_accepts_matching_global_inherit_and_pg16_accepts_either() {
2321        let mut current = pgroles_core::model::RoleGraph::default();
2322        current.roles.insert(
2323            "alice".into(),
2324            pgroles_core::model::RoleState {
2325                inherit: false,
2326                ..Default::default()
2327            },
2328        );
2329        let noinherit = membership_semantics_fixture(false);
2330        assert!(
2331            validate_membership_semantics(
2332                &pgroles_core::sql::SqlContext::from_version_num(150_000),
2333                &current,
2334                &noinherit,
2335            )
2336            .is_ok()
2337        );
2338        assert!(
2339            validate_membership_semantics(
2340                &pgroles_core::sql::SqlContext::from_version_num(160_000),
2341                &current,
2342                &membership_semantics_fixture(true),
2343            )
2344            .is_ok()
2345        );
2346    }
2347
2348    #[test]
2349    fn setting_lifecycle_condition_preserves_decision_condition() {
2350        let mut conditions = vec![EphemeralAccessCondition {
2351            condition_type: "Approved".into(),
2352            status: "True".into(),
2353            reason: Some("ApprovedByTest".into()),
2354            message: None,
2355            last_transition_time: Some("2026-08-11T00:00:00Z".into()),
2356            bundle_hash: Some("sha256:test".into()),
2357            granted_duration: Some("1800s".into()),
2358        }];
2359        set_condition(
2360            &mut conditions,
2361            access_condition("Applied", true, "Applied", "done"),
2362        );
2363        assert!(
2364            conditions
2365                .iter()
2366                .any(|condition| condition.condition_type == "Approved")
2367        );
2368        assert!(
2369            conditions
2370                .iter()
2371                .any(|condition| condition.condition_type == "Applied")
2372        );
2373    }
2374
2375    #[test]
2376    fn applying_overlay_requires_request_owned_activation_plan() {
2377        let mut request = EphemeralAccessRequest::new(
2378            "request",
2379            crate::crd::EphemeralAccessRequestSpec {
2380                access_policy_ref: crate::crd::LocalObjectReference {
2381                    name: "access".into(),
2382                },
2383                subject: crate::crd::EphemeralAccessSubject {
2384                    role: "alice".into(),
2385                },
2386                requested_by: crate::crd::EphemeralAccessActor {
2387                    username: "requester@example.com".into(),
2388                    uid: Some("requester-uid".into()),
2389                    groups: vec!["developers".into()],
2390                },
2391                requested_duration: Some("30m".into()),
2392                justification: Some("test".into()),
2393            },
2394        );
2395        request.metadata.namespace = Some("default".into());
2396        request.metadata.uid = Some("request-uid".into());
2397        let resolved = ResolvedEphemeralAccess {
2398            access_policy_uid: "access-uid".into(),
2399            access_policy_generation: 1,
2400            target_policy_uid: "target-uid".into(),
2401            target_policy_generation: 1,
2402            target_database_fingerprint: "sha256:database".into(),
2403            granted_duration: "1800s".into(),
2404            bundle_encoding: crate::crd::EPHEMERAL_BUNDLE_ENCODING_V1.into(),
2405            bundle_hash: "sha256:bundle".into(),
2406            memberships: Vec::new(),
2407        };
2408        let mut plan = PostgresPolicyPlan::new(
2409            "activation",
2410            PostgresPolicyPlanSpec {
2411                policy_ref: PolicyPlanRef {
2412                    name: "target".into(),
2413                },
2414                policy_generation: 1,
2415                reconciliation_mode: crate::crd::CrdReconciliationMode::Authoritative,
2416                owned_roles: Vec::new(),
2417                owned_schemas: Vec::new(),
2418                managed_database_identity: "default/database".into(),
2419                origin: Some(PlanOrigin {
2420                    kind: "EphemeralAccessRequest".into(),
2421                    name: "request".into(),
2422                    uid: "request-uid".into(),
2423                }),
2424                scope: Some(PlanScope {
2425                    kind: "MembershipBundle".into(),
2426                    operation: ScopedPlanOperation::Activate,
2427                    bundle_hash: "sha256:bundle".into(),
2428                }),
2429            },
2430        );
2431
2432        assert!(!plan_authorizes_activation(
2433            &plan, &request, &resolved, true
2434        ));
2435        plan.metadata.owner_references = request.controller_owner_ref(&()).map(|owner| vec![owner]);
2436        assert!(plan_authorizes_activation(&plan, &request, &resolved, true));
2437        assert!(
2438            validate_existing_scoped_plan(
2439                &plan,
2440                &request,
2441                "target",
2442                &resolved,
2443                ScopedPlanOperation::Activate,
2444                "default/database",
2445            )
2446            .is_ok()
2447        );
2448        plan.spec.origin.as_mut().expect("origin").uid = "stale-request-uid".into();
2449        assert!(
2450            validate_existing_scoped_plan(
2451                &plan,
2452                &request,
2453                "target",
2454                &resolved,
2455                ScopedPlanOperation::Activate,
2456                "default/database",
2457            )
2458            .is_err()
2459        );
2460        plan.spec.origin.as_mut().expect("origin").uid = "request-uid".into();
2461        assert!(!plan_authorizes_activation(
2462            &plan, &request, &resolved, false
2463        ));
2464
2465        plan.status = Some(PostgresPolicyPlanStatus {
2466            phase: PlanPhase::Applied,
2467            ..Default::default()
2468        });
2469        assert!(plan_authorizes_activation(
2470            &plan, &request, &resolved, false
2471        ));
2472        plan.spec.scope.as_mut().expect("scope").bundle_hash = "sha256:other".into();
2473        assert!(!plan_authorizes_activation(
2474            &plan, &request, &resolved, true
2475        ));
2476    }
2477}