use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use serde_json::Value;
use crate::diff::{Change, ReconciliationMode};
pub const APPROVAL_EFFECT_ENCODING_V1: &str = "pgroles.io/approval-effect/v1";
pub const APPROVAL_EFFECT_ENCODING_V2: &str = "pgroles.io/approval-effect/v2";
pub const APPROVAL_EFFECT_ENCODING_V3: &str = "pgroles.io/approval-effect/v3";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ApprovalDigestError {
#[error(
"no password source version recorded for role `{role}`: \
refusing to compute an approval digest that cannot bind the password source"
)]
MissingPasswordSource { role: String },
}
#[derive(Debug, Clone, Copy)]
pub struct EffectDigestInputs<'a> {
pub reconciliation_mode: ReconciliationMode,
pub target: &'a str,
pub target_identity: &'a TargetIdentity,
pub password_source_versions: &'a BTreeMap<String, String>,
pub owned_roles: &'a [String],
pub owned_schemas: &'a [String],
}
#[derive(Serialize)]
struct CanonicalChangeSet<'a> {
effect_encoding: &'a str,
reconciliation_mode: ReconciliationMode,
target: &'a str,
target_physical_identity: Option<&'a str>,
target_logical_fingerprint: Option<&'a str>,
owned_roles: BTreeSet<&'a str>,
owned_schemas: BTreeSet<&'a str>,
effects: Vec<Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TargetIdentity {
pub physical: Option<String>,
pub logical: Option<String>,
}
impl TargetIdentity {
pub fn logical_only(logical: impl Into<String>) -> Self {
Self {
physical: None,
logical: Some(logical.into()),
}
}
pub fn has_physical(&self) -> bool {
self.physical.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetIdentityReason {
TargetChanged,
TargetIdentityUnavailable,
TargetIdentityAppeared,
PhysicalIdentityRequired,
}
impl TargetIdentityReason {
pub fn as_str(self) -> &'static str {
match self {
TargetIdentityReason::TargetChanged => "TargetChanged",
TargetIdentityReason::TargetIdentityUnavailable => "TargetIdentityUnavailable",
TargetIdentityReason::TargetIdentityAppeared => "TargetIdentityAppeared",
TargetIdentityReason::PhysicalIdentityRequired => "PhysicalIdentityRequired",
}
}
pub fn message(self) -> &'static str {
match self {
TargetIdentityReason::TargetChanged => {
"the database this plan was approved against is not the database it would now \
execute against"
}
TargetIdentityReason::TargetIdentityUnavailable => {
"an identity bound at approval time can no longer be read from the target"
}
TargetIdentityReason::TargetIdentityAppeared => {
"an identity that was unavailable at approval time is now readable, so the \
approval was never bound to it"
}
TargetIdentityReason::PhysicalIdentityRequired => {
"requirePhysicalIdentity is set but pg_control_system().system_identifier could \
not be read from the target"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetIdentityVerdict {
Proceed,
Superseded(TargetIdentityReason),
Blocked(TargetIdentityReason),
}
pub fn evaluate_target_identity(
approved: &TargetIdentity,
observed: &TargetIdentity,
require_physical_identity: bool,
) -> TargetIdentityVerdict {
if require_physical_identity && (!observed.has_physical() || !approved.has_physical()) {
return TargetIdentityVerdict::Blocked(TargetIdentityReason::PhysicalIdentityRequired);
}
for (approved, observed) in [
(&approved.physical, &observed.physical),
(&approved.logical, &observed.logical),
] {
match (approved, observed) {
(Some(approved), Some(observed)) if approved != observed => {
return TargetIdentityVerdict::Superseded(TargetIdentityReason::TargetChanged);
}
(Some(_), None) => {
return TargetIdentityVerdict::Superseded(
TargetIdentityReason::TargetIdentityUnavailable,
);
}
(None, Some(_)) => {
return TargetIdentityVerdict::Superseded(
TargetIdentityReason::TargetIdentityAppeared,
);
}
_ => {}
}
}
TargetIdentityVerdict::Proceed
}
pub fn compute_change_digest(
changes: &[Change],
inputs: &EffectDigestInputs<'_>,
) -> Result<String, ApprovalDigestError> {
Ok(sha256_prefixed(&canonical_change_set_bytes(
changes, inputs,
)?))
}
pub fn canonical_change_set_bytes(
changes: &[Change],
inputs: &EffectDigestInputs<'_>,
) -> Result<Vec<u8>, ApprovalDigestError> {
let mut effects = Vec::with_capacity(changes.len());
for change in changes {
effects.push(canonical_effect(change, inputs.password_source_versions)?);
}
effects.sort_by_cached_key(ToString::to_string);
let owned_roles: BTreeSet<&str> = inputs.owned_roles.iter().map(String::as_str).collect();
let owned_schemas: BTreeSet<&str> = inputs.owned_schemas.iter().map(String::as_str).collect();
Ok(serde_json::to_vec(&CanonicalChangeSet {
effect_encoding: APPROVAL_EFFECT_ENCODING_V3,
reconciliation_mode: inputs.reconciliation_mode,
target: inputs.target,
target_physical_identity: inputs.target_identity.physical.as_deref(),
target_logical_fingerprint: inputs.target_identity.logical.as_deref(),
owned_roles,
owned_schemas,
effects,
})
.expect("canonical change set is serializable"))
}
fn canonical_effect(
change: &Change,
password_source_versions: &BTreeMap<String, String>,
) -> Result<Value, ApprovalDigestError> {
if let Change::SetPassword { name, .. } = change {
let source = password_source_versions
.get(name)
.ok_or_else(|| ApprovalDigestError::MissingPasswordSource { role: name.clone() })?;
return Ok(serde_json::json!({
"SetPassword": {
"name": name,
"password_source": source,
}
}));
}
Ok(serde_json::to_value(change).expect("change is serializable"))
}
fn sha256_prefixed(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write;
let digest = Sha256::digest(bytes);
let mut hash = String::with_capacity(7 + digest.len() * 2);
hash.push_str("sha256:");
for byte in digest {
write!(&mut hash, "{byte:02x}").expect("writing to a String cannot fail");
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{ObjectType, Privilege};
use crate::model::{Grantee, RoleState};
fn versions(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
entries
.iter()
.map(|(role, version)| ((*role).to_string(), (*version).to_string()))
.collect()
}
static TARGET_IDENTITY: std::sync::LazyLock<TargetIdentity> =
std::sync::LazyLock::new(|| TargetIdentity {
physical: Some("7412330000000000001".to_string()),
logical: Some("sha256:fingerprint".to_string()),
});
fn inputs<'a>(
password_source_versions: &'a BTreeMap<String, String>,
) -> EffectDigestInputs<'a> {
EffectDigestInputs {
reconciliation_mode: ReconciliationMode::Authoritative,
target: "default/postgres-credentials:url",
target_identity: &TARGET_IDENTITY,
password_source_versions,
owned_roles: &[],
owned_schemas: &[],
}
}
#[test]
fn a_management_scope_change_changes_the_digest_with_identical_effects() {
let changes = [create_role("reader")];
let versions = BTreeMap::new();
let narrow = ["app_reader".to_string()];
let wide = ["app_reader".to_string(), "team_y_user".to_string()];
let mut inputs_narrow = inputs(&versions);
inputs_narrow.owned_roles = &narrow;
let mut inputs_wide = inputs(&versions);
inputs_wide.owned_roles = &wide;
assert_ne!(
compute_change_digest(&changes, &inputs_narrow).expect("digest"),
compute_change_digest(&changes, &inputs_wide).expect("digest"),
);
}
#[test]
fn management_scope_order_and_duplicates_do_not_change_the_digest() {
let changes = [create_role("reader")];
let versions = BTreeMap::new();
let forward = ["a".to_string(), "b".to_string()];
let shuffled = ["b".to_string(), "a".to_string(), "b".to_string()];
let mut inputs_forward = inputs(&versions);
inputs_forward.owned_roles = &forward;
let mut inputs_shuffled = inputs(&versions);
inputs_shuffled.owned_roles = &shuffled;
assert_eq!(
compute_change_digest(&changes, &inputs_forward).expect("digest"),
compute_change_digest(&changes, &inputs_shuffled).expect("digest"),
);
}
fn set_password(name: &str, verifier: &str) -> Change {
Change::SetPassword {
name: name.to_string(),
password: verifier.to_string(),
}
}
fn create_role(name: &str) -> Change {
Change::CreateRole {
name: name.to_string(),
state: RoleState {
login: true,
..RoleState::default()
},
}
}
fn grant(role: &str) -> Change {
Change::Grant {
role: Grantee::parse(role),
privileges: [Privilege::Select].into_iter().collect(),
object_type: ObjectType::Table,
schema: Some("inventory".to_string()),
name: Some("orders".to_string()),
}
}
#[test]
fn password_verifier_does_not_affect_the_digest() {
let versions = versions(&[("app", "role-passwords:app:7")]);
let inputs = inputs(&versions);
let first = compute_change_digest(
&[set_password(
"app",
"SCRAM-SHA-256$4096:saltA$storedA:serverA",
)],
&inputs,
)
.expect("digest");
let second = compute_change_digest(
&[set_password(
"app",
"SCRAM-SHA-256$4096:saltB$storedB:serverB",
)],
&inputs,
)
.expect("digest");
assert_eq!(
first, second,
"a re-derived verifier for an unchanged source must keep the same approval identity"
);
}
#[test]
fn rotating_the_password_source_changes_the_digest() {
let before = versions(&[("app", "role-passwords:app:7")]);
let after = versions(&[("app", "role-passwords:app:8")]);
let change = [set_password("app", "SCRAM-SHA-256$4096:salt$stored:server")];
assert_ne!(
compute_change_digest(&change, &inputs(&before)).expect("digest"),
compute_change_digest(&change, &inputs(&after)).expect("digest"),
"an approval must not carry over to a different password source"
);
}
#[test]
fn a_password_change_without_a_source_version_fails_closed() {
let empty = BTreeMap::new();
assert_eq!(
compute_change_digest(&[set_password("app", "SCRAM-SHA-256$...")], &inputs(&empty)),
Err(ApprovalDigestError::MissingPasswordSource {
role: "app".to_string()
})
);
}
#[test]
fn emission_order_does_not_affect_the_digest() {
let versions = BTreeMap::new();
let inputs = inputs(&versions);
let forward = [create_role("reporting"), grant("reporting")];
let reversed = [grant("reporting"), create_role("reporting")];
assert_eq!(
compute_change_digest(&forward, &inputs).expect("digest"),
compute_change_digest(&reversed, &inputs).expect("digest"),
);
}
#[test]
fn effects_are_bound_to_the_reconciliation_mode() {
let versions = BTreeMap::new();
let changes = [grant("reporting")];
let authoritative = compute_change_digest(&changes, &inputs(&versions)).expect("digest");
let additive = compute_change_digest(
&changes,
&EffectDigestInputs {
reconciliation_mode: ReconciliationMode::Additive,
..inputs(&versions)
},
)
.expect("digest");
assert_ne!(authoritative, additive);
}
#[test]
fn effects_are_bound_to_the_target() {
let versions = BTreeMap::new();
let changes = [grant("reporting")];
let original = compute_change_digest(&changes, &inputs(&versions)).expect("digest");
let repointed = compute_change_digest(
&changes,
&EffectDigestInputs {
target: "default/other-credentials:url",
..inputs(&versions)
},
)
.expect("digest");
assert_ne!(
original, repointed,
"an approval must not carry over to a different database"
);
}
#[test]
fn effects_are_bound_to_both_halves_of_the_target_identity() {
let versions = BTreeMap::new();
let changes = [grant("reporting")];
let baseline = compute_change_digest(&changes, &inputs(&versions)).expect("digest");
let cloned = TargetIdentity {
logical: Some("sha256:other-endpoint".to_string()),
..TARGET_IDENTITY.clone()
};
let restored = TargetIdentity {
physical: Some("7412330000000000002".to_string()),
..TARGET_IDENTITY.clone()
};
let downgraded = TargetIdentity {
physical: None,
..TARGET_IDENTITY.clone()
};
for moved in [cloned, restored, downgraded] {
assert_ne!(
baseline,
compute_change_digest(
&changes,
&EffectDigestInputs {
target_identity: &moved,
..inputs(&versions)
},
)
.expect("digest"),
"an approval must not carry over to a different target identity"
);
}
}
#[test]
fn an_unchanged_target_identity_keeps_the_digest() {
let versions = BTreeMap::new();
let changes = [grant("reporting")];
let same = TARGET_IDENTITY.clone();
assert_eq!(
compute_change_digest(&changes, &inputs(&versions)).expect("digest"),
compute_change_digest(
&changes,
&EffectDigestInputs {
target_identity: &same,
..inputs(&versions)
},
)
.expect("digest"),
);
}
fn identity(physical: Option<&str>, logical: Option<&str>) -> TargetIdentity {
TargetIdentity {
physical: physical.map(str::to_string),
logical: logical.map(str::to_string),
}
}
#[test]
fn an_unchanged_identity_proceeds() {
let approved = identity(Some("id-1"), Some("fp-1"));
assert_eq!(
evaluate_target_identity(&approved, &approved.clone(), false),
TargetIdentityVerdict::Proceed
);
assert_eq!(
evaluate_target_identity(&approved, &approved, true),
TargetIdentityVerdict::Proceed
);
}
#[test]
fn consistent_physical_unavailability_proceeds() {
let logical_only = identity(None, Some("fp-1"));
assert_eq!(
evaluate_target_identity(&logical_only, &logical_only.clone(), false),
TargetIdentityVerdict::Proceed
);
}
#[test]
fn a_changed_physical_identity_supersedes() {
assert_eq!(
evaluate_target_identity(
&identity(Some("id-1"), Some("fp-1")),
&identity(Some("id-2"), Some("fp-1")),
false,
),
TargetIdentityVerdict::Superseded(TargetIdentityReason::TargetChanged)
);
}
#[test]
fn a_changed_logical_fingerprint_supersedes() {
assert_eq!(
evaluate_target_identity(
&identity(Some("id-1"), Some("fp-1")),
&identity(Some("id-1"), Some("fp-2")),
false,
),
TargetIdentityVerdict::Superseded(TargetIdentityReason::TargetChanged)
);
}
#[test]
fn a_physical_downgrade_supersedes() {
assert_eq!(
evaluate_target_identity(
&identity(Some("id-1"), Some("fp-1")),
&identity(None, Some("fp-1")),
false,
),
TargetIdentityVerdict::Superseded(TargetIdentityReason::TargetIdentityUnavailable)
);
}
#[test]
fn a_physical_upgrade_supersedes_once() {
assert_eq!(
evaluate_target_identity(
&identity(None, Some("fp-1")),
&identity(Some("id-1"), Some("fp-1")),
false,
),
TargetIdentityVerdict::Superseded(TargetIdentityReason::TargetIdentityAppeared)
);
}
#[test]
fn require_physical_identity_blocks_when_it_is_unavailable() {
assert_eq!(
evaluate_target_identity(
&identity(Some("id-1"), Some("fp-1")),
&identity(None, Some("fp-1")),
true,
),
TargetIdentityVerdict::Blocked(TargetIdentityReason::PhysicalIdentityRequired)
);
assert_eq!(
evaluate_target_identity(
&identity(None, Some("fp-1")),
&identity(Some("id-1"), Some("fp-1")),
true,
),
TargetIdentityVerdict::Blocked(TargetIdentityReason::PhysicalIdentityRequired)
);
assert_eq!(
evaluate_target_identity(
&identity(None, Some("fp-1")),
&identity(None, Some("fp-1")),
true,
),
TargetIdentityVerdict::Blocked(TargetIdentityReason::PhysicalIdentityRequired)
);
}
#[test]
fn require_physical_identity_still_reports_a_mismatch_as_a_supersede() {
assert_eq!(
evaluate_target_identity(
&identity(Some("id-1"), Some("fp-1")),
&identity(Some("id-2"), Some("fp-1")),
true,
),
TargetIdentityVerdict::Superseded(TargetIdentityReason::TargetChanged)
);
}
#[test]
fn different_effects_produce_different_digests() {
let versions = BTreeMap::new();
let inputs = inputs(&versions);
assert_ne!(
compute_change_digest(&[grant("reporting")], &inputs).expect("digest"),
compute_change_digest(&[grant("analytics")], &inputs).expect("digest"),
);
}
#[test]
fn an_empty_change_set_has_a_stable_digest() {
let versions = BTreeMap::new();
let inputs = inputs(&versions);
assert_eq!(
compute_change_digest(&[], &inputs).expect("digest"),
compute_change_digest(&[], &inputs).expect("digest"),
);
}
#[test]
fn the_digest_is_prefixed_and_hex_encoded() {
let versions = BTreeMap::new();
let digest =
compute_change_digest(&[grant("reporting")], &inputs(&versions)).expect("digest");
let hex = digest.strip_prefix("sha256:").expect("sha256: prefix");
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn canonical_bytes_are_pinned_for_a_fixed_input() {
let versions = versions(&[("app", "role-passwords:app:7")]);
let changes = [
grant("reporting"),
set_password("app", "SCRAM-SHA-256$4096:salt$stored:server"),
];
let bytes = canonical_change_set_bytes(&changes, &inputs(&versions)).expect("bytes");
let encoded = String::from_utf8(bytes).expect("canonical bytes are UTF-8 JSON");
assert_eq!(
encoded,
r#"{"effect_encoding":"pgroles.io/approval-effect/v3","reconciliation_mode":"Authoritative","target":"default/postgres-credentials:url","target_physical_identity":"7412330000000000001","target_logical_fingerprint":"sha256:fingerprint","owned_roles":[],"owned_schemas":[],"effects":[{"Grant":{"name":"orders","object_type":"table","privileges":["SELECT"],"role":"reporting","schema":"inventory"}},{"SetPassword":{"name":"app","password_source":"role-passwords:app:7"}}]}"#,
"canonical encoding changed; bump the encoding constant rather than \
editing this fixture"
);
}
#[test]
fn the_digest_never_contains_password_material() {
let versions = versions(&[("app", "role-passwords:app:7")]);
let verifier = "SCRAM-SHA-256$4096:c2FsdA==$c3RvcmVk:c2VydmVy";
let effect = canonical_effect(&set_password("app", verifier), &versions).expect("effect");
let rendered = effect.to_string();
assert!(!rendered.contains(verifier));
assert!(!rendered.contains("SCRAM-SHA-256"));
assert!(rendered.contains("role-passwords:app:7"));
}
}