1use 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
35pub(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 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
940async 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 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 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
1360pub 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 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 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 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, ¤t, 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(×tamp), 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, ¤t, &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 ¤t,
2334 &noinherit,
2335 )
2336 .is_ok()
2337 );
2338 assert!(
2339 validate_membership_semantics(
2340 &pgroles_core::sql::SqlContext::from_version_num(160_000),
2341 ¤t,
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}