Skip to main content

pgroles_core/
manifest.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, 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("duplicate schema name: \"{0}\"")]
19    DuplicateSchema(String),
20
21    #[error("profile \"{0}\" referenced by schema \"{1}\" is not defined")]
22    UndefinedProfile(String, String),
23
24    #[error("role_pattern must contain {{profile}} placeholder, got: \"{0}\"")]
25    InvalidRolePattern(String),
26
27    #[error("top-level default privilege for schema \"{schema}\" must specify grant.role")]
28    MissingDefaultPrivilegeRole { schema: String },
29
30    #[error("duplicate retirement entry for role: \"{0}\"")]
31    DuplicateRetirement(String),
32
33    #[error("retirement entry for role \"{0}\" conflicts with a desired role of the same name")]
34    RetirementRoleStillDesired(String),
35
36    #[error("retirement entry for role \"{role}\" cannot reassign ownership to itself")]
37    RetirementSelfReassign { role: String },
38
39    #[error(
40        "role \"{role}\" has a password but login is not enabled — password will have no effect"
41    )]
42    PasswordWithoutLogin { role: String },
43
44    #[error(
45        "role \"{role}\" has an invalid password_valid_until value \"{value}\": expected ISO 8601 timestamp (e.g. \"2025-12-31T00:00:00Z\")"
46    )]
47    InvalidValidUntil { role: String, value: String },
48
49    #[error(
50        "role \"{role}\" has an invalid config parameter name \"{parameter}\": expected a PostgreSQL setting name (letters, digits, underscores, optionally dot-qualified)"
51    )]
52    InvalidConfigParameter { role: String, parameter: String },
53
54    #[error(
55        "role \"{role}\" sets config `role: {target}` but declares no membership in \"{target}\" — the setting would fail at login; add \"{role}\" to the members of \"{target}\""
56    )]
57    SetRoleWithoutMembership { role: String, target: String },
58}
59
60// ---------------------------------------------------------------------------
61// Enums
62// ---------------------------------------------------------------------------
63
64/// PostgreSQL object types that can have privileges granted on them.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
66#[serde(rename_all = "snake_case")]
67pub enum ObjectType {
68    Table,
69    View,
70    #[serde(alias = "materialized_view")]
71    MaterializedView,
72    Sequence,
73    Function,
74    Schema,
75    Database,
76    Type,
77}
78
79impl std::fmt::Display for ObjectType {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            ObjectType::Table => write!(f, "table"),
83            ObjectType::View => write!(f, "view"),
84            ObjectType::MaterializedView => write!(f, "materialized_view"),
85            ObjectType::Sequence => write!(f, "sequence"),
86            ObjectType::Function => write!(f, "function"),
87            ObjectType::Schema => write!(f, "schema"),
88            ObjectType::Database => write!(f, "database"),
89            ObjectType::Type => write!(f, "type"),
90        }
91    }
92}
93
94/// PostgreSQL privilege types.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
96#[serde(rename_all = "UPPERCASE")]
97pub enum Privilege {
98    Select,
99    Insert,
100    Update,
101    Delete,
102    Truncate,
103    References,
104    Trigger,
105    Execute,
106    Usage,
107    Create,
108    Connect,
109    Temporary,
110}
111
112impl std::fmt::Display for Privilege {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Privilege::Select => write!(f, "SELECT"),
116            Privilege::Insert => write!(f, "INSERT"),
117            Privilege::Update => write!(f, "UPDATE"),
118            Privilege::Delete => write!(f, "DELETE"),
119            Privilege::Truncate => write!(f, "TRUNCATE"),
120            Privilege::References => write!(f, "REFERENCES"),
121            Privilege::Trigger => write!(f, "TRIGGER"),
122            Privilege::Execute => write!(f, "EXECUTE"),
123            Privilege::Usage => write!(f, "USAGE"),
124            Privilege::Create => write!(f, "CREATE"),
125            Privilege::Connect => write!(f, "CONNECT"),
126            Privilege::Temporary => write!(f, "TEMPORARY"),
127        }
128    }
129}
130
131// ---------------------------------------------------------------------------
132// YAML manifest types
133// ---------------------------------------------------------------------------
134
135/// Top-level policy manifest — the YAML file that users write.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PolicyManifest {
138    /// Default owner for ALTER DEFAULT PRIVILEGES (e.g. "app_owner").
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub default_owner: Option<String>,
141
142    /// Cloud auth provider configurations for IAM-mapped role awareness.
143    #[serde(default)]
144    pub auth_providers: Vec<AuthProvider>,
145
146    /// Reusable privilege profiles. Stored as a `BTreeMap` so YAML
147    /// serialization is deterministic — two `pgroles generate` runs against
148    /// the same database produce byte-identical output.
149    #[serde(default)]
150    pub profiles: BTreeMap<String, Profile>,
151
152    /// Schema bindings that expand profiles into concrete roles/grants.
153    #[serde(default)]
154    pub schemas: Vec<SchemaBinding>,
155
156    /// One-off role definitions (not from profiles).
157    #[serde(default)]
158    pub roles: Vec<RoleDefinition>,
159
160    /// One-off grants (not from profiles).
161    #[serde(default)]
162    pub grants: Vec<Grant>,
163
164    /// One-off default privileges (not from profiles).
165    #[serde(default)]
166    pub default_privileges: Vec<DefaultPrivilege>,
167
168    /// Membership edges (opt-in).
169    #[serde(default)]
170    pub memberships: Vec<Membership>,
171
172    /// Explicit role-retirement workflows for roles that should be removed.
173    #[serde(default)]
174    pub retirements: Vec<RoleRetirement>,
175}
176
177/// Cloud authentication provider configuration.
178///
179/// Declares awareness of cloud IAM-mapped roles so pgroles can correctly
180/// reference auto-created role names in grants and memberships.
181#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
182#[serde(tag = "type", rename_all = "snake_case")]
183pub enum AuthProvider {
184    /// Google Cloud SQL IAM authentication.
185    /// Service accounts map to PG roles like `user@project.iam`.
186    CloudSqlIam {
187        /// GCP project ID (for documentation/validation).
188        #[serde(default)]
189        project: Option<String>,
190    },
191    /// Google AlloyDB IAM authentication.
192    /// IAM users and groups map to PostgreSQL roles managed by AlloyDB.
193    #[serde(rename = "alloydb_iam")]
194    AlloyDbIam {
195        /// GCP project ID (for documentation/validation).
196        #[serde(default)]
197        project: Option<String>,
198        /// AlloyDB cluster name (for documentation/validation).
199        #[serde(default)]
200        cluster: Option<String>,
201    },
202    /// AWS RDS IAM authentication.
203    /// IAM users authenticate via token; the PG role must have `rds_iam` granted.
204    RdsIam {
205        /// AWS region (for documentation/validation).
206        #[serde(default)]
207        region: Option<String>,
208    },
209    /// Azure Entra ID (AAD) authentication for Azure Database for PostgreSQL.
210    AzureAd {
211        /// Azure tenant ID (for documentation/validation).
212        #[serde(default)]
213        tenant_id: Option<String>,
214    },
215    /// Supabase-managed PostgreSQL authentication.
216    Supabase {
217        /// Supabase project ref (for documentation/validation).
218        #[serde(default)]
219        project_ref: Option<String>,
220    },
221    /// PlanetScale PostgreSQL authentication metadata.
222    PlanetScale {
223        /// PlanetScale organization (for documentation/validation).
224        #[serde(default)]
225        organization: Option<String>,
226    },
227}
228
229/// A reusable privilege profile — defines what grants a role should have.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Profile {
232    #[serde(default)]
233    pub login: Option<bool>,
234
235    #[serde(default)]
236    pub inherit: Option<bool>,
237
238    #[serde(default)]
239    pub grants: Vec<ProfileGrant>,
240
241    #[serde(default)]
242    pub default_privileges: Vec<DefaultPrivilegeGrant>,
243
244    /// Role-level configuration parameter defaults for generated roles,
245    /// applied via `ALTER ROLE ... SET parameter = value`. Values support the
246    /// `{schema}` and `{profile}` placeholders (the same two `role_pattern`
247    /// supports), substituted per `schema x profile` expansion — e.g.
248    /// `search_path: "{schema}"` becomes `search_path: inventory` on the role
249    /// generated for the `inventory` schema. Keys are literal PostgreSQL
250    /// parameter names; placeholders are not substituted in keys (a `{schema}`
251    /// key is rejected by [`is_valid_config_parameter_name`], same as any
252    /// other invalid parameter name). Values are always strings — see
253    /// [`ConfigValue`].
254    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
255    pub config: BTreeMap<String, ConfigValue>,
256}
257
258/// A grant template within a profile (schema is filled in during expansion).
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct ProfileGrant {
261    pub privileges: Vec<Privilege>,
262    #[serde(alias = "on")]
263    pub object: ProfileObjectTarget,
264}
265
266/// Object target within a profile — schema is omitted (filled during expansion).
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct ProfileObjectTarget {
269    #[serde(rename = "type")]
270    pub object_type: ObjectType,
271    /// Object name, or "*" for all objects of this type. Omit for schema-level grants.
272    #[serde(default)]
273    pub name: Option<String>,
274}
275
276/// A schema binding — associates a schema with one or more profiles.
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
278pub struct SchemaBinding {
279    pub name: String,
280
281    #[serde(default)]
282    pub profiles: Vec<String>,
283
284    /// Role naming pattern. Supports `{schema}` and `{profile}` placeholders.
285    /// Defaults to `"{schema}-{profile}"`.
286    #[serde(default = "default_role_pattern")]
287    pub role_pattern: String,
288
289    /// Override default_owner for this schema's default privileges.
290    #[serde(default)]
291    pub owner: Option<String>,
292}
293
294#[derive(
295    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
296)]
297#[serde(rename_all = "snake_case")]
298pub enum SchemaBindingFacet {
299    Owner,
300    Bindings,
301}
302
303impl std::fmt::Display for SchemaBindingFacet {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        match self {
306            SchemaBindingFacet::Owner => write!(f, "owner"),
307            SchemaBindingFacet::Bindings => write!(f, "bindings"),
308        }
309    }
310}
311
312pub(crate) fn default_role_pattern() -> String {
313    "{schema}-{profile}".to_string()
314}
315
316/// Substitute the `{schema}` and `{profile}` placeholders in a profile
317/// `config` value — the same two placeholders `role_pattern` supports.
318/// Values without either placeholder are returned unchanged.
319fn substitute_placeholders(value: &str, schema: &str, profile: &str) -> String {
320    value
321        .replace("{schema}", schema)
322        .replace("{profile}", profile)
323}
324
325fn is_false(value: &bool) -> bool {
326    !*value
327}
328
329/// A concrete role definition.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct RoleDefinition {
332    pub name: String,
333
334    /// Treat this role as managed by another system. pgroles may reference it
335    /// in grants, ownership, and memberships, but will not create, alter, drop,
336    /// password-manage, or manage memberships granted from this role.
337    #[serde(default, skip_serializing_if = "is_false")]
338    pub external: bool,
339
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub login: Option<bool>,
342
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub superuser: Option<bool>,
345
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub createdb: Option<bool>,
348
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub createrole: Option<bool>,
351
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub inherit: Option<bool>,
354
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub replication: Option<bool>,
357
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub bypassrls: Option<bool>,
360
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub connection_limit: Option<i32>,
363
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub comment: Option<String>,
366
367    /// Password source for this role. Passwords are never stored in the manifest
368    /// directly — only a reference to an environment variable is allowed.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub password: Option<PasswordSource>,
371
372    /// Password expiration timestamp (ISO 8601, e.g. "2025-12-31T00:00:00Z").
373    /// Maps to PostgreSQL's `VALID UNTIL` clause.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub password_valid_until: Option<String>,
376
377    /// Role-level configuration parameter defaults, applied via
378    /// `ALTER ROLE ... SET parameter = value`. Keys are PostgreSQL setting
379    /// names (e.g. `role`, `search_path`, `statement_timeout`); values are
380    /// applied as string literals. Settings present on the role in the
381    /// database but absent here are `RESET` in authoritative mode.
382    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
383    pub config: BTreeMap<String, ConfigValue>,
384}
385
386/// A role configuration parameter value.
387///
388/// Values are always strings — quote numbers and booleans (e.g.
389/// `statement_timeout: "30000"`, `jit: "off"`). The Kubernetes CRD schema
390/// types config values as strings, and the CLI enforces the same rule so a
391/// manifest means the same thing whether it is applied with `pgroles` or
392/// `kubectl`. PostgreSQL coerces the string to the parameter's type.
393#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
394#[serde(transparent)]
395pub struct ConfigValue(pub String);
396
397impl<'de> Deserialize<'de> for ConfigValue {
398    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
399    where
400        D: serde::Deserializer<'de>,
401    {
402        struct ConfigValueVisitor;
403
404        impl serde::de::Visitor<'_> for ConfigValueVisitor {
405            type Value = ConfigValue;
406
407            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408                f.write_str("a string")
409            }
410
411            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
412                Ok(ConfigValue(v.to_string()))
413            }
414
415            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
416                Ok(ConfigValue(v))
417            }
418
419            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
420                Err(E::custom(format!(
421                    "config values must be quoted strings: write \"{v}\" instead of {v}"
422                )))
423            }
424
425            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
426                Err(E::custom(format!(
427                    "config values must be quoted strings: write \"{v}\" instead of {v}"
428                )))
429            }
430
431            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
432                Err(E::custom(format!(
433                    "config values must be quoted strings: write \"{v}\" instead of {v}"
434                )))
435            }
436
437            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> {
438                let suggestion = if v { "on" } else { "off" };
439                Err(E::custom(format!(
440                    "config values must be quoted strings: write \"{suggestion}\" (or \"{v}\") instead of {v}"
441                )))
442            }
443        }
444
445        deserializer.deserialize_any(ConfigValueVisitor)
446    }
447}
448
449/// Validate a PostgreSQL configuration parameter name: one or more
450/// letter/underscore-led identifier segments separated by dots (custom GUCs
451/// like `app.tenant` are dot-qualified).
452pub fn is_valid_config_parameter_name(name: &str) -> bool {
453    !name.is_empty()
454        && name.split('.').all(|segment| {
455            let mut chars = segment.chars();
456            matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
457                && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
458        })
459}
460
461/// Source for a role password. Passwords are never stored in YAML manifests.
462///
463/// This follows the same security model as `DATABASE_URL` — secrets come from
464/// the runtime environment, not from configuration files.
465#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
466pub struct PasswordSource {
467    /// Name of the environment variable containing the password.
468    pub from_env: String,
469}
470
471/// A concrete grant on a specific object or wildcard.
472#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
473pub struct Grant {
474    pub role: String,
475    pub privileges: Vec<Privilege>,
476    #[serde(alias = "on")]
477    pub object: ObjectTarget,
478}
479
480/// Target object for a grant.
481#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
482pub struct ObjectTarget {
483    #[serde(rename = "type")]
484    pub object_type: ObjectType,
485
486    /// Schema name. Required for most object types except database.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub schema: Option<String>,
489
490    /// Object name, or "*" for all objects. Omit for schema-level grants.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub name: Option<String>,
493}
494
495/// Default privilege configuration.
496#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
497pub struct DefaultPrivilege {
498    /// The role that owns newly created objects. If omitted, uses manifest's default_owner.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub owner: Option<String>,
501
502    pub schema: String,
503
504    pub grant: Vec<DefaultPrivilegeGrant>,
505}
506
507/// A single default privilege grant entry.
508#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
509pub struct DefaultPrivilegeGrant {
510    /// The role receiving the default privilege. Only used in top-level default_privileges
511    /// (in profiles, the role is determined by expansion).
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub role: Option<String>,
514
515    pub privileges: Vec<Privilege>,
516    pub on_type: ObjectType,
517}
518
519/// A membership declaration — which members belong to a role.
520#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
521pub struct Membership {
522    pub role: String,
523    pub members: Vec<MemberSpec>,
524}
525
526/// A single member of a role.
527///
528/// Both `inherit` and `admin` are optional. When omitted, they default to
529/// `inherit: true` and `admin: false` at resolution time (in `RoleGraph`
530/// construction). Keeping them optional in the CRD avoids Kubernetes
531/// injecting default values into the stored resource, which causes
532/// perpetual diffs in GitOps tools like ArgoCD.
533#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
534pub struct MemberSpec {
535    pub name: String,
536
537    /// Whether the member inherits the role's privileges. Defaults to `true`.
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub inherit: Option<bool>,
540
541    /// Whether the member can administer the role. Defaults to `false`.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub admin: Option<bool>,
544}
545
546impl MemberSpec {
547    /// Resolve `inherit` with its default (true).
548    pub fn inherit(&self) -> bool {
549        self.inherit.unwrap_or(true)
550    }
551
552    /// Resolve `admin` with its default (false).
553    pub fn admin(&self) -> bool {
554        self.admin.unwrap_or(false)
555    }
556}
557
558/// Declarative workflow for retiring an existing role.
559#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
560pub struct RoleRetirement {
561    /// The role to retire and ultimately drop.
562    pub role: String,
563
564    /// Optional successor role for `REASSIGN OWNED BY ... TO ...`.
565    #[serde(default)]
566    pub reassign_owned_to: Option<String>,
567
568    /// Whether to run `DROP OWNED BY` before dropping the role.
569    #[serde(default)]
570    pub drop_owned: bool,
571
572    /// Whether to terminate other active sessions for the role before drop.
573    #[serde(default)]
574    pub terminate_sessions: bool,
575}
576
577// ---------------------------------------------------------------------------
578// Expanded manifest — the result of profile expansion
579// ---------------------------------------------------------------------------
580
581/// The fully expanded policy — all profiles resolved into concrete roles, grants,
582/// default privileges, and memberships. Ready to be converted into a `RoleGraph`.
583#[derive(Debug, Clone)]
584pub struct ExpandedManifest {
585    pub schemas: Vec<ExpandedSchema>,
586    pub roles: Vec<RoleDefinition>,
587    pub grants: Vec<Grant>,
588    pub default_privileges: Vec<DefaultPrivilege>,
589    pub memberships: Vec<Membership>,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct ExpandedSchema {
594    pub name: String,
595    pub owner: Option<String>,
596}
597
598// ---------------------------------------------------------------------------
599// Expansion logic
600// ---------------------------------------------------------------------------
601
602/// Parse a YAML string into a `PolicyManifest`.
603///
604/// Accepts both bare manifests and Kubernetes CustomResource wrappers.
605/// If the YAML contains an `apiVersion` and `spec` field, the `spec` is
606/// extracted and parsed as a `PolicyManifest`.
607pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
608    // Check if this looks like a Kubernetes CR wrapper.
609    let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
610    if let serde_yaml::Value::Mapping(ref map) = value {
611        let api_version_key = serde_yaml::Value::String("apiVersion".into());
612        let spec_key = serde_yaml::Value::String("spec".into());
613        if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
614            let spec = map.get(&spec_key).ok_or_else(|| {
615                ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
616            })?;
617            let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
618            return Ok(manifest);
619        }
620    }
621    let manifest: PolicyManifest = serde_yaml::from_value(value)?;
622    Ok(manifest)
623}
624
625/// Expand a `PolicyManifest` by resolving all `profiles × schemas` into concrete
626/// roles, grants, and default privileges. Merges with one-off definitions.
627/// Validates no duplicate role names.
628pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
629    let mut seen_schemas: HashSet<String> = HashSet::new();
630    for schema_binding in &manifest.schemas {
631        if !seen_schemas.insert(schema_binding.name.clone()) {
632            return Err(ManifestError::DuplicateSchema(schema_binding.name.clone()));
633        }
634    }
635
636    let schemas: Vec<ExpandedSchema> = manifest
637        .schemas
638        .iter()
639        .map(|schema_binding| ExpandedSchema {
640            name: schema_binding.name.clone(),
641            owner: schema_binding
642                .owner
643                .clone()
644                .or(manifest.default_owner.clone()),
645        })
646        .collect();
647    let mut roles: Vec<RoleDefinition> = Vec::new();
648    let mut grants: Vec<Grant> = Vec::new();
649    let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
650
651    // Expand each schema × profile combination
652    for schema_binding in &manifest.schemas {
653        for profile_name in &schema_binding.profiles {
654            let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
655                ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
656            })?;
657
658            // Validate pattern contains {profile}
659            if !schema_binding.role_pattern.contains("{profile}") {
660                return Err(ManifestError::InvalidRolePattern(
661                    schema_binding.role_pattern.clone(),
662                ));
663            }
664
665            // Generate role name from pattern
666            let role_name = schema_binding
667                .role_pattern
668                .replace("{schema}", &schema_binding.name)
669                .replace("{profile}", profile_name);
670
671            // Expand profile config — substitute {schema}/{profile} in VALUES
672            // only. Keys are literal PostgreSQL parameter names; a `{schema}`
673            // or `{profile}` key is not a valid identifier and is rejected by
674            // the parameter-name validation below, which is the desired
675            // outcome (placeholders only make sense in values).
676            let config: BTreeMap<String, ConfigValue> = profile
677                .config
678                .iter()
679                .map(|(parameter, value)| {
680                    let substituted =
681                        substitute_placeholders(&value.0, &schema_binding.name, profile_name);
682                    (parameter.clone(), ConfigValue(substituted))
683                })
684                .collect();
685
686            // Create role definition
687            roles.push(RoleDefinition {
688                name: role_name.clone(),
689                external: false,
690                login: profile.login,
691                superuser: None,
692                createdb: None,
693                createrole: None,
694                inherit: profile.inherit,
695                replication: None,
696                bypassrls: None,
697                connection_limit: None,
698                comment: Some(format!(
699                    "Generated from profile '{profile_name}' for schema '{}'",
700                    schema_binding.name
701                )),
702                password: None,
703                password_valid_until: None,
704                config,
705            });
706
707            // Expand profile grants — fill in schema
708            for profile_grant in &profile.grants {
709                let object_target = match profile_grant.object.object_type {
710                    ObjectType::Schema => ObjectTarget {
711                        object_type: ObjectType::Schema,
712                        schema: None,
713                        name: Some(schema_binding.name.clone()),
714                    },
715                    _ => ObjectTarget {
716                        object_type: profile_grant.object.object_type,
717                        schema: Some(schema_binding.name.clone()),
718                        name: profile_grant.object.name.clone(),
719                    },
720                };
721
722                grants.push(Grant {
723                    role: role_name.clone(),
724                    privileges: profile_grant.privileges.clone(),
725                    object: object_target,
726                });
727            }
728
729            // Expand profile default privileges
730            if !profile.default_privileges.is_empty() {
731                let owner = schema_binding
732                    .owner
733                    .clone()
734                    .or(manifest.default_owner.clone());
735
736                let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
737                    .default_privileges
738                    .iter()
739                    .map(|dp| DefaultPrivilegeGrant {
740                        role: Some(role_name.clone()),
741                        privileges: dp.privileges.clone(),
742                        on_type: dp.on_type,
743                    })
744                    .collect();
745
746                default_privileges.push(DefaultPrivilege {
747                    owner,
748                    schema: schema_binding.name.clone(),
749                    grant: expanded_grants,
750                });
751            }
752        }
753    }
754
755    // Top-level default privileges must always identify the grantee role.
756    for default_priv in &manifest.default_privileges {
757        for grant in &default_priv.grant {
758            if grant.role.is_none() {
759                return Err(ManifestError::MissingDefaultPrivilegeRole {
760                    schema: default_priv.schema.clone(),
761                });
762            }
763        }
764    }
765
766    // Merge one-off definitions
767    roles.extend(manifest.roles.clone());
768    grants.extend(manifest.grants.clone());
769    default_privileges.extend(manifest.default_privileges.clone());
770    let memberships = manifest.memberships.clone();
771
772    // Validate no duplicate role names
773    let mut seen_roles: HashSet<String> = HashSet::new();
774    for role in &roles {
775        if seen_roles.contains(&role.name) {
776            return Err(ManifestError::DuplicateRole(role.name.clone()));
777        }
778        seen_roles.insert(role.name.clone());
779    }
780
781    let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
782    let mut seen_retirements: HashSet<String> = HashSet::new();
783    for retirement in &manifest.retirements {
784        if seen_retirements.contains(&retirement.role) {
785            return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
786        }
787        if desired_role_names.contains(&retirement.role) {
788            return Err(ManifestError::RetirementRoleStillDesired(
789                retirement.role.clone(),
790            ));
791        }
792        if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
793            return Err(ManifestError::RetirementSelfReassign {
794                role: retirement.role.clone(),
795            });
796        }
797        seen_retirements.insert(retirement.role.clone());
798    }
799
800    // Validate: password on a non-login role is an error.
801    // We require login to be explicitly true — if login is None (defaults to false)
802    // a password would be useless.
803    for role in &roles {
804        if role.password.is_some() && role.login != Some(true) {
805            return Err(ManifestError::PasswordWithoutLogin {
806                role: role.name.clone(),
807            });
808        }
809    }
810
811    // Validate: password_valid_until must be a valid ISO 8601 timestamp.
812    for role in &roles {
813        if let Some(value) = &role.password_valid_until
814            && !is_valid_iso8601_timestamp(value)
815        {
816            return Err(ManifestError::InvalidValidUntil {
817                role: role.name.clone(),
818                value: value.clone(),
819            });
820        }
821    }
822
823    // Validate: config parameter names must be well-formed. A `role` setting
824    // whose target is declared in this manifest must be backed by a declared
825    // membership, otherwise PostgreSQL rejects the setting at login time
826    // ("permission denied to set role"). Targets not declared here may be
827    // externally managed, so we only enforce what we can see.
828    for role in &roles {
829        for (parameter, value) in &role.config {
830            if !is_valid_config_parameter_name(parameter) {
831                return Err(ManifestError::InvalidConfigParameter {
832                    role: role.name.clone(),
833                    parameter: parameter.clone(),
834                });
835            }
836            if parameter.eq_ignore_ascii_case("role") {
837                let target = value.0.as_str();
838                let target_declared = desired_role_names.contains(target);
839                let membership_declared = memberships.iter().any(|membership| {
840                    membership.role == target
841                        && membership
842                            .members
843                            .iter()
844                            .any(|member| member.name == role.name)
845                });
846                if target_declared && !membership_declared {
847                    return Err(ManifestError::SetRoleWithoutMembership {
848                        role: role.name.clone(),
849                        target: target.to_string(),
850                    });
851                }
852            }
853        }
854    }
855
856    Ok(ExpandedManifest {
857        schemas,
858        roles,
859        grants,
860        default_privileges,
861        memberships,
862    })
863}
864
865// ---------------------------------------------------------------------------
866// Validation helpers
867// ---------------------------------------------------------------------------
868
869/// Validate that a string is a plausible ISO 8601 timestamp.
870///
871/// Accepts formats like:
872/// - `2025-12-31T00:00:00Z`
873/// - `2025-12-31T00:00:00+00:00`
874/// - `2025-12-31T00:00:00-05:00`
875/// - `2025-12-31T00:00:00.123Z`
876///
877/// This validates structure and numeric ranges (month 01-12, day 01-31,
878/// hour 00-23, minute/second 00-59). It does not check calendar validity
879/// (e.g. Feb 30 passes). PostgreSQL itself will reject truly invalid dates.
880fn is_valid_iso8601_timestamp(value: &str) -> bool {
881    // Minimum valid: "YYYY-MM-DDTHH:MM:SSZ" = 20 chars
882    if value.len() < 20 {
883        return false;
884    }
885
886    let bytes = value.as_bytes();
887
888    // Check date part: YYYY-MM-DD
889    if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
890        return false;
891    }
892
893    let year = &value[0..4];
894    let month = &value[5..7];
895    let day = &value[8..10];
896
897    let Ok(y) = year.parse::<u16>() else {
898        return false;
899    };
900    let Ok(m) = month.parse::<u8>() else {
901        return false;
902    };
903    let Ok(d) = day.parse::<u8>() else {
904        return false;
905    };
906
907    if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
908        return false;
909    }
910
911    // Check time part: HH:MM:SS
912    if bytes[13] != b':' || bytes[16] != b':' {
913        return false;
914    }
915
916    let hour = &value[11..13];
917    let minute = &value[14..16];
918    let second = &value[17..19];
919
920    let Ok(h) = hour.parse::<u8>() else {
921        return false;
922    };
923    let Ok(min) = minute.parse::<u8>() else {
924        return false;
925    };
926    let Ok(sec) = second.parse::<u8>() else {
927        return false;
928    };
929
930    if h > 23 || min > 59 || sec > 59 {
931        return false;
932    }
933
934    // Remaining suffix must be a valid timezone indicator.
935    let suffix = &value[19..];
936
937    // Handle optional fractional seconds: .NNN
938    let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
939        // Skip digits after the decimal point
940        let frac_end = rest
941            .find(|c: char| !c.is_ascii_digit())
942            .unwrap_or(rest.len());
943        if frac_end == 0 {
944            return false; // "." with no digits
945        }
946        &rest[frac_end..]
947    } else {
948        suffix
949    };
950
951    // Valid timezone indicators: "Z", "+HH:MM", "-HH:MM"
952    match tz_part {
953        "Z" => true,
954        s if (s.starts_with('+') || s.starts_with('-'))
955            && s.len() == 6
956            && s.as_bytes()[3] == b':' =>
957        {
958            let Ok(tz_h) = s[1..3].parse::<u8>() else {
959                return false;
960            };
961            let Ok(tz_m) = s[4..6].parse::<u8>() else {
962                return false;
963            };
964            tz_h <= 14 && tz_m <= 59
965        }
966        _ => false,
967    }
968}
969
970// ---------------------------------------------------------------------------
971// Tests
972// ---------------------------------------------------------------------------
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    #[test]
979    fn parse_minimal_role() {
980        let yaml = r#"
981roles:
982  - name: test-role
983"#;
984        let manifest = parse_manifest(yaml).unwrap();
985        assert_eq!(manifest.roles.len(), 1);
986        assert_eq!(manifest.roles[0].name, "test-role");
987        assert!(manifest.roles[0].login.is_none());
988    }
989
990    #[test]
991    fn parse_role_config_accepts_strings() {
992        let yaml = r#"
993roles:
994  - name: blue
995    login: true
996    config:
997      role: combined
998      statement_timeout: "30000"
999      jit: "off"
1000"#;
1001        let manifest = parse_manifest(yaml).unwrap();
1002        let config = &manifest.roles[0].config;
1003        assert_eq!(config["role"].0, "combined");
1004        assert_eq!(config["statement_timeout"].0, "30000");
1005        assert_eq!(config["jit"].0, "off");
1006    }
1007
1008    #[test]
1009    fn parse_role_config_rejects_unquoted_number() {
1010        // The CRD schema types config values as strings, so the CLI enforces
1011        // the same rule — the same manifest must be valid in both paths.
1012        let yaml = r#"
1013roles:
1014  - name: blue
1015    config:
1016      statement_timeout: 30000
1017"#;
1018        let err = parse_manifest(yaml).unwrap_err();
1019        assert!(
1020            err.to_string().contains("write \"30000\" instead of 30000"),
1021            "unexpected error: {err}"
1022        );
1023    }
1024
1025    #[test]
1026    fn parse_role_config_rejects_unquoted_boolean() {
1027        let yaml = r#"
1028roles:
1029  - name: blue
1030    config:
1031      jit: false
1032"#;
1033        let err = parse_manifest(yaml).unwrap_err();
1034        assert!(
1035            err.to_string().contains("write \"off\""),
1036            "unexpected error: {err}"
1037        );
1038    }
1039
1040    #[test]
1041    fn expand_rejects_invalid_config_parameter_name() {
1042        let yaml = r#"
1043roles:
1044  - name: blue
1045    config:
1046      "bad name; DROP TABLE": x
1047"#;
1048        let manifest = parse_manifest(yaml).unwrap();
1049        let err = expand_manifest(&manifest).unwrap_err();
1050        assert!(matches!(err, ManifestError::InvalidConfigParameter { .. }));
1051    }
1052
1053    #[test]
1054    fn expand_rejects_set_role_without_declared_membership() {
1055        let yaml = r#"
1056roles:
1057  - name: blue
1058    login: true
1059    config:
1060      role: combined
1061  - name: combined
1062"#;
1063        let manifest = parse_manifest(yaml).unwrap();
1064        let err = expand_manifest(&manifest).unwrap_err();
1065        assert!(matches!(
1066            err,
1067            ManifestError::SetRoleWithoutMembership { role, target }
1068                if role == "blue" && target == "combined"
1069        ));
1070    }
1071
1072    #[test]
1073    fn expand_accepts_set_role_with_declared_membership() {
1074        let yaml = r#"
1075roles:
1076  - name: blue
1077    login: true
1078    config:
1079      role: combined
1080  - name: combined
1081
1082memberships:
1083  - role: combined
1084    members:
1085      - name: blue
1086"#;
1087        let manifest = parse_manifest(yaml).unwrap();
1088        assert!(expand_manifest(&manifest).is_ok());
1089    }
1090
1091    #[test]
1092    fn expand_accepts_set_role_to_undeclared_target() {
1093        // Target role not declared in the manifest — assumed externally
1094        // managed, so no membership can be verified.
1095        let yaml = r#"
1096roles:
1097  - name: blue
1098    login: true
1099    config:
1100      role: external_combined
1101"#;
1102        let manifest = parse_manifest(yaml).unwrap();
1103        assert!(expand_manifest(&manifest).is_ok());
1104    }
1105
1106    #[test]
1107    fn config_parameter_name_validation() {
1108        assert!(is_valid_config_parameter_name("role"));
1109        assert!(is_valid_config_parameter_name("search_path"));
1110        assert!(is_valid_config_parameter_name("app.tenant"));
1111        assert!(is_valid_config_parameter_name("_x.y2"));
1112        assert!(!is_valid_config_parameter_name(""));
1113        assert!(!is_valid_config_parameter_name("2bad"));
1114        assert!(!is_valid_config_parameter_name("bad name"));
1115        assert!(!is_valid_config_parameter_name("bad;name"));
1116        assert!(!is_valid_config_parameter_name("trailing."));
1117        assert!(!is_valid_config_parameter_name(".leading"));
1118    }
1119
1120    #[test]
1121    fn parse_full_policy() {
1122        let yaml = r#"
1123default_owner: app_owner
1124
1125profiles:
1126  editor:
1127    login: false
1128    grants:
1129      - privileges: [USAGE]
1130        object: { type: schema }
1131      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
1132        object: { type: table, name: "*" }
1133      - privileges: [USAGE, SELECT, UPDATE]
1134        object: { type: sequence, name: "*" }
1135      - privileges: [EXECUTE]
1136        object: { type: function, name: "*" }
1137    default_privileges:
1138      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
1139        on_type: table
1140      - privileges: [USAGE, SELECT, UPDATE]
1141        on_type: sequence
1142      - privileges: [EXECUTE]
1143        on_type: function
1144
1145schemas:
1146  - name: inventory
1147    profiles: [editor]
1148  - name: catalog
1149    profiles: [editor]
1150
1151roles:
1152  - name: analytics-readonly
1153    login: true
1154
1155memberships:
1156  - role: inventory-editor
1157    members:
1158      - name: "alice@example.com"
1159        inherit: true
1160"#;
1161        let manifest = parse_manifest(yaml).unwrap();
1162        assert_eq!(manifest.profiles.len(), 1);
1163        assert_eq!(manifest.schemas.len(), 2);
1164        assert_eq!(manifest.roles.len(), 1);
1165        assert_eq!(manifest.memberships.len(), 1);
1166        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
1167    }
1168
1169    #[test]
1170    fn reject_invalid_yaml() {
1171        let yaml = "not: [valid: yaml: {{";
1172        assert!(parse_manifest(yaml).is_err());
1173    }
1174
1175    #[test]
1176    fn expand_profiles_basic() {
1177        let yaml = r#"
1178profiles:
1179  editor:
1180    login: false
1181    grants:
1182      - privileges: [USAGE]
1183        object: { type: schema }
1184      - privileges: [SELECT, INSERT]
1185        object: { type: table, name: "*" }
1186
1187schemas:
1188  - name: myschema
1189    profiles: [editor]
1190"#;
1191        let manifest = parse_manifest(yaml).unwrap();
1192        let expanded = expand_manifest(&manifest).unwrap();
1193
1194        assert_eq!(expanded.roles.len(), 1);
1195        assert_eq!(expanded.roles[0].name, "myschema-editor");
1196        assert_eq!(expanded.roles[0].login, Some(false));
1197        assert_eq!(expanded.roles[0].inherit, None);
1198
1199        // Schema usage grant + table grant
1200        assert_eq!(expanded.grants.len(), 2);
1201        assert_eq!(expanded.grants[0].role, "myschema-editor");
1202        assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
1203        assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
1204
1205        assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
1206        assert_eq!(
1207            expanded.grants[1].object.schema,
1208            Some("myschema".to_string())
1209        );
1210        assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
1211    }
1212
1213    #[test]
1214    fn expand_schema_owner_overrides_default_owner() {
1215        let yaml = r#"
1216default_owner: app_owner
1217
1218profiles:
1219  editor:
1220    default_privileges:
1221      - privileges: [SELECT]
1222        on_type: table
1223
1224schemas:
1225  - name: inventory
1226    owner: inventory_owner
1227    profiles: [editor]
1228  - name: catalog
1229    profiles: [editor]
1230"#;
1231
1232        let manifest = parse_manifest(yaml).unwrap();
1233        let expanded = expand_manifest(&manifest).unwrap();
1234
1235        assert_eq!(
1236            expanded.schemas,
1237            vec![
1238                ExpandedSchema {
1239                    name: "inventory".to_string(),
1240                    owner: Some("inventory_owner".to_string()),
1241                },
1242                ExpandedSchema {
1243                    name: "catalog".to_string(),
1244                    owner: Some("app_owner".to_string()),
1245                },
1246            ]
1247        );
1248    }
1249
1250    #[test]
1251    fn expand_profiles_preserves_generated_role_inherit() {
1252        let yaml = r#"
1253profiles:
1254  editor:
1255    login: false
1256    inherit: false
1257    grants:
1258      - privileges: [USAGE]
1259        object: { type: schema }
1260
1261schemas:
1262  - name: myschema
1263    profiles: [editor]
1264"#;
1265
1266        let manifest = parse_manifest(yaml).unwrap();
1267        let expanded = expand_manifest(&manifest).unwrap();
1268
1269        assert_eq!(expanded.roles.len(), 1);
1270        assert_eq!(expanded.roles[0].name, "myschema-editor");
1271        assert_eq!(expanded.roles[0].login, Some(false));
1272        assert_eq!(expanded.roles[0].inherit, Some(false));
1273    }
1274
1275    #[test]
1276    fn expand_declared_schema_with_no_profiles() {
1277        let yaml = r#"
1278schemas:
1279  - name: cdc
1280    owner: cdc_owner
1281    profiles: []
1282"#;
1283
1284        let manifest = parse_manifest(yaml).unwrap();
1285        let expanded = expand_manifest(&manifest).unwrap();
1286
1287        assert_eq!(expanded.schemas.len(), 1);
1288        assert_eq!(expanded.schemas[0].name, "cdc");
1289        assert_eq!(expanded.schemas[0].owner.as_deref(), Some("cdc_owner"));
1290        assert!(expanded.roles.is_empty());
1291        assert!(expanded.grants.is_empty());
1292        assert!(expanded.default_privileges.is_empty());
1293    }
1294
1295    #[test]
1296    fn expand_profiles_multi_schema() {
1297        let yaml = r#"
1298profiles:
1299  editor:
1300    grants:
1301      - privileges: [SELECT]
1302        object: { type: table, name: "*" }
1303  viewer:
1304    grants:
1305      - privileges: [SELECT]
1306        object: { type: table, name: "*" }
1307
1308schemas:
1309  - name: alpha
1310    profiles: [editor, viewer]
1311  - name: beta
1312    profiles: [editor, viewer]
1313  - name: gamma
1314    profiles: [editor]
1315"#;
1316        let manifest = parse_manifest(yaml).unwrap();
1317        let expanded = expand_manifest(&manifest).unwrap();
1318
1319        // 2 + 2 + 1 = 5 roles
1320        assert_eq!(expanded.roles.len(), 5);
1321        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
1322        assert!(role_names.contains(&"alpha-editor"));
1323        assert!(role_names.contains(&"alpha-viewer"));
1324        assert!(role_names.contains(&"beta-editor"));
1325        assert!(role_names.contains(&"beta-viewer"));
1326        assert!(role_names.contains(&"gamma-editor"));
1327    }
1328
1329    #[test]
1330    fn expand_custom_role_pattern() {
1331        let yaml = r#"
1332profiles:
1333  viewer:
1334    grants:
1335      - privileges: [SELECT]
1336        object: { type: table, name: "*" }
1337
1338schemas:
1339  - name: legacy_data
1340    profiles: [viewer]
1341    role_pattern: "legacy-{profile}"
1342"#;
1343        let manifest = parse_manifest(yaml).unwrap();
1344        let expanded = expand_manifest(&manifest).unwrap();
1345
1346        assert_eq!(expanded.roles.len(), 1);
1347        assert_eq!(expanded.roles[0].name, "legacy-viewer");
1348    }
1349
1350    #[test]
1351    fn expand_rejects_duplicate_role_name() {
1352        let yaml = r#"
1353profiles:
1354  editor:
1355    grants: []
1356
1357schemas:
1358  - name: inventory
1359    profiles: [editor]
1360
1361roles:
1362  - name: inventory-editor
1363"#;
1364        let manifest = parse_manifest(yaml).unwrap();
1365        let result = expand_manifest(&manifest);
1366        assert!(result.is_err());
1367        assert!(
1368            result
1369                .unwrap_err()
1370                .to_string()
1371                .contains("duplicate role name")
1372        );
1373    }
1374
1375    #[test]
1376    fn expand_rejects_duplicate_schema_name() {
1377        let yaml = r#"
1378schemas:
1379  - name: inventory
1380    profiles: []
1381  - name: inventory
1382    owner: inventory_owner
1383    profiles: []
1384"#;
1385
1386        let manifest = parse_manifest(yaml).unwrap();
1387        let error = expand_manifest(&manifest).unwrap_err();
1388        assert!(error.to_string().contains("duplicate schema name"));
1389    }
1390
1391    #[test]
1392    fn expand_rejects_undefined_profile() {
1393        let yaml = r#"
1394profiles: {}
1395
1396schemas:
1397  - name: inventory
1398    profiles: [nonexistent]
1399"#;
1400        let manifest = parse_manifest(yaml).unwrap();
1401        let result = expand_manifest(&manifest);
1402        assert!(result.is_err());
1403        assert!(result.unwrap_err().to_string().contains("not defined"));
1404    }
1405
1406    #[test]
1407    fn expand_rejects_invalid_pattern() {
1408        let yaml = r#"
1409profiles:
1410  editor:
1411    grants: []
1412
1413schemas:
1414  - name: inventory
1415    profiles: [editor]
1416    role_pattern: "static-name"
1417"#;
1418        let manifest = parse_manifest(yaml).unwrap();
1419        let result = expand_manifest(&manifest);
1420        assert!(result.is_err());
1421        assert!(
1422            result
1423                .unwrap_err()
1424                .to_string()
1425                .contains("{profile} placeholder")
1426        );
1427    }
1428
1429    #[test]
1430    fn expand_rejects_top_level_default_privilege_without_role() {
1431        let yaml = r#"
1432default_privileges:
1433  - schema: public
1434    grant:
1435      - privileges: [SELECT]
1436        on_type: table
1437"#;
1438        let manifest = parse_manifest(yaml).unwrap();
1439        let result = expand_manifest(&manifest);
1440        assert!(result.is_err());
1441        assert!(
1442            result
1443                .unwrap_err()
1444                .to_string()
1445                .contains("must specify grant.role")
1446        );
1447    }
1448
1449    #[test]
1450    fn expand_default_privileges_with_owner_override() {
1451        let yaml = r#"
1452default_owner: app_owner
1453
1454profiles:
1455  editor:
1456    grants: []
1457    default_privileges:
1458      - privileges: [SELECT]
1459        on_type: table
1460
1461schemas:
1462  - name: inventory
1463    profiles: [editor]
1464  - name: legacy
1465    profiles: [editor]
1466    owner: legacy_admin
1467"#;
1468        let manifest = parse_manifest(yaml).unwrap();
1469        let expanded = expand_manifest(&manifest).unwrap();
1470
1471        assert_eq!(expanded.default_privileges.len(), 2);
1472
1473        // inventory uses default_owner
1474        assert_eq!(
1475            expanded.default_privileges[0].owner,
1476            Some("app_owner".to_string())
1477        );
1478        assert_eq!(expanded.default_privileges[0].schema, "inventory");
1479
1480        // legacy uses override
1481        assert_eq!(
1482            expanded.default_privileges[1].owner,
1483            Some("legacy_admin".to_string())
1484        );
1485        assert_eq!(expanded.default_privileges[1].schema, "legacy");
1486    }
1487
1488    #[test]
1489    fn expand_merges_oneoff_roles_and_grants() {
1490        let yaml = r#"
1491profiles:
1492  editor:
1493    grants:
1494      - privileges: [SELECT]
1495        object: { type: table, name: "*" }
1496
1497schemas:
1498  - name: inventory
1499    profiles: [editor]
1500
1501roles:
1502  - name: analytics
1503    login: true
1504
1505grants:
1506  - role: analytics
1507    privileges: [SELECT]
1508    on:
1509      type: table
1510      schema: inventory
1511      name: "*"
1512"#;
1513        let manifest = parse_manifest(yaml).unwrap();
1514        let expanded = expand_manifest(&manifest).unwrap();
1515
1516        assert_eq!(expanded.roles.len(), 2);
1517        assert_eq!(expanded.grants.len(), 2); // 1 from profile + 1 one-off
1518    }
1519
1520    #[test]
1521    fn parse_manifest_accepts_legacy_on_alias() {
1522        let yaml = r#"
1523grants:
1524  - role: analytics
1525    privileges: [SELECT]
1526    on:
1527      type: table
1528      schema: public
1529      name: "*"
1530"#;
1531        let manifest = parse_manifest(yaml).unwrap();
1532        assert_eq!(manifest.grants.len(), 1);
1533        assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1534        assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1535        assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1536    }
1537
1538    #[test]
1539    fn parse_membership_with_email_roles() {
1540        let yaml = r#"
1541memberships:
1542  - role: inventory-editor
1543    members:
1544      - name: "alice@example.com"
1545        inherit: true
1546      - name: "engineering@example.com"
1547        admin: true
1548"#;
1549        let manifest = parse_manifest(yaml).unwrap();
1550        assert_eq!(manifest.memberships.len(), 1);
1551        assert_eq!(manifest.memberships[0].members.len(), 2);
1552        assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1553        assert_eq!(manifest.memberships[0].members[0].inherit, Some(true));
1554        assert_eq!(manifest.memberships[0].members[1].admin, Some(true));
1555    }
1556
1557    #[test]
1558    fn member_spec_defaults() {
1559        let yaml = r#"
1560memberships:
1561  - role: some-role
1562    members:
1563      - name: user1
1564"#;
1565        let manifest = parse_manifest(yaml).unwrap();
1566        // When omitted, both fields are None (defaults applied at resolution time).
1567        assert_eq!(manifest.memberships[0].members[0].inherit, None);
1568        assert_eq!(manifest.memberships[0].members[0].admin, None);
1569        // Accessor methods still return the expected defaults.
1570        assert!(manifest.memberships[0].members[0].inherit());
1571        assert!(!manifest.memberships[0].members[0].admin());
1572    }
1573
1574    #[test]
1575    fn expand_rejects_duplicate_retirements() {
1576        let yaml = r#"
1577retirements:
1578  - role: old-app
1579  - role: old-app
1580"#;
1581        let manifest = parse_manifest(yaml).unwrap();
1582        let result = expand_manifest(&manifest);
1583        assert!(matches!(
1584            result,
1585            Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1586        ));
1587    }
1588
1589    #[test]
1590    fn expand_rejects_retirement_for_desired_role() {
1591        let yaml = r#"
1592roles:
1593  - name: old-app
1594
1595retirements:
1596  - role: old-app
1597"#;
1598        let manifest = parse_manifest(yaml).unwrap();
1599        let result = expand_manifest(&manifest);
1600        assert!(matches!(
1601            result,
1602            Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1603        ));
1604    }
1605
1606    #[test]
1607    fn expand_rejects_self_reassign_retirement() {
1608        let yaml = r#"
1609retirements:
1610  - role: old-app
1611    reassign_owned_to: old-app
1612"#;
1613        let manifest = parse_manifest(yaml).unwrap();
1614        let result = expand_manifest(&manifest);
1615        assert!(matches!(
1616            result,
1617            Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1618        ));
1619    }
1620
1621    #[test]
1622    fn parse_auth_providers() {
1623        let yaml = r#"
1624auth_providers:
1625  - type: cloud_sql_iam
1626    project: my-gcp-project
1627  - type: alloydb_iam
1628    project: my-gcp-project
1629    cluster: analytics-prod
1630  - type: rds_iam
1631    region: us-east-1
1632  - type: azure_ad
1633    tenant_id: "abc-123"
1634  - type: supabase
1635    project_ref: myprojref
1636  - type: planet_scale
1637    organization: my-org
1638
1639roles:
1640  - name: app-service
1641"#;
1642        let manifest = parse_manifest(yaml).unwrap();
1643        assert_eq!(manifest.auth_providers.len(), 6);
1644        assert!(matches!(
1645            &manifest.auth_providers[0],
1646            AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1647        ));
1648        assert!(matches!(
1649            &manifest.auth_providers[1],
1650            AuthProvider::AlloyDbIam {
1651                project: Some(p),
1652                cluster: Some(c)
1653            } if p == "my-gcp-project" && c == "analytics-prod"
1654        ));
1655        assert!(matches!(
1656            &manifest.auth_providers[2],
1657            AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1658        ));
1659        assert!(matches!(
1660            &manifest.auth_providers[3],
1661            AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1662        ));
1663        assert!(matches!(
1664            &manifest.auth_providers[4],
1665            AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1666        ));
1667        assert!(matches!(
1668            &manifest.auth_providers[5],
1669            AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1670        ));
1671    }
1672
1673    #[test]
1674    fn parse_manifest_without_auth_providers() {
1675        let yaml = r#"
1676roles:
1677  - name: test-role
1678"#;
1679        let manifest = parse_manifest(yaml).unwrap();
1680        assert!(manifest.auth_providers.is_empty());
1681    }
1682
1683    #[test]
1684    fn parse_role_with_password_source() {
1685        let yaml = r#"
1686roles:
1687  - name: app-service
1688    login: true
1689    password:
1690      from_env: APP_SERVICE_PASSWORD
1691    password_valid_until: "2025-12-31T00:00:00Z"
1692"#;
1693        let manifest = parse_manifest(yaml).unwrap();
1694        assert_eq!(manifest.roles.len(), 1);
1695        let role = &manifest.roles[0];
1696        assert!(role.password.is_some());
1697        assert_eq!(
1698            role.password.as_ref().unwrap().from_env,
1699            "APP_SERVICE_PASSWORD"
1700        );
1701        assert_eq!(
1702            role.password_valid_until,
1703            Some("2025-12-31T00:00:00Z".to_string())
1704        );
1705    }
1706
1707    #[test]
1708    fn parse_role_without_password() {
1709        let yaml = r#"
1710roles:
1711  - name: app-service
1712    login: true
1713"#;
1714        let manifest = parse_manifest(yaml).unwrap();
1715        assert!(manifest.roles[0].password.is_none());
1716        assert!(manifest.roles[0].password_valid_until.is_none());
1717    }
1718
1719    #[test]
1720    fn reject_password_on_nologin_role() {
1721        let yaml = r#"
1722roles:
1723  - name: nologin-role
1724    login: false
1725    password:
1726      from_env: SOME_PASSWORD
1727"#;
1728        let manifest = parse_manifest(yaml).unwrap();
1729        let result = expand_manifest(&manifest);
1730        assert!(result.is_err());
1731        assert!(
1732            result
1733                .unwrap_err()
1734                .to_string()
1735                .contains("login is not enabled")
1736        );
1737    }
1738
1739    #[test]
1740    fn reject_password_on_default_login_role() {
1741        // login is None (defaults to NOLOGIN) — password should still be rejected
1742        let yaml = r#"
1743roles:
1744  - name: implicit-nologin-role
1745    password:
1746      from_env: SOME_PASSWORD
1747"#;
1748        let manifest = parse_manifest(yaml).unwrap();
1749        let result = expand_manifest(&manifest);
1750        assert!(result.is_err());
1751        assert!(
1752            result
1753                .unwrap_err()
1754                .to_string()
1755                .contains("login is not enabled")
1756        );
1757    }
1758
1759    #[test]
1760    fn reject_invalid_password_valid_until() {
1761        let yaml = r#"
1762roles:
1763  - name: bad-date
1764    login: true
1765    password_valid_until: "not-a-date"
1766"#;
1767        let manifest = parse_manifest(yaml).unwrap();
1768        let result = expand_manifest(&manifest);
1769        assert!(result.is_err());
1770        assert!(
1771            result
1772                .unwrap_err()
1773                .to_string()
1774                .contains("invalid password_valid_until")
1775        );
1776    }
1777
1778    #[test]
1779    fn reject_date_only_valid_until() {
1780        let yaml = r#"
1781roles:
1782  - name: bad-date
1783    login: true
1784    password_valid_until: "2025-12-31"
1785"#;
1786        let manifest = parse_manifest(yaml).unwrap();
1787        let result = expand_manifest(&manifest);
1788        assert!(result.is_err());
1789    }
1790
1791    #[test]
1792    fn accept_valid_iso8601_timestamps() {
1793        // UTC with Z
1794        assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1795        // With timezone offset
1796        assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1797        assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1798        // With fractional seconds
1799        assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1800    }
1801
1802    #[test]
1803    fn reject_invalid_iso8601_timestamps() {
1804        assert!(!is_valid_iso8601_timestamp("not-a-date"));
1805        assert!(!is_valid_iso8601_timestamp("2025-12-31")); // date only
1806        assert!(!is_valid_iso8601_timestamp("2025-13-31T00:00:00Z")); // month 13
1807        assert!(!is_valid_iso8601_timestamp("2025-12-31T25:00:00Z")); // hour 25
1808        assert!(!is_valid_iso8601_timestamp("2025-12-31T00:00:00")); // no timezone
1809        assert!(!is_valid_iso8601_timestamp("")); // empty
1810    }
1811
1812    #[test]
1813    fn parse_manifest_from_kubernetes_cr() {
1814        let yaml = r#"
1815apiVersion: pgroles.io/v1alpha1
1816kind: PostgresPolicy
1817metadata:
1818  name: staging-policy
1819  namespace: pgroles-system
1820spec:
1821  connection:
1822    secretRef:
1823      name: pgroles-db-credentials
1824  interval: "5m"
1825  mode: plan
1826  roles:
1827    - name: app_analytics
1828      login: true
1829    - name: app_billing
1830      login: true
1831  schemas:
1832    - name: analytics
1833      profiles: [editor, viewer]
1834  profiles:
1835    editor:
1836      grants:
1837        - object: { type: schema }
1838          privileges: [USAGE]
1839        - object: { type: table, name: "*" }
1840          privileges: [SELECT, INSERT, UPDATE, DELETE]
1841    viewer:
1842      grants:
1843        - object: { type: schema }
1844          privileges: [USAGE]
1845        - object: { type: table, name: "*" }
1846          privileges: [SELECT]
1847  memberships:
1848    - role: analytics-editor
1849      members:
1850        - { name: app_analytics }
1851    - role: analytics-viewer
1852      members:
1853        - { name: app_billing }
1854"#;
1855        let manifest = parse_manifest(yaml).unwrap();
1856        assert_eq!(manifest.roles.len(), 2);
1857        assert_eq!(manifest.roles[0].name, "app_analytics");
1858        assert_eq!(manifest.schemas.len(), 1);
1859        assert_eq!(manifest.memberships.len(), 2);
1860        assert_eq!(manifest.profiles.len(), 2);
1861    }
1862
1863    #[test]
1864    fn profile_config_substitutes_schema_and_profile_placeholders_in_values() {
1865        let yaml = r#"
1866profiles:
1867  editor:
1868    login: true
1869    config:
1870      search_path: "{schema}"
1871      statement_timeout: "30s"
1872      app.profile_name: "{profile}"
1873      app.combo: "{schema}-{profile}-{schema}"
1874      app.literal: "no placeholders here"
1875
1876schemas:
1877  - name: inventory
1878    profiles: [editor]
1879"#;
1880        let manifest = parse_manifest(yaml).unwrap();
1881        let expanded = expand_manifest(&manifest).unwrap();
1882
1883        assert_eq!(expanded.roles.len(), 1);
1884        let role = &expanded.roles[0];
1885        assert_eq!(role.name, "inventory-editor");
1886        assert_eq!(role.config["search_path"].0, "inventory");
1887        assert_eq!(role.config["statement_timeout"].0, "30s");
1888        assert_eq!(role.config["app.profile_name"].0, "editor");
1889        assert_eq!(role.config["app.combo"].0, "inventory-editor-inventory");
1890        assert_eq!(role.config["app.literal"].0, "no placeholders here");
1891    }
1892
1893    #[test]
1894    fn profile_config_empty_when_not_declared() {
1895        let yaml = r#"
1896profiles:
1897  editor:
1898    login: true
1899
1900schemas:
1901  - name: inventory
1902    profiles: [editor]
1903"#;
1904        let manifest = parse_manifest(yaml).unwrap();
1905        let expanded = expand_manifest(&manifest).unwrap();
1906        assert!(expanded.roles[0].config.is_empty());
1907    }
1908
1909    #[test]
1910    fn profile_config_rejects_invalid_parameter_name() {
1911        // {schema} substitution only applies to values, never to keys — a
1912        // literal `{schema}` key is not a valid PostgreSQL parameter name and
1913        // is rejected the same way any other malformed key would be.
1914        let yaml = r#"
1915profiles:
1916  editor:
1917    config:
1918      "{schema}": inventory
1919
1920schemas:
1921  - name: inventory
1922    profiles: [editor]
1923"#;
1924        let manifest = parse_manifest(yaml).unwrap();
1925        let err = expand_manifest(&manifest).unwrap_err();
1926        assert!(matches!(err, ManifestError::InvalidConfigParameter { .. }));
1927    }
1928
1929    #[test]
1930    fn profile_config_rejects_unquoted_number() {
1931        let yaml = r#"
1932profiles:
1933  editor:
1934    config:
1935      statement_timeout: 30000
1936
1937schemas:
1938  - name: inventory
1939    profiles: [editor]
1940"#;
1941        let err = parse_manifest(yaml).unwrap_err();
1942        assert!(
1943            err.to_string().contains("write \"30000\" instead of 30000"),
1944            "unexpected error: {err}"
1945        );
1946    }
1947
1948    #[test]
1949    fn profile_config_role_membership_validation_fires() {
1950        // A profile `config: { role: <group> }` pointing at a manifest role
1951        // without a declared membership must fail the same way a hand-written
1952        // role's `config.role` would — the cross-check applies to generated
1953        // roles because they land in the same `roles` vec before validation.
1954        let yaml = r#"
1955profiles:
1956  editor:
1957    login: true
1958    config:
1959      role: combined
1960
1961schemas:
1962  - name: inventory
1963    profiles: [editor]
1964
1965roles:
1966  - name: combined
1967"#;
1968        let manifest = parse_manifest(yaml).unwrap();
1969        let err = expand_manifest(&manifest).unwrap_err();
1970        assert!(matches!(
1971            err,
1972            ManifestError::SetRoleWithoutMembership { role, target }
1973                if role == "inventory-editor" && target == "combined"
1974        ));
1975    }
1976
1977    #[test]
1978    fn profile_config_role_membership_validation_passes_with_declared_membership() {
1979        let yaml = r#"
1980profiles:
1981  editor:
1982    login: true
1983    config:
1984      role: combined
1985
1986schemas:
1987  - name: inventory
1988    profiles: [editor]
1989
1990roles:
1991  - name: combined
1992
1993memberships:
1994  - role: combined
1995    members:
1996      - name: inventory-editor
1997"#;
1998        let manifest = parse_manifest(yaml).unwrap();
1999        assert!(expand_manifest(&manifest).is_ok());
2000    }
2001
2002    #[test]
2003    fn parse_manifest_bare_and_cr_produce_same_result() {
2004        let bare = r#"
2005roles:
2006  - name: test_role
2007    login: true
2008schemas:
2009  - name: public
2010    profiles: [viewer]
2011profiles:
2012  viewer:
2013    grants:
2014      - object: { type: schema }
2015        privileges: [USAGE]
2016"#;
2017        let cr = r#"
2018apiVersion: pgroles.io/v1alpha1
2019kind: PostgresPolicy
2020metadata:
2021  name: test
2022spec:
2023  roles:
2024    - name: test_role
2025      login: true
2026  schemas:
2027    - name: public
2028      profiles: [viewer]
2029  profiles:
2030    viewer:
2031      grants:
2032        - object: { type: schema }
2033          privileges: [USAGE]
2034"#;
2035        let from_bare = parse_manifest(bare).unwrap();
2036        let from_cr = parse_manifest(cr).unwrap();
2037        assert_eq!(from_bare.roles.len(), from_cr.roles.len());
2038        assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
2039        assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
2040    }
2041}