use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("YAML parse error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("duplicate role name: \"{0}\"")]
DuplicateRole(String),
#[error("profile \"{0}\" referenced by schema \"{1}\" is not defined")]
UndefinedProfile(String, String),
#[error("role_pattern must contain {{profile}} placeholder, got: \"{0}\"")]
InvalidRolePattern(String),
#[error("top-level default privilege for schema \"{schema}\" must specify grant.role")]
MissingDefaultPrivilegeRole { schema: String },
#[error("duplicate retirement entry for role: \"{0}\"")]
DuplicateRetirement(String),
#[error("retirement entry for role \"{0}\" conflicts with a desired role of the same name")]
RetirementRoleStillDesired(String),
#[error("retirement entry for role \"{role}\" cannot reassign ownership to itself")]
RetirementSelfReassign { role: String },
#[error(
"role \"{role}\" has a password but login is not enabled — password will have no effect"
)]
PasswordWithoutLogin { role: String },
#[error(
"role \"{role}\" has an invalid password_valid_until value \"{value}\": expected ISO 8601 timestamp (e.g. \"2025-12-31T00:00:00Z\")"
)]
InvalidValidUntil { role: String, value: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ObjectType {
Table,
View,
#[serde(alias = "materialized_view")]
MaterializedView,
Sequence,
Function,
Schema,
Database,
Type,
}
impl std::fmt::Display for ObjectType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ObjectType::Table => write!(f, "table"),
ObjectType::View => write!(f, "view"),
ObjectType::MaterializedView => write!(f, "materialized_view"),
ObjectType::Sequence => write!(f, "sequence"),
ObjectType::Function => write!(f, "function"),
ObjectType::Schema => write!(f, "schema"),
ObjectType::Database => write!(f, "database"),
ObjectType::Type => write!(f, "type"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "UPPERCASE")]
pub enum Privilege {
Select,
Insert,
Update,
Delete,
Truncate,
References,
Trigger,
Execute,
Usage,
Create,
Connect,
Temporary,
}
impl std::fmt::Display for Privilege {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Privilege::Select => write!(f, "SELECT"),
Privilege::Insert => write!(f, "INSERT"),
Privilege::Update => write!(f, "UPDATE"),
Privilege::Delete => write!(f, "DELETE"),
Privilege::Truncate => write!(f, "TRUNCATE"),
Privilege::References => write!(f, "REFERENCES"),
Privilege::Trigger => write!(f, "TRIGGER"),
Privilege::Execute => write!(f, "EXECUTE"),
Privilege::Usage => write!(f, "USAGE"),
Privilege::Create => write!(f, "CREATE"),
Privilege::Connect => write!(f, "CONNECT"),
Privilege::Temporary => write!(f, "TEMPORARY"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_owner: Option<String>,
#[serde(default)]
pub auth_providers: Vec<AuthProvider>,
#[serde(default)]
pub profiles: HashMap<String, Profile>,
#[serde(default)]
pub schemas: Vec<SchemaBinding>,
#[serde(default)]
pub roles: Vec<RoleDefinition>,
#[serde(default)]
pub grants: Vec<Grant>,
#[serde(default)]
pub default_privileges: Vec<DefaultPrivilege>,
#[serde(default)]
pub memberships: Vec<Membership>,
#[serde(default)]
pub retirements: Vec<RoleRetirement>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthProvider {
CloudSqlIam {
#[serde(default)]
project: Option<String>,
},
#[serde(rename = "alloydb_iam")]
AlloyDbIam {
#[serde(default)]
project: Option<String>,
#[serde(default)]
cluster: Option<String>,
},
RdsIam {
#[serde(default)]
region: Option<String>,
},
AzureAd {
#[serde(default)]
tenant_id: Option<String>,
},
Supabase {
#[serde(default)]
project_ref: Option<String>,
},
PlanetScale {
#[serde(default)]
organization: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(default)]
pub login: Option<bool>,
#[serde(default)]
pub grants: Vec<ProfileGrant>,
#[serde(default)]
pub default_privileges: Vec<DefaultPrivilegeGrant>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileGrant {
pub privileges: Vec<Privilege>,
#[serde(alias = "on")]
pub object: ProfileObjectTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileObjectTarget {
#[serde(rename = "type")]
pub object_type: ObjectType,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SchemaBinding {
pub name: String,
pub profiles: Vec<String>,
#[serde(default = "default_role_pattern")]
pub role_pattern: String,
#[serde(default)]
pub owner: Option<String>,
}
fn default_role_pattern() -> String {
"{schema}-{profile}".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleDefinition {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superuser: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub createdb: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub createrole: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inherit: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replication: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bypassrls: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_limit: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<PasswordSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_valid_until: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PasswordSource {
pub from_env: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Grant {
pub role: String,
pub privileges: Vec<Privilege>,
#[serde(alias = "on")]
pub object: ObjectTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ObjectTarget {
#[serde(rename = "type")]
pub object_type: ObjectType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DefaultPrivilege {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
pub schema: String,
pub grant: Vec<DefaultPrivilegeGrant>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DefaultPrivilegeGrant {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub privileges: Vec<Privilege>,
pub on_type: ObjectType,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Membership {
pub role: String,
pub members: Vec<MemberSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MemberSpec {
pub name: String,
#[serde(default = "default_true")]
pub inherit: bool,
#[serde(default)]
pub admin: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RoleRetirement {
pub role: String,
#[serde(default)]
pub reassign_owned_to: Option<String>,
#[serde(default)]
pub drop_owned: bool,
#[serde(default)]
pub terminate_sessions: bool,
}
#[derive(Debug, Clone)]
pub struct ExpandedManifest {
pub roles: Vec<RoleDefinition>,
pub grants: Vec<Grant>,
pub default_privileges: Vec<DefaultPrivilege>,
pub memberships: Vec<Membership>,
}
pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
if let serde_yaml::Value::Mapping(ref map) = value {
let api_version_key = serde_yaml::Value::String("apiVersion".into());
let spec_key = serde_yaml::Value::String("spec".into());
if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
let spec = map.get(&spec_key).ok_or_else(|| {
ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
})?;
let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
return Ok(manifest);
}
}
let manifest: PolicyManifest = serde_yaml::from_value(value)?;
Ok(manifest)
}
pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
let mut roles: Vec<RoleDefinition> = Vec::new();
let mut grants: Vec<Grant> = Vec::new();
let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
for schema_binding in &manifest.schemas {
for profile_name in &schema_binding.profiles {
let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
})?;
if !schema_binding.role_pattern.contains("{profile}") {
return Err(ManifestError::InvalidRolePattern(
schema_binding.role_pattern.clone(),
));
}
let role_name = schema_binding
.role_pattern
.replace("{schema}", &schema_binding.name)
.replace("{profile}", profile_name);
roles.push(RoleDefinition {
name: role_name.clone(),
login: profile.login,
superuser: None,
createdb: None,
createrole: None,
inherit: None,
replication: None,
bypassrls: None,
connection_limit: None,
comment: Some(format!(
"Generated from profile '{profile_name}' for schema '{}'",
schema_binding.name
)),
password: None,
password_valid_until: None,
});
for profile_grant in &profile.grants {
let object_target = match profile_grant.object.object_type {
ObjectType::Schema => ObjectTarget {
object_type: ObjectType::Schema,
schema: None,
name: Some(schema_binding.name.clone()),
},
_ => ObjectTarget {
object_type: profile_grant.object.object_type,
schema: Some(schema_binding.name.clone()),
name: profile_grant.object.name.clone(),
},
};
grants.push(Grant {
role: role_name.clone(),
privileges: profile_grant.privileges.clone(),
object: object_target,
});
}
if !profile.default_privileges.is_empty() {
let owner = schema_binding
.owner
.clone()
.or(manifest.default_owner.clone());
let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
.default_privileges
.iter()
.map(|dp| DefaultPrivilegeGrant {
role: Some(role_name.clone()),
privileges: dp.privileges.clone(),
on_type: dp.on_type,
})
.collect();
default_privileges.push(DefaultPrivilege {
owner,
schema: schema_binding.name.clone(),
grant: expanded_grants,
});
}
}
}
for default_priv in &manifest.default_privileges {
for grant in &default_priv.grant {
if grant.role.is_none() {
return Err(ManifestError::MissingDefaultPrivilegeRole {
schema: default_priv.schema.clone(),
});
}
}
}
roles.extend(manifest.roles.clone());
grants.extend(manifest.grants.clone());
default_privileges.extend(manifest.default_privileges.clone());
let memberships = manifest.memberships.clone();
let mut seen_roles: HashSet<String> = HashSet::new();
for role in &roles {
if seen_roles.contains(&role.name) {
return Err(ManifestError::DuplicateRole(role.name.clone()));
}
seen_roles.insert(role.name.clone());
}
let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
let mut seen_retirements: HashSet<String> = HashSet::new();
for retirement in &manifest.retirements {
if seen_retirements.contains(&retirement.role) {
return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
}
if desired_role_names.contains(&retirement.role) {
return Err(ManifestError::RetirementRoleStillDesired(
retirement.role.clone(),
));
}
if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
return Err(ManifestError::RetirementSelfReassign {
role: retirement.role.clone(),
});
}
seen_retirements.insert(retirement.role.clone());
}
for role in &roles {
if role.password.is_some() && role.login != Some(true) {
return Err(ManifestError::PasswordWithoutLogin {
role: role.name.clone(),
});
}
}
for role in &roles {
if let Some(value) = &role.password_valid_until
&& !is_valid_iso8601_timestamp(value)
{
return Err(ManifestError::InvalidValidUntil {
role: role.name.clone(),
value: value.clone(),
});
}
}
Ok(ExpandedManifest {
roles,
grants,
default_privileges,
memberships,
})
}
fn is_valid_iso8601_timestamp(value: &str) -> bool {
if value.len() < 20 {
return false;
}
let bytes = value.as_bytes();
if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
return false;
}
let year = &value[0..4];
let month = &value[5..7];
let day = &value[8..10];
let Ok(y) = year.parse::<u16>() else {
return false;
};
let Ok(m) = month.parse::<u8>() else {
return false;
};
let Ok(d) = day.parse::<u8>() else {
return false;
};
if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return false;
}
if bytes[13] != b':' || bytes[16] != b':' {
return false;
}
let hour = &value[11..13];
let minute = &value[14..16];
let second = &value[17..19];
let Ok(h) = hour.parse::<u8>() else {
return false;
};
let Ok(min) = minute.parse::<u8>() else {
return false;
};
let Ok(sec) = second.parse::<u8>() else {
return false;
};
if h > 23 || min > 59 || sec > 59 {
return false;
}
let suffix = &value[19..];
let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
let frac_end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
if frac_end == 0 {
return false; }
&rest[frac_end..]
} else {
suffix
};
match tz_part {
"Z" => true,
s if (s.starts_with('+') || s.starts_with('-'))
&& s.len() == 6
&& s.as_bytes()[3] == b':' =>
{
let Ok(tz_h) = s[1..3].parse::<u8>() else {
return false;
};
let Ok(tz_m) = s[4..6].parse::<u8>() else {
return false;
};
tz_h <= 14 && tz_m <= 59
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_role() {
let yaml = r#"
roles:
- name: test-role
"#;
let manifest = parse_manifest(yaml).unwrap();
assert_eq!(manifest.roles.len(), 1);
assert_eq!(manifest.roles[0].name, "test-role");
assert!(manifest.roles[0].login.is_none());
}
#[test]
fn parse_full_policy() {
let yaml = r#"
default_owner: app_owner
profiles:
editor:
login: false
grants:
- privileges: [USAGE]
object: { type: schema }
- privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
object: { type: table, name: "*" }
- privileges: [USAGE, SELECT, UPDATE]
object: { type: sequence, name: "*" }
- privileges: [EXECUTE]
object: { type: function, name: "*" }
default_privileges:
- privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
on_type: table
- privileges: [USAGE, SELECT, UPDATE]
on_type: sequence
- privileges: [EXECUTE]
on_type: function
schemas:
- name: inventory
profiles: [editor]
- name: catalog
profiles: [editor]
roles:
- name: analytics-readonly
login: true
memberships:
- role: inventory-editor
members:
- name: "alice@example.com"
inherit: true
"#;
let manifest = parse_manifest(yaml).unwrap();
assert_eq!(manifest.profiles.len(), 1);
assert_eq!(manifest.schemas.len(), 2);
assert_eq!(manifest.roles.len(), 1);
assert_eq!(manifest.memberships.len(), 1);
assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
}
#[test]
fn reject_invalid_yaml() {
let yaml = "not: [valid: yaml: {{";
assert!(parse_manifest(yaml).is_err());
}
#[test]
fn expand_profiles_basic() {
let yaml = r#"
profiles:
editor:
login: false
grants:
- privileges: [USAGE]
object: { type: schema }
- privileges: [SELECT, INSERT]
object: { type: table, name: "*" }
schemas:
- name: myschema
profiles: [editor]
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
assert_eq!(expanded.roles.len(), 1);
assert_eq!(expanded.roles[0].name, "myschema-editor");
assert_eq!(expanded.roles[0].login, Some(false));
assert_eq!(expanded.grants.len(), 2);
assert_eq!(expanded.grants[0].role, "myschema-editor");
assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
assert_eq!(
expanded.grants[1].object.schema,
Some("myschema".to_string())
);
assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
}
#[test]
fn expand_profiles_multi_schema() {
let yaml = r#"
profiles:
editor:
grants:
- privileges: [SELECT]
object: { type: table, name: "*" }
viewer:
grants:
- privileges: [SELECT]
object: { type: table, name: "*" }
schemas:
- name: alpha
profiles: [editor, viewer]
- name: beta
profiles: [editor, viewer]
- name: gamma
profiles: [editor]
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
assert_eq!(expanded.roles.len(), 5);
let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
assert!(role_names.contains(&"alpha-editor"));
assert!(role_names.contains(&"alpha-viewer"));
assert!(role_names.contains(&"beta-editor"));
assert!(role_names.contains(&"beta-viewer"));
assert!(role_names.contains(&"gamma-editor"));
}
#[test]
fn expand_custom_role_pattern() {
let yaml = r#"
profiles:
viewer:
grants:
- privileges: [SELECT]
object: { type: table, name: "*" }
schemas:
- name: legacy_data
profiles: [viewer]
role_pattern: "legacy-{profile}"
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
assert_eq!(expanded.roles.len(), 1);
assert_eq!(expanded.roles[0].name, "legacy-viewer");
}
#[test]
fn expand_rejects_duplicate_role_name() {
let yaml = r#"
profiles:
editor:
grants: []
schemas:
- name: inventory
profiles: [editor]
roles:
- name: inventory-editor
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("duplicate role name")
);
}
#[test]
fn expand_rejects_undefined_profile() {
let yaml = r#"
profiles: {}
schemas:
- name: inventory
profiles: [nonexistent]
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not defined"));
}
#[test]
fn expand_rejects_invalid_pattern() {
let yaml = r#"
profiles:
editor:
grants: []
schemas:
- name: inventory
profiles: [editor]
role_pattern: "static-name"
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("{profile} placeholder")
);
}
#[test]
fn expand_rejects_top_level_default_privilege_without_role() {
let yaml = r#"
default_privileges:
- schema: public
grant:
- privileges: [SELECT]
on_type: table
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must specify grant.role")
);
}
#[test]
fn expand_default_privileges_with_owner_override() {
let yaml = r#"
default_owner: app_owner
profiles:
editor:
grants: []
default_privileges:
- privileges: [SELECT]
on_type: table
schemas:
- name: inventory
profiles: [editor]
- name: legacy
profiles: [editor]
owner: legacy_admin
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
assert_eq!(expanded.default_privileges.len(), 2);
assert_eq!(
expanded.default_privileges[0].owner,
Some("app_owner".to_string())
);
assert_eq!(expanded.default_privileges[0].schema, "inventory");
assert_eq!(
expanded.default_privileges[1].owner,
Some("legacy_admin".to_string())
);
assert_eq!(expanded.default_privileges[1].schema, "legacy");
}
#[test]
fn expand_merges_oneoff_roles_and_grants() {
let yaml = r#"
profiles:
editor:
grants:
- privileges: [SELECT]
object: { type: table, name: "*" }
schemas:
- name: inventory
profiles: [editor]
roles:
- name: analytics
login: true
grants:
- role: analytics
privileges: [SELECT]
on:
type: table
schema: inventory
name: "*"
"#;
let manifest = parse_manifest(yaml).unwrap();
let expanded = expand_manifest(&manifest).unwrap();
assert_eq!(expanded.roles.len(), 2);
assert_eq!(expanded.grants.len(), 2); }
#[test]
fn parse_manifest_accepts_legacy_on_alias() {
let yaml = r#"
grants:
- role: analytics
privileges: [SELECT]
on:
type: table
schema: public
name: "*"
"#;
let manifest = parse_manifest(yaml).unwrap();
assert_eq!(manifest.grants.len(), 1);
assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
}
#[test]
fn parse_membership_with_email_roles() {
let yaml = r#"
memberships:
- role: inventory-editor
members:
- name: "alice@example.com"
inherit: true
- name: "engineering@example.com"
admin: true
"#;
let manifest = parse_manifest(yaml).unwrap();
assert_eq!(manifest.memberships.len(), 1);
assert_eq!(manifest.memberships[0].members.len(), 2);
assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
assert!(manifest.memberships[0].members[0].inherit);
assert!(manifest.memberships[0].members[1].admin);
}
#[test]
fn member_spec_defaults() {
let yaml = r#"
memberships:
- role: some-role
members:
- name: user1
"#;
let manifest = parse_manifest(yaml).unwrap();
assert!(manifest.memberships[0].members[0].inherit);
assert!(!manifest.memberships[0].members[0].admin);
}
#[test]
fn expand_rejects_duplicate_retirements() {
let yaml = r#"
retirements:
- role: old-app
- role: old-app
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(matches!(
result,
Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
));
}
#[test]
fn expand_rejects_retirement_for_desired_role() {
let yaml = r#"
roles:
- name: old-app
retirements:
- role: old-app
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(matches!(
result,
Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
));
}
#[test]
fn expand_rejects_self_reassign_retirement() {
let yaml = r#"
retirements:
- role: old-app
reassign_owned_to: old-app
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(matches!(
result,
Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
));
}
#[test]
fn parse_auth_providers() {
let yaml = r#"
auth_providers:
- type: cloud_sql_iam
project: my-gcp-project
- type: alloydb_iam
project: my-gcp-project
cluster: analytics-prod
- type: rds_iam
region: us-east-1
- type: azure_ad
tenant_id: "abc-123"
- type: supabase
project_ref: myprojref
- type: planet_scale
organization: my-org
roles:
- name: app-service
"#;
let manifest = parse_manifest(yaml).unwrap();
assert_eq!(manifest.auth_providers.len(), 6);
assert!(matches!(
&manifest.auth_providers[0],
AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
));
assert!(matches!(
&manifest.auth_providers[1],
AuthProvider::AlloyDbIam {
project: Some(p),
cluster: Some(c)
} if p == "my-gcp-project" && c == "analytics-prod"
));
assert!(matches!(
&manifest.auth_providers[2],
AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
));
assert!(matches!(
&manifest.auth_providers[3],
AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
));
assert!(matches!(
&manifest.auth_providers[4],
AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
));
assert!(matches!(
&manifest.auth_providers[5],
AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
));
}
#[test]
fn parse_manifest_without_auth_providers() {
let yaml = r#"
roles:
- name: test-role
"#;
let manifest = parse_manifest(yaml).unwrap();
assert!(manifest.auth_providers.is_empty());
}
#[test]
fn parse_role_with_password_source() {
let yaml = r#"
roles:
- name: app-service
login: true
password:
from_env: APP_SERVICE_PASSWORD
password_valid_until: "2025-12-31T00:00:00Z"
"#;
let manifest = parse_manifest(yaml).unwrap();
assert_eq!(manifest.roles.len(), 1);
let role = &manifest.roles[0];
assert!(role.password.is_some());
assert_eq!(
role.password.as_ref().unwrap().from_env,
"APP_SERVICE_PASSWORD"
);
assert_eq!(
role.password_valid_until,
Some("2025-12-31T00:00:00Z".to_string())
);
}
#[test]
fn parse_role_without_password() {
let yaml = r#"
roles:
- name: app-service
login: true
"#;
let manifest = parse_manifest(yaml).unwrap();
assert!(manifest.roles[0].password.is_none());
assert!(manifest.roles[0].password_valid_until.is_none());
}
#[test]
fn reject_password_on_nologin_role() {
let yaml = r#"
roles:
- name: nologin-role
login: false
password:
from_env: SOME_PASSWORD
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("login is not enabled")
);
}
#[test]
fn reject_password_on_default_login_role() {
let yaml = r#"
roles:
- name: implicit-nologin-role
password:
from_env: SOME_PASSWORD
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("login is not enabled")
);
}
#[test]
fn reject_invalid_password_valid_until() {
let yaml = r#"
roles:
- name: bad-date
login: true
password_valid_until: "not-a-date"
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid password_valid_until")
);
}
#[test]
fn reject_date_only_valid_until() {
let yaml = r#"
roles:
- name: bad-date
login: true
password_valid_until: "2025-12-31"
"#;
let manifest = parse_manifest(yaml).unwrap();
let result = expand_manifest(&manifest);
assert!(result.is_err());
}
#[test]
fn accept_valid_iso8601_timestamps() {
assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
}
#[test]
fn reject_invalid_iso8601_timestamps() {
assert!(!is_valid_iso8601_timestamp("not-a-date"));
assert!(!is_valid_iso8601_timestamp("2025-12-31")); assert!(!is_valid_iso8601_timestamp("2025-13-31T00:00:00Z")); assert!(!is_valid_iso8601_timestamp("2025-12-31T25:00:00Z")); assert!(!is_valid_iso8601_timestamp("2025-12-31T00:00:00")); assert!(!is_valid_iso8601_timestamp("")); }
#[test]
fn parse_manifest_from_kubernetes_cr() {
let yaml = r#"
apiVersion: pgroles.io/v1alpha1
kind: PostgresPolicy
metadata:
name: staging-policy
namespace: pgroles-system
spec:
connection:
secretRef:
name: pgroles-db-credentials
interval: "5m"
mode: plan
roles:
- name: app_analytics
login: true
- name: app_billing
login: true
schemas:
- name: analytics
profiles: [editor, viewer]
profiles:
editor:
grants:
- object: { type: schema }
privileges: [USAGE]
- object: { type: table, name: "*" }
privileges: [SELECT, INSERT, UPDATE, DELETE]
viewer:
grants:
- object: { type: schema }
privileges: [USAGE]
- object: { type: table, name: "*" }
privileges: [SELECT]
memberships:
- role: analytics-editor
members:
- { name: app_analytics }
- role: analytics-viewer
members:
- { name: app_billing }
"#;
let manifest = parse_manifest(yaml).unwrap();
assert_eq!(manifest.roles.len(), 2);
assert_eq!(manifest.roles[0].name, "app_analytics");
assert_eq!(manifest.schemas.len(), 1);
assert_eq!(manifest.memberships.len(), 2);
assert_eq!(manifest.profiles.len(), 2);
}
#[test]
fn parse_manifest_bare_and_cr_produce_same_result() {
let bare = r#"
roles:
- name: test_role
login: true
schemas:
- name: public
profiles: [viewer]
profiles:
viewer:
grants:
- object: { type: schema }
privileges: [USAGE]
"#;
let cr = r#"
apiVersion: pgroles.io/v1alpha1
kind: PostgresPolicy
metadata:
name: test
spec:
roles:
- name: test_role
login: true
schemas:
- name: public
profiles: [viewer]
profiles:
viewer:
grants:
- object: { type: schema }
privileges: [USAGE]
"#;
let from_bare = parse_manifest(bare).unwrap();
let from_cr = parse_manifest(cr).unwrap();
assert_eq!(from_bare.roles.len(), from_cr.roles.len());
assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
}
}