Skip to main content

pgroles_core/
manifest.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5
6// ---------------------------------------------------------------------------
7// Errors
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Error)]
11pub enum ManifestError {
12    #[error("YAML parse error: {0}")]
13    Yaml(#[from] serde_yaml::Error),
14
15    #[error("duplicate role name: \"{0}\"")]
16    DuplicateRole(String),
17
18    #[error("profile \"{0}\" referenced by schema \"{1}\" is not defined")]
19    UndefinedProfile(String, String),
20
21    #[error("role_pattern must contain {{profile}} placeholder, got: \"{0}\"")]
22    InvalidRolePattern(String),
23
24    #[error("top-level default privilege for schema \"{schema}\" must specify grant.role")]
25    MissingDefaultPrivilegeRole { schema: String },
26
27    #[error("duplicate retirement entry for role: \"{0}\"")]
28    DuplicateRetirement(String),
29
30    #[error("retirement entry for role \"{0}\" conflicts with a desired role of the same name")]
31    RetirementRoleStillDesired(String),
32
33    #[error("retirement entry for role \"{role}\" cannot reassign ownership to itself")]
34    RetirementSelfReassign { role: String },
35
36    #[error(
37        "role \"{role}\" has a password but login is not enabled — password will have no effect"
38    )]
39    PasswordWithoutLogin { role: String },
40
41    #[error(
42        "role \"{role}\" has an invalid password_valid_until value \"{value}\": expected ISO 8601 timestamp (e.g. \"2025-12-31T00:00:00Z\")"
43    )]
44    InvalidValidUntil { role: String, value: String },
45}
46
47// ---------------------------------------------------------------------------
48// Enums
49// ---------------------------------------------------------------------------
50
51/// PostgreSQL object types that can have privileges granted on them.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum ObjectType {
55    Table,
56    View,
57    #[serde(alias = "materialized_view")]
58    MaterializedView,
59    Sequence,
60    Function,
61    Schema,
62    Database,
63    Type,
64}
65
66impl std::fmt::Display for ObjectType {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            ObjectType::Table => write!(f, "table"),
70            ObjectType::View => write!(f, "view"),
71            ObjectType::MaterializedView => write!(f, "materialized_view"),
72            ObjectType::Sequence => write!(f, "sequence"),
73            ObjectType::Function => write!(f, "function"),
74            ObjectType::Schema => write!(f, "schema"),
75            ObjectType::Database => write!(f, "database"),
76            ObjectType::Type => write!(f, "type"),
77        }
78    }
79}
80
81/// PostgreSQL privilege types.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
83#[serde(rename_all = "UPPERCASE")]
84pub enum Privilege {
85    Select,
86    Insert,
87    Update,
88    Delete,
89    Truncate,
90    References,
91    Trigger,
92    Execute,
93    Usage,
94    Create,
95    Connect,
96    Temporary,
97}
98
99impl std::fmt::Display for Privilege {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            Privilege::Select => write!(f, "SELECT"),
103            Privilege::Insert => write!(f, "INSERT"),
104            Privilege::Update => write!(f, "UPDATE"),
105            Privilege::Delete => write!(f, "DELETE"),
106            Privilege::Truncate => write!(f, "TRUNCATE"),
107            Privilege::References => write!(f, "REFERENCES"),
108            Privilege::Trigger => write!(f, "TRIGGER"),
109            Privilege::Execute => write!(f, "EXECUTE"),
110            Privilege::Usage => write!(f, "USAGE"),
111            Privilege::Create => write!(f, "CREATE"),
112            Privilege::Connect => write!(f, "CONNECT"),
113            Privilege::Temporary => write!(f, "TEMPORARY"),
114        }
115    }
116}
117
118// ---------------------------------------------------------------------------
119// YAML manifest types
120// ---------------------------------------------------------------------------
121
122/// Top-level policy manifest — the YAML file that users write.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct PolicyManifest {
125    /// Default owner for ALTER DEFAULT PRIVILEGES (e.g. "app_owner").
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub default_owner: Option<String>,
128
129    /// Cloud auth provider configurations for IAM-mapped role awareness.
130    #[serde(default)]
131    pub auth_providers: Vec<AuthProvider>,
132
133    /// Reusable privilege profiles.
134    #[serde(default)]
135    pub profiles: HashMap<String, Profile>,
136
137    /// Schema bindings that expand profiles into concrete roles/grants.
138    #[serde(default)]
139    pub schemas: Vec<SchemaBinding>,
140
141    /// One-off role definitions (not from profiles).
142    #[serde(default)]
143    pub roles: Vec<RoleDefinition>,
144
145    /// One-off grants (not from profiles).
146    #[serde(default)]
147    pub grants: Vec<Grant>,
148
149    /// One-off default privileges (not from profiles).
150    #[serde(default)]
151    pub default_privileges: Vec<DefaultPrivilege>,
152
153    /// Membership edges (opt-in).
154    #[serde(default)]
155    pub memberships: Vec<Membership>,
156
157    /// Explicit role-retirement workflows for roles that should be removed.
158    #[serde(default)]
159    pub retirements: Vec<RoleRetirement>,
160}
161
162/// Cloud authentication provider configuration.
163///
164/// Declares awareness of cloud IAM-mapped roles so pgroles can correctly
165/// reference auto-created role names in grants and memberships.
166#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
167#[serde(tag = "type", rename_all = "snake_case")]
168pub enum AuthProvider {
169    /// Google Cloud SQL IAM authentication.
170    /// Service accounts map to PG roles like `user@project.iam`.
171    CloudSqlIam {
172        /// GCP project ID (for documentation/validation).
173        #[serde(default)]
174        project: Option<String>,
175    },
176    /// Google AlloyDB IAM authentication.
177    /// IAM users and groups map to PostgreSQL roles managed by AlloyDB.
178    #[serde(rename = "alloydb_iam")]
179    AlloyDbIam {
180        /// GCP project ID (for documentation/validation).
181        #[serde(default)]
182        project: Option<String>,
183        /// AlloyDB cluster name (for documentation/validation).
184        #[serde(default)]
185        cluster: Option<String>,
186    },
187    /// AWS RDS IAM authentication.
188    /// IAM users authenticate via token; the PG role must have `rds_iam` granted.
189    RdsIam {
190        /// AWS region (for documentation/validation).
191        #[serde(default)]
192        region: Option<String>,
193    },
194    /// Azure Entra ID (AAD) authentication for Azure Database for PostgreSQL.
195    AzureAd {
196        /// Azure tenant ID (for documentation/validation).
197        #[serde(default)]
198        tenant_id: Option<String>,
199    },
200    /// Supabase-managed PostgreSQL authentication.
201    Supabase {
202        /// Supabase project ref (for documentation/validation).
203        #[serde(default)]
204        project_ref: Option<String>,
205    },
206    /// PlanetScale PostgreSQL authentication metadata.
207    PlanetScale {
208        /// PlanetScale organization (for documentation/validation).
209        #[serde(default)]
210        organization: Option<String>,
211    },
212}
213
214/// A reusable privilege profile — defines what grants a role should have.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct Profile {
217    #[serde(default)]
218    pub login: Option<bool>,
219
220    #[serde(default)]
221    pub grants: Vec<ProfileGrant>,
222
223    #[serde(default)]
224    pub default_privileges: Vec<DefaultPrivilegeGrant>,
225}
226
227/// A grant template within a profile (schema is filled in during expansion).
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ProfileGrant {
230    pub privileges: Vec<Privilege>,
231    #[serde(alias = "on")]
232    pub object: ProfileObjectTarget,
233}
234
235/// Object target within a profile — schema is omitted (filled during expansion).
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ProfileObjectTarget {
238    #[serde(rename = "type")]
239    pub object_type: ObjectType,
240    /// Object name, or "*" for all objects of this type. Omit for schema-level grants.
241    #[serde(default)]
242    pub name: Option<String>,
243}
244
245/// A schema binding — associates a schema with one or more profiles.
246#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
247pub struct SchemaBinding {
248    pub name: String,
249
250    pub profiles: Vec<String>,
251
252    /// Role naming pattern. Supports `{schema}` and `{profile}` placeholders.
253    /// Defaults to `"{schema}-{profile}"`.
254    #[serde(default = "default_role_pattern")]
255    pub role_pattern: String,
256
257    /// Override default_owner for this schema's default privileges.
258    #[serde(default)]
259    pub owner: Option<String>,
260}
261
262fn default_role_pattern() -> String {
263    "{schema}-{profile}".to_string()
264}
265
266/// A concrete role definition.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct RoleDefinition {
269    pub name: String,
270
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub login: Option<bool>,
273
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub superuser: Option<bool>,
276
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub createdb: Option<bool>,
279
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub createrole: Option<bool>,
282
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub inherit: Option<bool>,
285
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub replication: Option<bool>,
288
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub bypassrls: Option<bool>,
291
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub connection_limit: Option<i32>,
294
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub comment: Option<String>,
297
298    /// Password source for this role. Passwords are never stored in the manifest
299    /// directly — only a reference to an environment variable is allowed.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub password: Option<PasswordSource>,
302
303    /// Password expiration timestamp (ISO 8601, e.g. "2025-12-31T00:00:00Z").
304    /// Maps to PostgreSQL's `VALID UNTIL` clause.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub password_valid_until: Option<String>,
307}
308
309/// Source for a role password. Passwords are never stored in YAML manifests.
310///
311/// This follows the same security model as `DATABASE_URL` — secrets come from
312/// the runtime environment, not from configuration files.
313#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
314pub struct PasswordSource {
315    /// Name of the environment variable containing the password.
316    pub from_env: String,
317}
318
319/// A concrete grant on a specific object or wildcard.
320#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
321pub struct Grant {
322    pub role: String,
323    pub privileges: Vec<Privilege>,
324    #[serde(alias = "on")]
325    pub object: ObjectTarget,
326}
327
328/// Target object for a grant.
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
330pub struct ObjectTarget {
331    #[serde(rename = "type")]
332    pub object_type: ObjectType,
333
334    /// Schema name. Required for most object types except database.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub schema: Option<String>,
337
338    /// Object name, or "*" for all objects. Omit for schema-level grants.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub name: Option<String>,
341}
342
343/// Default privilege configuration.
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
345pub struct DefaultPrivilege {
346    /// The role that owns newly created objects. If omitted, uses manifest's default_owner.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub owner: Option<String>,
349
350    pub schema: String,
351
352    pub grant: Vec<DefaultPrivilegeGrant>,
353}
354
355/// A single default privilege grant entry.
356#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
357pub struct DefaultPrivilegeGrant {
358    /// The role receiving the default privilege. Only used in top-level default_privileges
359    /// (in profiles, the role is determined by expansion).
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub role: Option<String>,
362
363    pub privileges: Vec<Privilege>,
364    pub on_type: ObjectType,
365}
366
367/// A membership declaration — which members belong to a role.
368#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
369pub struct Membership {
370    pub role: String,
371    pub members: Vec<MemberSpec>,
372}
373
374/// A single member of a role.
375///
376/// Both `inherit` and `admin` are optional. When omitted, they default to
377/// `inherit: true` and `admin: false` at resolution time (in `RoleGraph`
378/// construction). Keeping them optional in the CRD avoids Kubernetes
379/// injecting default values into the stored resource, which causes
380/// perpetual diffs in GitOps tools like ArgoCD.
381#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
382pub struct MemberSpec {
383    pub name: String,
384
385    /// Whether the member inherits the role's privileges. Defaults to `true`.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub inherit: Option<bool>,
388
389    /// Whether the member can administer the role. Defaults to `false`.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub admin: Option<bool>,
392}
393
394impl MemberSpec {
395    /// Resolve `inherit` with its default (true).
396    pub fn inherit(&self) -> bool {
397        self.inherit.unwrap_or(true)
398    }
399
400    /// Resolve `admin` with its default (false).
401    pub fn admin(&self) -> bool {
402        self.admin.unwrap_or(false)
403    }
404}
405
406/// Declarative workflow for retiring an existing role.
407#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
408pub struct RoleRetirement {
409    /// The role to retire and ultimately drop.
410    pub role: String,
411
412    /// Optional successor role for `REASSIGN OWNED BY ... TO ...`.
413    #[serde(default)]
414    pub reassign_owned_to: Option<String>,
415
416    /// Whether to run `DROP OWNED BY` before dropping the role.
417    #[serde(default)]
418    pub drop_owned: bool,
419
420    /// Whether to terminate other active sessions for the role before drop.
421    #[serde(default)]
422    pub terminate_sessions: bool,
423}
424
425// ---------------------------------------------------------------------------
426// Expanded manifest — the result of profile expansion
427// ---------------------------------------------------------------------------
428
429/// The fully expanded policy — all profiles resolved into concrete roles, grants,
430/// default privileges, and memberships. Ready to be converted into a `RoleGraph`.
431#[derive(Debug, Clone)]
432pub struct ExpandedManifest {
433    pub roles: Vec<RoleDefinition>,
434    pub grants: Vec<Grant>,
435    pub default_privileges: Vec<DefaultPrivilege>,
436    pub memberships: Vec<Membership>,
437}
438
439// ---------------------------------------------------------------------------
440// Expansion logic
441// ---------------------------------------------------------------------------
442
443/// Parse a YAML string into a `PolicyManifest`.
444///
445/// Accepts both bare manifests and Kubernetes CustomResource wrappers.
446/// If the YAML contains an `apiVersion` and `spec` field, the `spec` is
447/// extracted and parsed as a `PolicyManifest`.
448pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
449    // Check if this looks like a Kubernetes CR wrapper.
450    let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
451    if let serde_yaml::Value::Mapping(ref map) = value {
452        let api_version_key = serde_yaml::Value::String("apiVersion".into());
453        let spec_key = serde_yaml::Value::String("spec".into());
454        if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
455            let spec = map.get(&spec_key).ok_or_else(|| {
456                ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
457            })?;
458            let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
459            return Ok(manifest);
460        }
461    }
462    let manifest: PolicyManifest = serde_yaml::from_value(value)?;
463    Ok(manifest)
464}
465
466/// Expand a `PolicyManifest` by resolving all `profiles × schemas` into concrete
467/// roles, grants, and default privileges. Merges with one-off definitions.
468/// Validates no duplicate role names.
469pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
470    let mut roles: Vec<RoleDefinition> = Vec::new();
471    let mut grants: Vec<Grant> = Vec::new();
472    let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
473
474    // Expand each schema × profile combination
475    for schema_binding in &manifest.schemas {
476        for profile_name in &schema_binding.profiles {
477            let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
478                ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
479            })?;
480
481            // Validate pattern contains {profile}
482            if !schema_binding.role_pattern.contains("{profile}") {
483                return Err(ManifestError::InvalidRolePattern(
484                    schema_binding.role_pattern.clone(),
485                ));
486            }
487
488            // Generate role name from pattern
489            let role_name = schema_binding
490                .role_pattern
491                .replace("{schema}", &schema_binding.name)
492                .replace("{profile}", profile_name);
493
494            // Create role definition
495            roles.push(RoleDefinition {
496                name: role_name.clone(),
497                login: profile.login,
498                superuser: None,
499                createdb: None,
500                createrole: None,
501                inherit: None,
502                replication: None,
503                bypassrls: None,
504                connection_limit: None,
505                comment: Some(format!(
506                    "Generated from profile '{profile_name}' for schema '{}'",
507                    schema_binding.name
508                )),
509                password: None,
510                password_valid_until: None,
511            });
512
513            // Expand profile grants — fill in schema
514            for profile_grant in &profile.grants {
515                let object_target = match profile_grant.object.object_type {
516                    ObjectType::Schema => ObjectTarget {
517                        object_type: ObjectType::Schema,
518                        schema: None,
519                        name: Some(schema_binding.name.clone()),
520                    },
521                    _ => ObjectTarget {
522                        object_type: profile_grant.object.object_type,
523                        schema: Some(schema_binding.name.clone()),
524                        name: profile_grant.object.name.clone(),
525                    },
526                };
527
528                grants.push(Grant {
529                    role: role_name.clone(),
530                    privileges: profile_grant.privileges.clone(),
531                    object: object_target,
532                });
533            }
534
535            // Expand profile default privileges
536            if !profile.default_privileges.is_empty() {
537                let owner = schema_binding
538                    .owner
539                    .clone()
540                    .or(manifest.default_owner.clone());
541
542                let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
543                    .default_privileges
544                    .iter()
545                    .map(|dp| DefaultPrivilegeGrant {
546                        role: Some(role_name.clone()),
547                        privileges: dp.privileges.clone(),
548                        on_type: dp.on_type,
549                    })
550                    .collect();
551
552                default_privileges.push(DefaultPrivilege {
553                    owner,
554                    schema: schema_binding.name.clone(),
555                    grant: expanded_grants,
556                });
557            }
558        }
559    }
560
561    // Top-level default privileges must always identify the grantee role.
562    for default_priv in &manifest.default_privileges {
563        for grant in &default_priv.grant {
564            if grant.role.is_none() {
565                return Err(ManifestError::MissingDefaultPrivilegeRole {
566                    schema: default_priv.schema.clone(),
567                });
568            }
569        }
570    }
571
572    // Merge one-off definitions
573    roles.extend(manifest.roles.clone());
574    grants.extend(manifest.grants.clone());
575    default_privileges.extend(manifest.default_privileges.clone());
576    let memberships = manifest.memberships.clone();
577
578    // Validate no duplicate role names
579    let mut seen_roles: HashSet<String> = HashSet::new();
580    for role in &roles {
581        if seen_roles.contains(&role.name) {
582            return Err(ManifestError::DuplicateRole(role.name.clone()));
583        }
584        seen_roles.insert(role.name.clone());
585    }
586
587    let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
588    let mut seen_retirements: HashSet<String> = HashSet::new();
589    for retirement in &manifest.retirements {
590        if seen_retirements.contains(&retirement.role) {
591            return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
592        }
593        if desired_role_names.contains(&retirement.role) {
594            return Err(ManifestError::RetirementRoleStillDesired(
595                retirement.role.clone(),
596            ));
597        }
598        if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
599            return Err(ManifestError::RetirementSelfReassign {
600                role: retirement.role.clone(),
601            });
602        }
603        seen_retirements.insert(retirement.role.clone());
604    }
605
606    // Validate: password on a non-login role is an error.
607    // We require login to be explicitly true — if login is None (defaults to false)
608    // a password would be useless.
609    for role in &roles {
610        if role.password.is_some() && role.login != Some(true) {
611            return Err(ManifestError::PasswordWithoutLogin {
612                role: role.name.clone(),
613            });
614        }
615    }
616
617    // Validate: password_valid_until must be a valid ISO 8601 timestamp.
618    for role in &roles {
619        if let Some(value) = &role.password_valid_until
620            && !is_valid_iso8601_timestamp(value)
621        {
622            return Err(ManifestError::InvalidValidUntil {
623                role: role.name.clone(),
624                value: value.clone(),
625            });
626        }
627    }
628
629    Ok(ExpandedManifest {
630        roles,
631        grants,
632        default_privileges,
633        memberships,
634    })
635}
636
637// ---------------------------------------------------------------------------
638// Validation helpers
639// ---------------------------------------------------------------------------
640
641/// Validate that a string is a plausible ISO 8601 timestamp.
642///
643/// Accepts formats like:
644/// - `2025-12-31T00:00:00Z`
645/// - `2025-12-31T00:00:00+00:00`
646/// - `2025-12-31T00:00:00-05:00`
647/// - `2025-12-31T00:00:00.123Z`
648///
649/// This validates structure and numeric ranges (month 01-12, day 01-31,
650/// hour 00-23, minute/second 00-59). It does not check calendar validity
651/// (e.g. Feb 30 passes). PostgreSQL itself will reject truly invalid dates.
652fn is_valid_iso8601_timestamp(value: &str) -> bool {
653    // Minimum valid: "YYYY-MM-DDTHH:MM:SSZ" = 20 chars
654    if value.len() < 20 {
655        return false;
656    }
657
658    let bytes = value.as_bytes();
659
660    // Check date part: YYYY-MM-DD
661    if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
662        return false;
663    }
664
665    let year = &value[0..4];
666    let month = &value[5..7];
667    let day = &value[8..10];
668
669    let Ok(y) = year.parse::<u16>() else {
670        return false;
671    };
672    let Ok(m) = month.parse::<u8>() else {
673        return false;
674    };
675    let Ok(d) = day.parse::<u8>() else {
676        return false;
677    };
678
679    if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
680        return false;
681    }
682
683    // Check time part: HH:MM:SS
684    if bytes[13] != b':' || bytes[16] != b':' {
685        return false;
686    }
687
688    let hour = &value[11..13];
689    let minute = &value[14..16];
690    let second = &value[17..19];
691
692    let Ok(h) = hour.parse::<u8>() else {
693        return false;
694    };
695    let Ok(min) = minute.parse::<u8>() else {
696        return false;
697    };
698    let Ok(sec) = second.parse::<u8>() else {
699        return false;
700    };
701
702    if h > 23 || min > 59 || sec > 59 {
703        return false;
704    }
705
706    // Remaining suffix must be a valid timezone indicator.
707    let suffix = &value[19..];
708
709    // Handle optional fractional seconds: .NNN
710    let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
711        // Skip digits after the decimal point
712        let frac_end = rest
713            .find(|c: char| !c.is_ascii_digit())
714            .unwrap_or(rest.len());
715        if frac_end == 0 {
716            return false; // "." with no digits
717        }
718        &rest[frac_end..]
719    } else {
720        suffix
721    };
722
723    // Valid timezone indicators: "Z", "+HH:MM", "-HH:MM"
724    match tz_part {
725        "Z" => true,
726        s if (s.starts_with('+') || s.starts_with('-'))
727            && s.len() == 6
728            && s.as_bytes()[3] == b':' =>
729        {
730            let Ok(tz_h) = s[1..3].parse::<u8>() else {
731                return false;
732            };
733            let Ok(tz_m) = s[4..6].parse::<u8>() else {
734                return false;
735            };
736            tz_h <= 14 && tz_m <= 59
737        }
738        _ => false,
739    }
740}
741
742// ---------------------------------------------------------------------------
743// Tests
744// ---------------------------------------------------------------------------
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn parse_minimal_role() {
752        let yaml = r#"
753roles:
754  - name: test-role
755"#;
756        let manifest = parse_manifest(yaml).unwrap();
757        assert_eq!(manifest.roles.len(), 1);
758        assert_eq!(manifest.roles[0].name, "test-role");
759        assert!(manifest.roles[0].login.is_none());
760    }
761
762    #[test]
763    fn parse_full_policy() {
764        let yaml = r#"
765default_owner: app_owner
766
767profiles:
768  editor:
769    login: false
770    grants:
771      - privileges: [USAGE]
772        object: { type: schema }
773      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
774        object: { type: table, name: "*" }
775      - privileges: [USAGE, SELECT, UPDATE]
776        object: { type: sequence, name: "*" }
777      - privileges: [EXECUTE]
778        object: { type: function, name: "*" }
779    default_privileges:
780      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
781        on_type: table
782      - privileges: [USAGE, SELECT, UPDATE]
783        on_type: sequence
784      - privileges: [EXECUTE]
785        on_type: function
786
787schemas:
788  - name: inventory
789    profiles: [editor]
790  - name: catalog
791    profiles: [editor]
792
793roles:
794  - name: analytics-readonly
795    login: true
796
797memberships:
798  - role: inventory-editor
799    members:
800      - name: "alice@example.com"
801        inherit: true
802"#;
803        let manifest = parse_manifest(yaml).unwrap();
804        assert_eq!(manifest.profiles.len(), 1);
805        assert_eq!(manifest.schemas.len(), 2);
806        assert_eq!(manifest.roles.len(), 1);
807        assert_eq!(manifest.memberships.len(), 1);
808        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
809    }
810
811    #[test]
812    fn reject_invalid_yaml() {
813        let yaml = "not: [valid: yaml: {{";
814        assert!(parse_manifest(yaml).is_err());
815    }
816
817    #[test]
818    fn expand_profiles_basic() {
819        let yaml = r#"
820profiles:
821  editor:
822    login: false
823    grants:
824      - privileges: [USAGE]
825        object: { type: schema }
826      - privileges: [SELECT, INSERT]
827        object: { type: table, name: "*" }
828
829schemas:
830  - name: myschema
831    profiles: [editor]
832"#;
833        let manifest = parse_manifest(yaml).unwrap();
834        let expanded = expand_manifest(&manifest).unwrap();
835
836        assert_eq!(expanded.roles.len(), 1);
837        assert_eq!(expanded.roles[0].name, "myschema-editor");
838        assert_eq!(expanded.roles[0].login, Some(false));
839
840        // Schema usage grant + table grant
841        assert_eq!(expanded.grants.len(), 2);
842        assert_eq!(expanded.grants[0].role, "myschema-editor");
843        assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
844        assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
845
846        assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
847        assert_eq!(
848            expanded.grants[1].object.schema,
849            Some("myschema".to_string())
850        );
851        assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
852    }
853
854    #[test]
855    fn expand_profiles_multi_schema() {
856        let yaml = r#"
857profiles:
858  editor:
859    grants:
860      - privileges: [SELECT]
861        object: { type: table, name: "*" }
862  viewer:
863    grants:
864      - privileges: [SELECT]
865        object: { type: table, name: "*" }
866
867schemas:
868  - name: alpha
869    profiles: [editor, viewer]
870  - name: beta
871    profiles: [editor, viewer]
872  - name: gamma
873    profiles: [editor]
874"#;
875        let manifest = parse_manifest(yaml).unwrap();
876        let expanded = expand_manifest(&manifest).unwrap();
877
878        // 2 + 2 + 1 = 5 roles
879        assert_eq!(expanded.roles.len(), 5);
880        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
881        assert!(role_names.contains(&"alpha-editor"));
882        assert!(role_names.contains(&"alpha-viewer"));
883        assert!(role_names.contains(&"beta-editor"));
884        assert!(role_names.contains(&"beta-viewer"));
885        assert!(role_names.contains(&"gamma-editor"));
886    }
887
888    #[test]
889    fn expand_custom_role_pattern() {
890        let yaml = r#"
891profiles:
892  viewer:
893    grants:
894      - privileges: [SELECT]
895        object: { type: table, name: "*" }
896
897schemas:
898  - name: legacy_data
899    profiles: [viewer]
900    role_pattern: "legacy-{profile}"
901"#;
902        let manifest = parse_manifest(yaml).unwrap();
903        let expanded = expand_manifest(&manifest).unwrap();
904
905        assert_eq!(expanded.roles.len(), 1);
906        assert_eq!(expanded.roles[0].name, "legacy-viewer");
907    }
908
909    #[test]
910    fn expand_rejects_duplicate_role_name() {
911        let yaml = r#"
912profiles:
913  editor:
914    grants: []
915
916schemas:
917  - name: inventory
918    profiles: [editor]
919
920roles:
921  - name: inventory-editor
922"#;
923        let manifest = parse_manifest(yaml).unwrap();
924        let result = expand_manifest(&manifest);
925        assert!(result.is_err());
926        assert!(
927            result
928                .unwrap_err()
929                .to_string()
930                .contains("duplicate role name")
931        );
932    }
933
934    #[test]
935    fn expand_rejects_undefined_profile() {
936        let yaml = r#"
937profiles: {}
938
939schemas:
940  - name: inventory
941    profiles: [nonexistent]
942"#;
943        let manifest = parse_manifest(yaml).unwrap();
944        let result = expand_manifest(&manifest);
945        assert!(result.is_err());
946        assert!(result.unwrap_err().to_string().contains("not defined"));
947    }
948
949    #[test]
950    fn expand_rejects_invalid_pattern() {
951        let yaml = r#"
952profiles:
953  editor:
954    grants: []
955
956schemas:
957  - name: inventory
958    profiles: [editor]
959    role_pattern: "static-name"
960"#;
961        let manifest = parse_manifest(yaml).unwrap();
962        let result = expand_manifest(&manifest);
963        assert!(result.is_err());
964        assert!(
965            result
966                .unwrap_err()
967                .to_string()
968                .contains("{profile} placeholder")
969        );
970    }
971
972    #[test]
973    fn expand_rejects_top_level_default_privilege_without_role() {
974        let yaml = r#"
975default_privileges:
976  - schema: public
977    grant:
978      - privileges: [SELECT]
979        on_type: table
980"#;
981        let manifest = parse_manifest(yaml).unwrap();
982        let result = expand_manifest(&manifest);
983        assert!(result.is_err());
984        assert!(
985            result
986                .unwrap_err()
987                .to_string()
988                .contains("must specify grant.role")
989        );
990    }
991
992    #[test]
993    fn expand_default_privileges_with_owner_override() {
994        let yaml = r#"
995default_owner: app_owner
996
997profiles:
998  editor:
999    grants: []
1000    default_privileges:
1001      - privileges: [SELECT]
1002        on_type: table
1003
1004schemas:
1005  - name: inventory
1006    profiles: [editor]
1007  - name: legacy
1008    profiles: [editor]
1009    owner: legacy_admin
1010"#;
1011        let manifest = parse_manifest(yaml).unwrap();
1012        let expanded = expand_manifest(&manifest).unwrap();
1013
1014        assert_eq!(expanded.default_privileges.len(), 2);
1015
1016        // inventory uses default_owner
1017        assert_eq!(
1018            expanded.default_privileges[0].owner,
1019            Some("app_owner".to_string())
1020        );
1021        assert_eq!(expanded.default_privileges[0].schema, "inventory");
1022
1023        // legacy uses override
1024        assert_eq!(
1025            expanded.default_privileges[1].owner,
1026            Some("legacy_admin".to_string())
1027        );
1028        assert_eq!(expanded.default_privileges[1].schema, "legacy");
1029    }
1030
1031    #[test]
1032    fn expand_merges_oneoff_roles_and_grants() {
1033        let yaml = r#"
1034profiles:
1035  editor:
1036    grants:
1037      - privileges: [SELECT]
1038        object: { type: table, name: "*" }
1039
1040schemas:
1041  - name: inventory
1042    profiles: [editor]
1043
1044roles:
1045  - name: analytics
1046    login: true
1047
1048grants:
1049  - role: analytics
1050    privileges: [SELECT]
1051    on:
1052      type: table
1053      schema: inventory
1054      name: "*"
1055"#;
1056        let manifest = parse_manifest(yaml).unwrap();
1057        let expanded = expand_manifest(&manifest).unwrap();
1058
1059        assert_eq!(expanded.roles.len(), 2);
1060        assert_eq!(expanded.grants.len(), 2); // 1 from profile + 1 one-off
1061    }
1062
1063    #[test]
1064    fn parse_manifest_accepts_legacy_on_alias() {
1065        let yaml = r#"
1066grants:
1067  - role: analytics
1068    privileges: [SELECT]
1069    on:
1070      type: table
1071      schema: public
1072      name: "*"
1073"#;
1074        let manifest = parse_manifest(yaml).unwrap();
1075        assert_eq!(manifest.grants.len(), 1);
1076        assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1077        assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1078        assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1079    }
1080
1081    #[test]
1082    fn parse_membership_with_email_roles() {
1083        let yaml = r#"
1084memberships:
1085  - role: inventory-editor
1086    members:
1087      - name: "alice@example.com"
1088        inherit: true
1089      - name: "engineering@example.com"
1090        admin: true
1091"#;
1092        let manifest = parse_manifest(yaml).unwrap();
1093        assert_eq!(manifest.memberships.len(), 1);
1094        assert_eq!(manifest.memberships[0].members.len(), 2);
1095        assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1096        assert_eq!(manifest.memberships[0].members[0].inherit, Some(true));
1097        assert_eq!(manifest.memberships[0].members[1].admin, Some(true));
1098    }
1099
1100    #[test]
1101    fn member_spec_defaults() {
1102        let yaml = r#"
1103memberships:
1104  - role: some-role
1105    members:
1106      - name: user1
1107"#;
1108        let manifest = parse_manifest(yaml).unwrap();
1109        // When omitted, both fields are None (defaults applied at resolution time).
1110        assert_eq!(manifest.memberships[0].members[0].inherit, None);
1111        assert_eq!(manifest.memberships[0].members[0].admin, None);
1112        // Accessor methods still return the expected defaults.
1113        assert!(manifest.memberships[0].members[0].inherit());
1114        assert!(!manifest.memberships[0].members[0].admin());
1115    }
1116
1117    #[test]
1118    fn expand_rejects_duplicate_retirements() {
1119        let yaml = r#"
1120retirements:
1121  - role: old-app
1122  - role: old-app
1123"#;
1124        let manifest = parse_manifest(yaml).unwrap();
1125        let result = expand_manifest(&manifest);
1126        assert!(matches!(
1127            result,
1128            Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1129        ));
1130    }
1131
1132    #[test]
1133    fn expand_rejects_retirement_for_desired_role() {
1134        let yaml = r#"
1135roles:
1136  - name: old-app
1137
1138retirements:
1139  - role: old-app
1140"#;
1141        let manifest = parse_manifest(yaml).unwrap();
1142        let result = expand_manifest(&manifest);
1143        assert!(matches!(
1144            result,
1145            Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1146        ));
1147    }
1148
1149    #[test]
1150    fn expand_rejects_self_reassign_retirement() {
1151        let yaml = r#"
1152retirements:
1153  - role: old-app
1154    reassign_owned_to: old-app
1155"#;
1156        let manifest = parse_manifest(yaml).unwrap();
1157        let result = expand_manifest(&manifest);
1158        assert!(matches!(
1159            result,
1160            Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1161        ));
1162    }
1163
1164    #[test]
1165    fn parse_auth_providers() {
1166        let yaml = r#"
1167auth_providers:
1168  - type: cloud_sql_iam
1169    project: my-gcp-project
1170  - type: alloydb_iam
1171    project: my-gcp-project
1172    cluster: analytics-prod
1173  - type: rds_iam
1174    region: us-east-1
1175  - type: azure_ad
1176    tenant_id: "abc-123"
1177  - type: supabase
1178    project_ref: myprojref
1179  - type: planet_scale
1180    organization: my-org
1181
1182roles:
1183  - name: app-service
1184"#;
1185        let manifest = parse_manifest(yaml).unwrap();
1186        assert_eq!(manifest.auth_providers.len(), 6);
1187        assert!(matches!(
1188            &manifest.auth_providers[0],
1189            AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1190        ));
1191        assert!(matches!(
1192            &manifest.auth_providers[1],
1193            AuthProvider::AlloyDbIam {
1194                project: Some(p),
1195                cluster: Some(c)
1196            } if p == "my-gcp-project" && c == "analytics-prod"
1197        ));
1198        assert!(matches!(
1199            &manifest.auth_providers[2],
1200            AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1201        ));
1202        assert!(matches!(
1203            &manifest.auth_providers[3],
1204            AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1205        ));
1206        assert!(matches!(
1207            &manifest.auth_providers[4],
1208            AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1209        ));
1210        assert!(matches!(
1211            &manifest.auth_providers[5],
1212            AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1213        ));
1214    }
1215
1216    #[test]
1217    fn parse_manifest_without_auth_providers() {
1218        let yaml = r#"
1219roles:
1220  - name: test-role
1221"#;
1222        let manifest = parse_manifest(yaml).unwrap();
1223        assert!(manifest.auth_providers.is_empty());
1224    }
1225
1226    #[test]
1227    fn parse_role_with_password_source() {
1228        let yaml = r#"
1229roles:
1230  - name: app-service
1231    login: true
1232    password:
1233      from_env: APP_SERVICE_PASSWORD
1234    password_valid_until: "2025-12-31T00:00:00Z"
1235"#;
1236        let manifest = parse_manifest(yaml).unwrap();
1237        assert_eq!(manifest.roles.len(), 1);
1238        let role = &manifest.roles[0];
1239        assert!(role.password.is_some());
1240        assert_eq!(
1241            role.password.as_ref().unwrap().from_env,
1242            "APP_SERVICE_PASSWORD"
1243        );
1244        assert_eq!(
1245            role.password_valid_until,
1246            Some("2025-12-31T00:00:00Z".to_string())
1247        );
1248    }
1249
1250    #[test]
1251    fn parse_role_without_password() {
1252        let yaml = r#"
1253roles:
1254  - name: app-service
1255    login: true
1256"#;
1257        let manifest = parse_manifest(yaml).unwrap();
1258        assert!(manifest.roles[0].password.is_none());
1259        assert!(manifest.roles[0].password_valid_until.is_none());
1260    }
1261
1262    #[test]
1263    fn reject_password_on_nologin_role() {
1264        let yaml = r#"
1265roles:
1266  - name: nologin-role
1267    login: false
1268    password:
1269      from_env: SOME_PASSWORD
1270"#;
1271        let manifest = parse_manifest(yaml).unwrap();
1272        let result = expand_manifest(&manifest);
1273        assert!(result.is_err());
1274        assert!(
1275            result
1276                .unwrap_err()
1277                .to_string()
1278                .contains("login is not enabled")
1279        );
1280    }
1281
1282    #[test]
1283    fn reject_password_on_default_login_role() {
1284        // login is None (defaults to NOLOGIN) — password should still be rejected
1285        let yaml = r#"
1286roles:
1287  - name: implicit-nologin-role
1288    password:
1289      from_env: SOME_PASSWORD
1290"#;
1291        let manifest = parse_manifest(yaml).unwrap();
1292        let result = expand_manifest(&manifest);
1293        assert!(result.is_err());
1294        assert!(
1295            result
1296                .unwrap_err()
1297                .to_string()
1298                .contains("login is not enabled")
1299        );
1300    }
1301
1302    #[test]
1303    fn reject_invalid_password_valid_until() {
1304        let yaml = r#"
1305roles:
1306  - name: bad-date
1307    login: true
1308    password_valid_until: "not-a-date"
1309"#;
1310        let manifest = parse_manifest(yaml).unwrap();
1311        let result = expand_manifest(&manifest);
1312        assert!(result.is_err());
1313        assert!(
1314            result
1315                .unwrap_err()
1316                .to_string()
1317                .contains("invalid password_valid_until")
1318        );
1319    }
1320
1321    #[test]
1322    fn reject_date_only_valid_until() {
1323        let yaml = r#"
1324roles:
1325  - name: bad-date
1326    login: true
1327    password_valid_until: "2025-12-31"
1328"#;
1329        let manifest = parse_manifest(yaml).unwrap();
1330        let result = expand_manifest(&manifest);
1331        assert!(result.is_err());
1332    }
1333
1334    #[test]
1335    fn accept_valid_iso8601_timestamps() {
1336        // UTC with Z
1337        assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1338        // With timezone offset
1339        assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1340        assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1341        // With fractional seconds
1342        assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1343    }
1344
1345    #[test]
1346    fn reject_invalid_iso8601_timestamps() {
1347        assert!(!is_valid_iso8601_timestamp("not-a-date"));
1348        assert!(!is_valid_iso8601_timestamp("2025-12-31")); // date only
1349        assert!(!is_valid_iso8601_timestamp("2025-13-31T00:00:00Z")); // month 13
1350        assert!(!is_valid_iso8601_timestamp("2025-12-31T25:00:00Z")); // hour 25
1351        assert!(!is_valid_iso8601_timestamp("2025-12-31T00:00:00")); // no timezone
1352        assert!(!is_valid_iso8601_timestamp("")); // empty
1353    }
1354
1355    #[test]
1356    fn parse_manifest_from_kubernetes_cr() {
1357        let yaml = r#"
1358apiVersion: pgroles.io/v1alpha1
1359kind: PostgresPolicy
1360metadata:
1361  name: staging-policy
1362  namespace: pgroles-system
1363spec:
1364  connection:
1365    secretRef:
1366      name: pgroles-db-credentials
1367  interval: "5m"
1368  mode: plan
1369  roles:
1370    - name: app_analytics
1371      login: true
1372    - name: app_billing
1373      login: true
1374  schemas:
1375    - name: analytics
1376      profiles: [editor, viewer]
1377  profiles:
1378    editor:
1379      grants:
1380        - object: { type: schema }
1381          privileges: [USAGE]
1382        - object: { type: table, name: "*" }
1383          privileges: [SELECT, INSERT, UPDATE, DELETE]
1384    viewer:
1385      grants:
1386        - object: { type: schema }
1387          privileges: [USAGE]
1388        - object: { type: table, name: "*" }
1389          privileges: [SELECT]
1390  memberships:
1391    - role: analytics-editor
1392      members:
1393        - { name: app_analytics }
1394    - role: analytics-viewer
1395      members:
1396        - { name: app_billing }
1397"#;
1398        let manifest = parse_manifest(yaml).unwrap();
1399        assert_eq!(manifest.roles.len(), 2);
1400        assert_eq!(manifest.roles[0].name, "app_analytics");
1401        assert_eq!(manifest.schemas.len(), 1);
1402        assert_eq!(manifest.memberships.len(), 2);
1403        assert_eq!(manifest.profiles.len(), 2);
1404    }
1405
1406    #[test]
1407    fn parse_manifest_bare_and_cr_produce_same_result() {
1408        let bare = r#"
1409roles:
1410  - name: test_role
1411    login: true
1412schemas:
1413  - name: public
1414    profiles: [viewer]
1415profiles:
1416  viewer:
1417    grants:
1418      - object: { type: schema }
1419        privileges: [USAGE]
1420"#;
1421        let cr = r#"
1422apiVersion: pgroles.io/v1alpha1
1423kind: PostgresPolicy
1424metadata:
1425  name: test
1426spec:
1427  roles:
1428    - name: test_role
1429      login: true
1430  schemas:
1431    - name: public
1432      profiles: [viewer]
1433  profiles:
1434    viewer:
1435      grants:
1436        - object: { type: schema }
1437          privileges: [USAGE]
1438"#;
1439        let from_bare = parse_manifest(bare).unwrap();
1440        let from_cr = parse_manifest(cr).unwrap();
1441        assert_eq!(from_bare.roles.len(), from_cr.roles.len());
1442        assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
1443        assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
1444    }
1445}