Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion crates/openspine-kernel/src/store/audited_effect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use super::{Store, StoreError};
use openspine_schemas::action::{ActionId, GateDecision};
use openspine_schemas::artifact::ArtifactRef;
use openspine_schemas::ids::PrincipalId;
use ulid::Ulid;

/// Owned audit-row inputs for [`Store::with_audited_effect`], mirroring the
Expand All @@ -36,6 +37,9 @@ pub struct AuditDescriptor {
pub target_refs: Vec<ArtifactRef>,
/// Artifact references carried as payload metadata.
pub payload_refs: Vec<ArtifactRef>,
/// Optional typed principal that authored the effect (spec #197 D-003).
/// Folded into the audit pre-image via `append_audit_conn_with_actor`.
pub actor: Option<PrincipalId>,
}

impl AuditDescriptor {
Expand All @@ -52,6 +56,7 @@ impl AuditDescriptor {
task_grant_id: None,
target_refs: Vec::new(),
payload_refs: Vec::new(),
actor: None,
}
}

Expand All @@ -60,6 +65,14 @@ impl AuditDescriptor {
self.reason = Some(reason.into());
self
}

/// Attach the typed principal that authored this effect (spec #197 D-003),
/// moving owner facts out of the reason string into the typed audit actor
/// dimension. Folded into the audit pre-image so it cannot be rewritten.
pub fn with_actor(mut self, actor: PrincipalId) -> Self {
self.actor = Some(actor);
self
Comment on lines +72 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward descriptor actors through begin_effect

When an AuditDescriptor built with .with_actor(...) is passed to Store::begin_effect—the other existing consumer of this descriptor—effect_settlement.rs still calls the actor-less append_audit_conn, so the actor is silently persisted as null. This loses attribution for any owner-authored fenced effect despite the new descriptor contract; that path should pass audit.actor to append_audit_conn_with_actor as well.

Useful? React with 👍 / 👎.

}
}

