fix: authorize bound standalone Subject body and Job operations
This commit is contained in:
@@ -803,6 +803,41 @@ impl LocalSubjectHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl SubjektivHost for LocalSubjectHost {
|
impl SubjektivHost for LocalSubjectHost {
|
||||||
|
fn authorize_operation(
|
||||||
|
&self,
|
||||||
|
context: &SubjektivHostContext,
|
||||||
|
operation: &str,
|
||||||
|
) -> Result<(), SubjektivHostError> {
|
||||||
|
// An explicit operator connection issues this local body capability.
|
||||||
|
// Revalidate its live writer on every access/supply, not Feature enablement.
|
||||||
|
// Job decisions and Backend conversations are not local body capabilities.
|
||||||
|
self.body(context)?;
|
||||||
|
match operation {
|
||||||
|
"session.record"
|
||||||
|
| "memory.resident.read"
|
||||||
|
| "memory.resident.supply"
|
||||||
|
| "memory.surface.supply"
|
||||||
|
| "memory.query"
|
||||||
|
| "memory.read"
|
||||||
|
| "memory.changes.list"
|
||||||
|
| "memory.proposal.validate"
|
||||||
|
| "memory.receipt.read"
|
||||||
|
| "memory.staging.write"
|
||||||
|
| "memory.extract.read"
|
||||||
|
| "memory.extract.supply"
|
||||||
|
| "memory.extract.commit"
|
||||||
|
| "memory.consolidation.request"
|
||||||
|
| "journal.startup.read"
|
||||||
|
| "journal.startup.supply"
|
||||||
|
| "journal.compaction.read"
|
||||||
|
| "journal.compaction.supply"
|
||||||
|
| "journal.recording.request" => Ok(()),
|
||||||
|
_ => Err(SubjektivHostError::PermissionDenied(format!(
|
||||||
|
"operation `{operation}` is not granted by the local Subject body capability"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn memory_journal(
|
fn memory_journal(
|
||||||
&self,
|
&self,
|
||||||
context: &SubjektivHostContext,
|
context: &SubjektivHostContext,
|
||||||
@@ -1176,6 +1211,37 @@ impl LocalConsolidationHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl SubjektivHost for LocalConsolidationHost {
|
impl SubjektivHost for LocalConsolidationHost {
|
||||||
|
fn authorize_operation(
|
||||||
|
&self,
|
||||||
|
context: &SubjektivHostContext,
|
||||||
|
operation: &str,
|
||||||
|
) -> Result<(), SubjektivHostError> {
|
||||||
|
self.live()?;
|
||||||
|
let attempt = format!("job:{}", self.attempt_id);
|
||||||
|
if context.worker_id != attempt || context.session_id != attempt {
|
||||||
|
return Err(SubjektivHostError::PermissionDenied(
|
||||||
|
"context is not bound to this local consolidation attempt".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// The Host-issued batch/attempt grant is independent of body permission.
|
||||||
|
// Domain operations still enforce candidate membership and saved receipts.
|
||||||
|
match operation {
|
||||||
|
"memory.query"
|
||||||
|
| "memory.read"
|
||||||
|
| "memory.changes.list"
|
||||||
|
| "memory.staging.list"
|
||||||
|
| "memory.staging.read"
|
||||||
|
| "memory.candidate.decide"
|
||||||
|
| "memory.surface.prepare"
|
||||||
|
| "memory.surface.supply"
|
||||||
|
| "memory.surface.publish"
|
||||||
|
| "memory.surface.fail" => Ok(()),
|
||||||
|
_ => Err(SubjektivHostError::PermissionDenied(format!(
|
||||||
|
"operation `{operation}` is not granted by the local consolidation attempt"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn settings(&self) -> SubjektivHostSettings {
|
fn settings(&self) -> SubjektivHostSettings {
|
||||||
self.host.settings()
|
self.host.settings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,56 @@ fn closed_capabilities_and_foreign_worker_context_are_rejected() {
|
|||||||
session_id: session_store::SessionId::now_v7().to_string(),
|
session_id: session_store::SessionId::now_v7().to_string(),
|
||||||
};
|
};
|
||||||
assert!(port.memory(&owner, read.clone()).is_ok());
|
assert!(port.memory(&owner, read.clone()).is_ok());
|
||||||
|
let local_operations = [
|
||||||
|
"memory.resident.read",
|
||||||
|
"memory.resident.supply",
|
||||||
|
"memory.staging.write",
|
||||||
|
"journal.startup.read",
|
||||||
|
"journal.startup.supply",
|
||||||
|
"journal.compaction.read",
|
||||||
|
"journal.compaction.supply",
|
||||||
|
"journal.recording.request",
|
||||||
|
];
|
||||||
|
for operation in local_operations {
|
||||||
|
assert!(
|
||||||
|
port.authorize_operation(&owner, operation).is_ok(),
|
||||||
|
"{operation}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
port.authorize_operation(&foreign, operation).is_err(),
|
||||||
|
"{operation}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for operation in [
|
||||||
|
"chat.read",
|
||||||
|
"chat.send",
|
||||||
|
"worker.messaging",
|
||||||
|
"memory.candidate.decide",
|
||||||
|
"memory.surface.publish",
|
||||||
|
"memory.staging.list",
|
||||||
|
"journal",
|
||||||
|
"journal.startup",
|
||||||
|
"journal.startup.read.future",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
port.authorize_operation(&owner, operation),
|
||||||
|
Err(SubjektivHostError::PermissionDenied(_))
|
||||||
|
),
|
||||||
|
"local body must not grant {operation}"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert!(
|
assert!(
|
||||||
port.record_session(&owner, false).is_err(),
|
port.record_session(&owner, false).is_err(),
|
||||||
"unattributed Session is not read authority"
|
"unattributed Session is not read authority"
|
||||||
);
|
);
|
||||||
connection.close().unwrap();
|
connection.close().unwrap();
|
||||||
|
for operation in local_operations {
|
||||||
|
assert!(
|
||||||
|
port.authorize_operation(&owner, operation).is_err(),
|
||||||
|
"released capability: {operation}"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert!(port.memory(&owner, read).is_err());
|
assert!(port.memory(&owner, read).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,3 +179,91 @@ fn different_local_roots_do_not_authorize_shared_subject_ids() {
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consolidation_operation_policy_is_attempt_bound_and_does_not_inherit_body_rights() {
|
||||||
|
struct Fence(AtomicBool);
|
||||||
|
impl crate::jobs::JobAttemptFence for Fence {
|
||||||
|
fn ensure_live(&self) -> Result<(), String> {
|
||||||
|
self.0
|
||||||
|
.load(Ordering::Acquire)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or("attempt revoked".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
|
||||||
|
let subject = catalog.create("owner", None).unwrap();
|
||||||
|
let mut body = catalog
|
||||||
|
.connect(&subject.id, protocol::WorkerId::now_v7())
|
||||||
|
.unwrap();
|
||||||
|
let fence = Arc::new(Fence(AtomicBool::new(true)));
|
||||||
|
let job = LocalConsolidationHost {
|
||||||
|
host: body.host.clone(),
|
||||||
|
grant: LocalConsolidationGrant {
|
||||||
|
binding: body.binding.clone(),
|
||||||
|
candidate_ids: vec![],
|
||||||
|
},
|
||||||
|
job_id: "job-1".into(),
|
||||||
|
attempt_id: "attempt-1".into(),
|
||||||
|
fence: fence.clone(),
|
||||||
|
};
|
||||||
|
let context = SubjektivHostContext {
|
||||||
|
worker_id: "job:attempt-1".into(),
|
||||||
|
session_id: "job:attempt-1".into(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
job.authorize_operation(&context, "memory.staging.list")
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
job.authorize_operation(&context, "memory.surface.supply")
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
let foreign = SubjektivHostContext {
|
||||||
|
worker_id: "job:old-attempt".into(),
|
||||||
|
session_id: "job:old-attempt".into(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
job.authorize_operation(&foreign, "memory.staging.list")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for op in [
|
||||||
|
"session.record",
|
||||||
|
"memory.resident.read",
|
||||||
|
"memory.resident.supply",
|
||||||
|
"memory.extract.read",
|
||||||
|
"memory.staging.write",
|
||||||
|
"journal.startup.read",
|
||||||
|
"chat.send",
|
||||||
|
"memory",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
job.authorize_operation(&context, op),
|
||||||
|
Err(SubjektivHostError::PermissionDenied(_))
|
||||||
|
),
|
||||||
|
"Job must not gain {op}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
job.memory(
|
||||||
|
&context,
|
||||||
|
SubjektivMemoryBackendOperation::ReadCandidate(SubjektivMemoryCandidateReadRequest {
|
||||||
|
candidate_id: "outside-batch".into()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
fence.0.store(false, Ordering::Release);
|
||||||
|
assert!(
|
||||||
|
job.authorize_operation(&context, "memory.staging.list")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
fence.0.store(true, Ordering::Release);
|
||||||
|
body.close().unwrap();
|
||||||
|
assert!(
|
||||||
|
job.authorize_operation(&context, "memory.staging.list")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user