impl Store {
Expand All @@ -85,7 +98,7 @@ impl Store {
) -> Result<T, StoreError> {
self.with_immediate_tx(|tx| {
let value = effect(tx)?;
Self::append_audit_conn(
Self::append_audit_conn_with_actor(
tx,
descriptor.kind.as_str(),
descriptor.action.as_ref(),
Expand All @@ -94,6 +107,9 @@ impl Store {
descriptor.task_grant_id,
&descriptor.target_refs,
&descriptor.payload_refs,
None,
None,
descriptor.actor.as_ref(),
)?;
Ok(value)
})
Expand Down
1 change: 1 addition & 0 deletions crates/openspine-kernel/src/store/event_bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ impl Store {
"aggregate_id",
"aggregate_seq",
"payload_json",
"actor",
] {
let normalized = match field {
"aggregate_id" => meta
Expand Down
89 changes: 57 additions & 32 deletions crates/openspine-kernel/src/store/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use openspine_schemas::digest::Digest;
use openspine_schemas::identity::{
EntityType, Identifier, IdentifierKind, IdentifierVerificationMethod, Identity,
};
use openspine_schemas::ids::PrincipalId;
use openspine_schemas::principal::Principal;
use rusqlite::{params, OptionalExtension};
use sha2::Digest as _;
Expand Down Expand Up @@ -66,6 +67,20 @@ impl Store {
});

if !has_matching_identifier {
// Reserved owner-config-mismatch rejection kind (spec #208 D-007).
// Audit-only (no state effect), so it uses the append_audit* entry
// per the audited_effect module contract: commit the durable
// rejection row, then fail closed. The actor is the stored owner.
self.append_audit_with_actor(
"identity.owner_config_mismatch",
None,
None,
Some(&format!("identity={}", owner_identity.id)),
None,
&[],
&[],
Some(&PrincipalId::from(owner.id)),
)?;
return Err(StoreError::NotOwner(
"Stored owner Telegram identifier does not match current configured owner ID"
.to_string(),
Expand Down Expand Up @@ -109,38 +124,46 @@ impl Store {
schema_version: 1,
};

let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// Owner-creation is the reserved bootstrap-binding audit kind; the
// actor is the newly minted owner principal (spec #208 D-007). Routed
// through with_audited_effect so the owner insert and its audit row
// commit atomically in one Immediate transaction.
let descriptor = AuditDescriptor::new("identity.bootstrapped")
.with_reason(format!("identity={identity_id}"))
.with_actor(PrincipalId::from(principal.id));
let principal_json = serde_json::to_string(&principal)?;

tx.execute(
"INSERT INTO identities (id, identity_json) VALUES (?1, ?2)",
params![
identity_id.to_string(),
serde_json::to_string(&owner_identity)?
],
)?;
self.with_audited_effect(descriptor, |tx| {
tx.execute(
"INSERT INTO identities (id, identity_json) VALUES (?1, ?2)",
params![
identity_id.to_string(),
serde_json::to_string(&owner_identity)?
],
)?;

tx.execute(
"INSERT INTO identity_identifiers (value_hash, identifier_kind, identity_id) VALUES (?1, ?2, ?3)",
params![
owner_hash.as_str(),
"telegram_user_id",
identity_id.to_string()
],
)?;
tx.execute(
"INSERT INTO identity_identifiers (value_hash, identifier_kind, identity_id) VALUES (?1, ?2, ?3)",
params![
owner_hash.as_str(),
"telegram_user_id",
identity_id.to_string()
],
)?;

let principal_json = serde_json::to_string(&principal)?;
tx.execute(
"INSERT INTO principals (id, identity_id, is_owner, principal_json) VALUES (?1, ?2, ?3, ?4)",
params![
principal.id.to_string(),
principal.identity_id.to_string(),
1,
principal_json
],
)?;
tx.execute(
"INSERT INTO principals (id, identity_id, is_owner, principal_json) VALUES (?1, ?2, ?3, ?4)",
params![
principal.id.to_string(),
principal.identity_id.to_string(),
1,
principal_json
],
)?;

Ok(())
})?;

tx.commit()?;
Ok(principal)
}

Expand Down Expand Up @@ -212,10 +235,12 @@ impl Store {
// Enforce owner-principal context boundary
self.owner_principal_by_id(owner_principal_id)?;

let descriptor = AuditDescriptor::new("identity.bound").with_reason(format!(
"owner={owner_principal_id} identity={}",
identity.id
));
// Owner fact moves out of the reason string into the typed actor
// dimension (spec #208 D-007); reason keeps only the bound-identity
// target reference.
let descriptor = AuditDescriptor::new("identity.bound")
.with_reason(format!("identity={}", identity.id))
.with_actor(PrincipalId::from(owner_principal_id));

self.with_audited_effect(descriptor, |tx| {
// Insert identity
Expand Down
178 changes: 178 additions & 0 deletions crates/openspine-kernel/src/store/identity_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,182 @@ mod tests {
);
}
}

/// Return the `actor` field (as a JSON value) of the single audit event of
/// `kind`, panicking if there is not exactly one such event.
fn actor_of_kind(store: &Store, kind: &str) -> serde_json::Value {
let events = store.all_audit_event_jsons().unwrap();
let mut matching: Vec<serde_json::Value> = events
.iter()
.map(|j| serde_json::from_str::<serde_json::Value>(j).unwrap())
.filter(|v| v["kind"] == kind)
.collect();
assert_eq!(
matching.len(),
1,
"expected exactly one {kind} audit event, found {}",
matching.len()
);
matching.remove(0)["actor"].clone()
}

#[test]
fn bootstrap_emits_bootstrapped_kind_with_owner_actor() {
let store = Store::open_in_memory().unwrap();
let owner = store.bootstrap_owner_principal(42, "George").unwrap();

// Exactly one bootstrap-binding audit, carrying the new owner as actor.
assert_eq!(
store
.count_audit_events_of_kind("identity.bootstrapped")
.unwrap(),
1
);
assert_eq!(
actor_of_kind(&store, "identity.bootstrapped"),
serde_json::json!(owner.id.to_string())
);

// Idempotent re-bootstrap takes the fast path and emits no new row.
store.bootstrap_owner_principal(42, "George").unwrap();
assert_eq!(
store
.count_audit_events_of_kind("identity.bootstrapped")
.unwrap(),
1
);
}

#[test]
fn identity_bound_carries_actor_and_no_owner_fact_in_reason() {
let store = Store::open_in_memory().unwrap();
let owner = store.bootstrap_owner_principal(42, "George").unwrap();

let mut hasher = sha2::Sha256::new();
hasher.update(b"999");
let val_hash = openspine_schemas::digest::digest_from_hash(hasher.finalize().into());
let counterparty = Identity {
id: Ulid::new(),
display_name: "Bound Counterparty".to_string(),
entity_type: EntityType::Person,
identifiers: vec![Identifier {
kind: IdentifierKind::TelegramUserId,
value_hash: val_hash,
verified: true,
verification_method: IdentifierVerificationMethod::UserConfirmed,
}],
relationships: vec![],
schema_version: 1,
};
store
.owner_assert_identity_binding(owner.id, &OwnerVerifiedProof::test_new(), &counterparty)
.unwrap();

// Owner fact is carried by the typed actor dimension, not the reason.
assert_eq!(
actor_of_kind(&store, "identity.bound"),
serde_json::json!(owner.id.to_string())
);
let bound = store
.all_audit_event_jsons()
.unwrap()
.into_iter()
.map(|j| serde_json::from_str::<serde_json::Value>(&j).unwrap())
.find(|v| v["kind"] == "identity.bound")
.unwrap();
let reason = bound["reason"].as_str().unwrap_or_default();
assert!(
!reason.contains("owner="),
"owner fact must not remain in the reason string: {reason}"
);
assert!(!reason.contains(&owner.id.to_string()));
}

#[test]
fn config_mismatch_emits_audit_with_actor_and_still_rejects() {
let store = Store::open_in_memory().unwrap();
let owner = store.bootstrap_owner_principal(42, "George").unwrap();

// A different configured owner id fails closed but records a durable row.
let res = store.bootstrap_owner_principal(99, "George");
assert!(matches!(res.unwrap_err(), StoreError::NotOwner(_)));

assert_eq!(
store
.count_audit_events_of_kind("identity.owner_config_mismatch")
.unwrap(),
1
);
// Actor is the stored owner principal, not the rejected config id.
assert_eq!(
actor_of_kind(&store, "identity.owner_config_mismatch"),
serde_json::json!(owner.id.to_string())
);
}

#[test]
fn resolution_paths_do_not_write_audit_rows() {
let store = Store::open_in_memory().unwrap();
let owner = store.bootstrap_owner_principal(42, "George").unwrap();

let before = store.all_audit_event_jsons().unwrap().len();
let _ = store.get_identity(owner.identity_id).unwrap();
let _ = store.principal_exists(owner.id).unwrap();
let mut hasher = sha2::Sha256::new();
hasher.update(b"42");
let owner_hash = openspine_schemas::digest::digest_from_hash(hasher.finalize().into());
let _ = store
.resolve_identity_by_identifier_hash(&owner_hash, IdentifierKind::TelegramUserId)
.unwrap();
let after = store.all_audit_event_jsons().unwrap().len();

assert_eq!(before, after, "resolution must not write audit rows");
}

#[test]
fn no_raw_identifier_is_persisted_across_the_audit_dimension() {
let store = Store::open_in_memory().unwrap();
// Distinctive raw id, unlikely to collide with a ULID/timestamp.
let raw = 555_000_111_222_i64;
let raw_str = raw.to_string();
store.bootstrap_owner_principal(raw, "George").unwrap();
// Also exercise the mismatch path with another distinctive id.
let raw2 = 999_888_777_665_i64;
let raw2_str = raw2.to_string();
let _ = store.bootstrap_owner_principal(raw2, "George");

let conn = store.conn.lock();
let mut stmt = conn
.prepare("SELECT event_json, meta_json, COALESCE(kind, '') FROM audit_log")
.unwrap();
let rows: Vec<(String, String, String)> = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.map(Result::unwrap)
.collect();
for (event_json, meta_json, _kind) in &rows {
for raw in [&raw_str, &raw2_str] {
assert!(
!event_json.contains(raw.as_str()),
"raw identifier {raw} leaked into event_json"
);
assert!(
!meta_json.contains(raw.as_str()),
"raw identifier {raw} leaked into meta_json"
);
}
}
// identity_identifiers stores only value hashes, never the raw id.
let mut stmt = conn
.prepare("SELECT value_hash FROM identity_identifiers")
.unwrap();
let hashes: Vec<String> = stmt
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
for h in &hashes {
assert!(!h.contains(&raw_str) && !h.contains(&raw2_str));
}
}
}
Loading