feat: integrate Subject permission management with Worker contracts

This commit is contained in:
2026-10-30 05:02:30 +09:00
33 changed files with 4122 additions and 288 deletions
+18 -10
View File
@@ -9774,20 +9774,28 @@ impl server_api::ServerApi for ServerApiContractService {
let api = self.workspace_api()?.clone();
let actor = subject_authorization::administration_actor(&context)
.map_err(ApiError::into_repository_api_error)?;
require_workspace_owner(
&api,
&workspace_id,
actor,
"Subject permission administration",
)
.await
.map_err(ApiError::into_repository_api_error)?;
validate_workspace_scope(&api, &workspace_id)
.map_err(ApiError::into_repository_api_error)?;
let workspace = api
.store
.get_workspace(&workspace_id)
.await
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?
.ok_or_else(|| {
ApiError::from(Error::WorkspaceIdMismatch).into_repository_api_error()
})?;
let can_manage = workspace.owner_account_id == actor.account_id;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
Ok(open_subjektiv_store(&api)?
.subject_permissions(&subject_id)
.map(|permissions| server_api::SubjectPermissionsResponse { permissions })
.map(|permissions| server_api::SubjectPermissionsResponse {
permissions,
can_manage,
operations: server_api::authorization::subject_permission_operations(),
})
.map_err(subjektiv_store_error)?)
})
.await
@@ -9828,7 +9836,7 @@ impl server_api::ServerApi for ServerApiContractService {
let was_current = before.as_ref().map(|policy| &policy.change_id)
== request.expected_change_id.as_ref();
let permissions = store
.set_subject_permissions(&mutation_subject, &request)
.set_managed_subject_permissions(&mutation_subject, &request)
.map_err(subjektiv_store_error)?;
let changed = was_current
&& before.as_ref().map(|source| &source.change_id)
@@ -13,6 +13,8 @@ mod ticket_worker_messaging_connected;
mod worker_messaging_connected;
#[path = "worker_operation_authorization_tests.rs"]
mod worker_operation_authorization;
#[path = "subject_permission_management_tests.rs"]
mod subject_permission_management;
use agen::tool::{Tool, ToolExecutionContext, ToolOutput};
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate,
@@ -48,6 +50,14 @@ fn create_permission() -> ScopedPermission {
},
}
}
fn query_permission() -> ScopedPermission {
ScopedPermission {
operation: "ticket.query".into(),
resource: PermissionResource::Workspace {
workspace_id: TEST_WORKSPACE_ID.into(),
},
}
}
fn authorization_path() -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check")
}
@@ -185,6 +195,27 @@ impl Fixture {
self.subject
)
}
// Seed only intentionally unsupported saved input, never a management save.
// This models pre-catalog policies without teaching the HTTP validator to issue them.
fn seed_saved_policy(
&self,
execution: Vec<ScopedPermission>,
delegation: Vec<ScopedPermission>,
) -> SubjectPermissions {
let store = open_subjektiv_store(&self.api).unwrap();
let previous = store.subject_permissions(&self.subject).unwrap();
store
.set_subject_permissions(
&self.subject,
&SubjectPermissionsUpdate {
operation_id: Uuid::now_v7().to_string(),
expected_change_id: previous.map(|policy| policy.change_id),
execution,
delegation,
},
)
.unwrap()
}
async fn assign(
&self,
execution: Vec<ScopedPermission>,
@@ -418,6 +449,7 @@ async fn signed_current_body_uses_explicit_policy_and_old_unset_body_denies() {
let (status, body) = f.check(&f.source, &permission).await;
assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}");
}
f.seed_saved_policy(vec![activation("ticket")], vec![]);
f.assign(vec![activation("ticket"), create_permission()], vec![])
.await;
for permission in [activation("ticket"), create_permission()] {
@@ -603,7 +635,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
},
})
.collect();
f.assign(workspace_grants.clone(), vec![]).await;
f.seed_saved_policy(workspace_grants.clone(), vec![]);
for permission in &requested {
let (status, body) = f.check(&f.source, permission).await;
assert_eq!(
@@ -618,8 +650,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
.iter()
.map(|permission| permission.operation.split('.').next().unwrap())
.collect();
f.assign(feature_names.into_iter().map(activation).collect(), vec![])
.await;
f.seed_saved_policy(feature_names.into_iter().map(activation).collect(), vec![]);
for permission in &requested {
assert_eq!(
f.check(&f.source, permission).await.0,
@@ -627,7 +658,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
"{permission:?}"
);
}
f.assign(requested.clone(), vec![]).await;
f.seed_saved_policy(requested.clone(), vec![]);
for permission in &requested {
let (status, body) = f.check(&f.source, permission).await;
assert_eq!(
@@ -653,7 +684,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
async fn signed_host_rejects_cross_workspace_resource_claims_before_alias_resolution() {
let f = Fixture::new().await;
let permissions = concrete_resource_permissions(&f);
f.assign(permissions.clone(), vec![]).await;
f.seed_saved_policy(permissions.clone(), vec![]);
for permission in permissions
.iter()
.filter(|permission| permission.resource.workspace_id().is_some())
@@ -802,8 +833,8 @@ async fn signed_host_ungranted_operation_does_not_disclose_ticket_or_subject_exi
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
let body = serde_json::to_value(activation("ticket")).unwrap();
f.assign(vec![create_permission()], vec![]).await;
let body = serde_json::to_value(create_permission()).unwrap();
let (status, error) = response(
&f.api,
human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())),
@@ -901,7 +932,7 @@ async fn authorization_rejects_missing_source_unknown_identity_fields_and_tamper
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permissions() {
async fn only_owner_account_can_assign_shrink_and_revoke_subject_permissions() {
let f = Fixture::new().await;
let (status, body) = response(
&f.api,
@@ -912,8 +943,8 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
assert!(body["permissions"].is_null());
let original = f
.assign(
vec![activation("ticket"), create_permission()],
vec![activation("ticket")],
vec![create_permission(), query_permission()],
vec![create_permission()],
)
.await;
let (status, body) = response(
@@ -965,9 +996,20 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"foreign account {method}: {body}"
if method == "GET" {
StatusCode::OK
} else {
StatusCode::FORBIDDEN
},
"non-owner account {method}: {body}"
);
if method == "GET" {
assert_eq!(body["can_manage"], false);
assert_eq!(
body["permissions"],
serde_json::to_value(&original).unwrap()
);
}
}
assert_eq!(
open_subjektiv_store(&f.api)
@@ -976,7 +1018,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
.unwrap(),
Some(original.clone())
);
let shrunk = f.assign(vec![activation("ticket")], vec![]).await;
let shrunk = f.assign(vec![query_permission()], vec![]).await;
assert_ne!(shrunk.change_id, original.change_id);
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
@@ -995,7 +1037,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
let revoked = f.assign(vec![], vec![]).await;
assert!(revoked.execution.is_empty() && revoked.delegation.is_empty());
assert_eq!(
f.check(&f.source, &activation("ticket")).await.0,
f.check(&f.source, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
}
@@ -1146,6 +1188,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
("old-feature-only", vec![activation("ticket")]),
("wrong-resource", vec![wrong_workspace]),
] {
f.seed_saved_policy(permissions.clone(), vec![]);
f.assign(permissions, vec![]).await;
client.requests.lock().unwrap().clear();
let (installed, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await;
@@ -1188,7 +1231,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
1,
"genuine SQLite Ticket backend result: {created}"
);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let result = tool
.execute(
r#"{"title":"revoked after install"}"#,
@@ -1528,7 +1571,7 @@ fn creation_record_counts(f: &Fixture) -> [i64; 3] {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() {
let f = Fixture::new().await;
let permissions = vec![activation("ticket"), create_permission()];
let permissions = vec![query_permission(), create_permission()];
f.assign(vec![], permissions.clone()).await;
let mut child_request = f.child_request("redelegating-editor", permissions.clone());
child_request["execution_grant"]["redelegation"] = json!(permissions);
@@ -1645,7 +1688,7 @@ async fn generic_create_routes_preserve_human_and_non_subject_worker_creation()
async fn subject_control_creation_recovers_control_grant_failure_and_lost_response_without_duplicates()
{
let f = Fixture::new().await;
let permissions = vec![activation("ticket"), create_permission()];
let permissions = vec![query_permission(), create_permission()];
f.assign(vec![], permissions.clone()).await;
let mut missing_id = f.child_request("control-grant-recovery", permissions.clone());
missing_id
@@ -1842,7 +1885,7 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
let (status, body) = f
.spawn(
&child,
f.child_request("undelegatable-grandchild", vec![activation("ticket")]),
f.child_request("undelegatable-grandchild", vec![query_permission()]),
)
.await;
assert_eq!(
@@ -1855,12 +1898,12 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let mut singleton = f.child_request("singleton-escalation", vec![activation("ticket")]);
f.assign(vec![], vec![query_permission()]).await;
let mut singleton = f.child_request("singleton-escalation", vec![query_permission()]);
singleton["singleton_key"] = json!("model-chosen-singleton");
let mut body_attachment = f.child_request("subject-escalation", vec![activation("ticket")]);
let mut body_attachment = f.child_request("subject-escalation", vec![query_permission()]);
body_attachment["feature_connections"] = json!({"subjektiv":{"subject_id":f.subject}});
let mut spoof = f.child_request("origin-escalation", vec![activation("ticket")]);
let mut spoof = f.child_request("origin-escalation", vec![query_permission()]);
spoof["execution_grant"]["subject_id"] = json!(f.subject);
for request in [
singleton,
@@ -1882,12 +1925,9 @@ async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_
async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_falls_back_to_legacy()
{
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let child = f.child("revocable-child", vec![activation("ticket")]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
StatusCode::OK
);
f.assign(vec![], vec![query_permission()]).await;
let child = f.child("revocable-child", vec![query_permission()]).await;
assert_eq!(f.check(&child, &query_permission()).await.0, StatusCode::OK);
let before = open_subjektiv_store(&f.api)
.unwrap()
.worker_execution_grant(&child.runtime_id, &child.worker_id)
@@ -1895,7 +1935,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
.unwrap();
f.assign(vec![], vec![]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
let observation = f
@@ -1913,7 +1953,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
Some(json!({"expected_observation_token":observation,"request_id":"restore-revoked-child"})))).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
assert_eq!(
@@ -1925,33 +1965,33 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
);
// Regranting identical permissions changes the assignment identity. Old
// grants remain revoked rather than refreshing during restore/reconnect.
f.assign(vec![], vec![activation("ticket")]).await;
f.assign(vec![], vec![query_permission()]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
let current = f
.child("current-before-origin-release", vec![activation("ticket")])
.child("current-before-origin-release", vec![query_permission()])
.await;
assert_eq!(
f.check(&current, &activation("ticket")).await.0,
f.check(&current, &query_permission()).await.0,
StatusCode::OK
);
f.release_source_lease();
assert_eq!(
f.check(&current, &activation("ticket")).await.0,
f.check(&current, &query_permission()).await.0,
StatusCode::CONFLICT,
"the captured source lease is no longer current"
);
assert_eq!(
f.check(&f.source, &activation("ticket")).await.0,
f.check(&f.source, &query_permission()).await.0,
StatusCode::UNAUTHORIZED,
"released body fails signed-source authentication instead of falling through to legacy compatibility"
);
let (status, _) = f
.spawn(
&f.source,
f.child_request("released-origin-spawn", vec![activation("ticket")]),
f.child_request("released-origin-spawn", vec![query_permission()]),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
@@ -1960,7 +2000,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let client = f.client(&f.source);
let (mut host, installed) =
ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await;
@@ -2027,7 +2067,7 @@ async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_bac
let backend = browser_ticket_backend(&f.api).unwrap();
let tickets = backend.list(Default::default()).unwrap();
assert_eq!(tickets.len(), 1);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
assert!(
invoke
.execute(&args, ToolExecutionContext::direct())
@@ -2047,7 +2087,7 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
{
let f = Fixture::new().await;
let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null,
"execution":[activation("ticket")],"delegation":[]});
"execution":[create_permission()],"delegation":[]});
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())),
@@ -2101,13 +2141,13 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket"), create_permission()])
f.assign(vec![], vec![query_permission(), create_permission()])
.await;
let mut request = f.child_request(
"redelegating-parent",
vec![activation("ticket"), create_permission()],
vec![query_permission(), create_permission()],
);
request["execution_grant"]["redelegation"] = json!([activation("ticket")]);
request["execution_grant"]["redelegation"] = json!([query_permission()]);
let (status, body) = f.spawn(&f.source, request).await;
assert_eq!(status, StatusCode::OK, "{body}");
let parent = RuntimeWorkerRef::new(
@@ -2117,7 +2157,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
let (status, body) = f
.spawn(
&parent,
f.child_request("allowed-grandchild", vec![activation("ticket")]),
f.child_request("allowed-grandchild", vec![query_permission()]),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -2135,7 +2175,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
"keep immediate lineage instead of flattening to the body"
);
assert_eq!(
f.check(&grandchild, &activation("ticket")).await.0,
f.check(&grandchild, &query_permission()).await.0,
StatusCode::OK
);
assert_eq!(
@@ -2157,7 +2197,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
assert_eq!(f.runtime.spawn_requests().len(), calls);
f.assign(vec![], vec![]).await;
assert_eq!(
f.check(&grandchild, &activation("ticket")).await.0,
f.check(&grandchild, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
}
@@ -2165,8 +2205,8 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let mut request = f.child_request("profile-bound-once", vec![activation("ticket")]);
f.assign(vec![], vec![query_permission()]).await;
let mut request = f.child_request("profile-bound-once", vec![query_permission()]);
request["profile"] = json!("project:authority-editor");
let (status, body) = f.spawn(&f.source, request.clone()).await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -2202,7 +2242,7 @@ async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_oper
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let client = f.client(&f.source);
let (_host, installed, service) =
ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await;
@@ -2278,7 +2318,7 @@ async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source(
"Service authorized"
);
assert_eq!(ticket_posts(&client), 1);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
assert!(
tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service")))
.await
@@ -0,0 +1,579 @@
//! Management HTTP integration: real router/account authentication, catalog
//! validation, Subject SQLite policy/receipts and deletion; no running service or
//! model. The connected Ticket case also uses the signed Worker Host, real Tool
//! and Ticket backend via the parent suite's isolated Runtime fixture.
use super::*;
struct ManagementFixture {
api: WorkspaceApi,
owner: String,
subject: String,
_dir: tempfile::TempDir,
}
impl ManagementFixture {
async fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "permission-manager").await;
let subject = open_subjektiv_store(&api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("permission-management").unwrap())
.unwrap()
.id;
Self {
api,
owner,
subject,
_dir: dir,
}
}
fn path(&self) -> String {
format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",
self.subject
)
}
async fn read(&self) -> Value {
let (status, body) = response(
&self.api,
human_request(&self.owner, "GET", &self.path(), None),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
body
}
async fn save(&self, update: Value) -> Value {
let (status, body) = response(
&self.api,
human_request(&self.owner, "POST", &self.path(), Some(update)),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
body
}
fn assert_current(&self, expected: &Value) {
let saved = open_subjektiv_store(&self.api)
.unwrap()
.subject_permissions(&self.subject)
.unwrap();
assert_eq!(serde_json::to_value(saved).unwrap(), *expected);
}
}
fn update(screen: &Value, operation: &str, execution: Value, delegation: Value) -> Value {
json!({"operation_id":operation,"expected_change_id":screen["permissions"]["change_id"],
"execution":execution,"delegation":delegation})
}
#[tokio::test]
async fn management_read_exposes_capability_and_catalog_without_enabling_pending_worker_operations()
{
let f = ManagementFixture::new().await;
let screen = f.read().await;
assert!(screen["permissions"].is_null());
assert_eq!(screen["can_manage"], true);
let operations = screen["operations"].as_array().expect("management catalog");
let create = operations
.iter()
.find(|entry| entry["operation"] == "ticket.create")
.unwrap();
assert_eq!(create["availability"]["kind"], "available");
assert!(
create["resource_kinds"]
.as_array()
.unwrap()
.contains(&json!("workspace"))
);
assert!(!create["label"].as_str().unwrap().is_empty());
assert!(!create["description"].as_str().unwrap().is_empty());
let messaging = operations
.iter()
.find(|entry| entry["operation"] == "worker.messaging")
.unwrap();
assert_eq!(messaging["availability"]["kind"], "unavailable");
assert!(
!messaging["availability"]["reason"]
.as_str()
.unwrap()
.is_empty()
);
for name in [
"worker.messaging",
"worker.send_input",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.observe",
] {
let operation = operations
.iter()
.find(|entry| entry["operation"] == name)
.unwrap();
assert_eq!(
operation["availability"]["kind"], "unavailable",
"pending Worker contracts: {operation}"
);
assert_eq!(
operation["resource_kinds"],
json!([]),
"do not offer duplicate target control grants"
);
}
}
#[tokio::test]
async fn two_management_screens_conflict_without_overwriting_saved_execution_or_delegation() {
let f = ManagementFixture::new().await;
let first = f.read().await;
let second = f.read().await;
let saved = f
.save(update(
&first,
"screen-one",
json!([create_permission()]),
json!([query_permission()]),
))
.await;
let stale = update(
&second,
"screen-two",
json!([]),
json!([create_permission()]),
);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(stale)),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(
body["message"].is_string(),
"conflict must be visible: {body}"
);
assert_eq!(f.read().await["permissions"], saved["permissions"]);
f.assert_current(&saved["permissions"]);
}
#[tokio::test]
async fn discarded_save_response_recovers_by_exact_replay_and_authoritative_reread() {
let f = ManagementFixture::new().await;
let input = update(
&f.read().await,
"lost-save-response",
json!([create_permission()]),
json!([query_permission()]),
);
// Commit through the real HTTP route, then discard the entire response. This
// models response loss, not process death or rollback of the committed save.
drop(
build_router(f.api.clone())
.oneshot(human_request(
&f.owner,
"POST",
&f.path(),
Some(input.clone()),
))
.await
.unwrap(),
);
let reread = f.read().await;
assert_eq!(reread["permissions"]["execution"], input["execution"]);
assert_eq!(reread["permissions"]["delegation"], input["delegation"]);
let replay = f.save(input.clone()).await;
assert_eq!(replay["permissions"], reread["permissions"]);
assert_eq!(replay["interruptions"], json!([]));
assert_eq!(f.read().await["permissions"], reread["permissions"]);
f.assert_current(&reread["permissions"]);
let mut changed = input;
changed["execution"] = json!([]);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(changed)),
)
.await;
assert_eq!(
status,
StatusCode::CONFLICT,
"operation identity cannot be reused: {body}"
);
f.assert_current(&reread["permissions"]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn historical_save_receipt_after_shrink_and_revoke_never_restores_current_authority() {
let f = Fixture::new().await;
let (status, screen) = response(
&f.api,
human_request(&f.owner, "GET", &f.policy_path(), None),
)
.await;
assert_eq!(status, StatusCode::OK);
let original_input = update(
&screen,
"before-shrink",
json!([create_permission()]),
json!([create_permission()]),
);
let (status, original) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.policy_path(),
Some(original_input.clone()),
),
)
.await;
assert_eq!(status, StatusCode::OK, "{original}");
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
StatusCode::OK
);
for delegation in [vec![create_permission()], vec![]] {
let current = f.assign(vec![], delegation).await;
let (status, replay) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.policy_path(),
Some(original_input.clone()),
),
)
.await;
assert_eq!(status, StatusCode::OK, "historical receipt: {replay}");
assert_eq!(replay["permissions"], original["permissions"]);
assert_eq!(replay["interruptions"], json!([]));
let (status, read) = response(
&f.api,
human_request(&f.owner, "GET", &f.policy_path(), None),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read["permissions"], serde_json::to_value(&current).unwrap());
assert_ne!(read["permissions"], replay["permissions"]);
assert_eq!(
open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.unwrap(),
Some(current)
);
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
StatusCode::FORBIDDEN
);
}
}
#[tokio::test]
async fn subject_deletion_rejects_stale_screen_save_and_exact_receipt_replay_without_revival() {
let f = ManagementFixture::new().await;
let screen = f.read().await;
let initial_input = update(
&screen,
"save-before-delete",
json!([create_permission()]),
json!([query_permission()]),
);
let saved = f.save(initial_input.clone()).await;
let stale_screen = json!({"permissions":saved["permissions"]});
let late_input = update(
&stale_screen,
"late-screen-save",
json!([query_permission()]),
json!([]),
);
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"DELETE",
&format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}",
f.subject
),
Some(json!({"operation_id":"delete-after-save"})),
),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["completed"], true);
for input in [late_input, initial_input] {
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"deleted Subject cannot be revived: {body}"
);
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject(&f.subject)
.unwrap()
.is_none()
);
}
let (status, body) = response(&f.api, human_request(&f.owner, "GET", &f.path(), None)).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.is_err()
);
}
#[tokio::test]
async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() {
let f = ManagementFixture::new().await;
let account = seed_test_api_token(f.api.store.as_ref(), "permission-reader");
let initial = f.read().await;
let input = update(
&initial,
"owner-only-save",
json!([create_permission()]),
json!([]),
);
let (status, read) = response(&f.api, human_request(&account, "GET", &f.path(), None)).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(read["can_manage"], false);
assert_eq!(read["permissions"], initial["permissions"]);
let (status, body) = response(
&f.api,
human_request(&account, "POST", &f.path(), Some(input.clone())),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
f.assert_current(&Value::Null);
let saved = f.save(input.clone()).await;
let (status, body) = response(
&f.api,
human_request(&account, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"exact owner receipt replay must still authorize the caller: {body}"
);
f.assert_current(&saved["permissions"]);
}
#[tokio::test]
async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_rejected_without_policy_write()
{
let f = ManagementFixture::new().await;
let screen = f.read().await;
let mut unknown = create_permission();
unknown.operation = "ticket.future_operation".into();
let mut mismatch = create_permission();
mismatch.resource = PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: "not-a-collection".into(),
};
let mut unavailable = query_permission();
unavailable.operation = "worker.messaging".into();
for (name, permission) in [
("unknown", unknown),
("legacy", activation("ticket")),
("resource-mismatch", mismatch),
("unavailable", unavailable),
] {
for lane in ["execution", "delegation"] {
let mut input = update(
&screen,
&format!("reject-{name}-{lane}"),
json!([]),
json!([]),
);
input[lane] = json!([permission]);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}");
assert!(body["message"].is_string(), "{body}");
f.assert_current(&Value::Null);
assert!(f.read().await["permissions"].is_null());
}
}
}
#[tokio::test]
async fn unsupported_saved_entries_can_only_be_retained_in_the_same_lane_or_removed() {
let f = ManagementFixture::new().await;
let execution = vec![activation("ticket")];
let mut unknown = query_permission();
unknown.operation = "legacy.custom_operation".into();
let delegation = vec![unknown];
open_subjektiv_store(&f.api)
.unwrap()
.set_subject_permissions(
&f.subject,
&SubjectPermissionsUpdate {
operation_id: "seed-pre-catalog-policy".into(),
expected_change_id: None,
execution: execution.clone(),
delegation: delegation.clone(),
},
)
.unwrap();
let screen = f.read().await;
let saved = f
.save(update(
&screen,
"retain-existing",
json!([execution[0], create_permission()]),
json!(delegation),
))
.await;
assert_eq!(
saved["permissions"]["execution"],
json!([execution[0], create_permission()])
);
assert_eq!(saved["permissions"]["delegation"], json!(delegation));
let screen = f.read().await;
for (name, execution, delegation) in [
(
"copy-legacy-lane",
json!([activation("ticket"), create_permission()]),
json!([activation("ticket")]),
),
(
"copy-unknown-lane",
json!([delegation[0], create_permission()]),
json!(delegation),
),
] {
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.path(),
Some(update(&screen, name, execution, delegation)),
),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"retention cannot expand into another lane: {body}"
);
f.assert_current(&saved["permissions"]);
}
let removed = f
.save(update(
&screen,
"remove-unsupported",
json!([create_permission()]),
json!([]),
))
.await;
assert_eq!(
removed["permissions"]["execution"],
json!([create_permission()])
);
assert_eq!(removed["permissions"]["delegation"], json!([]));
f.assert_current(&removed["permissions"]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn management_saved_policy_controls_real_ticket_tool_and_raw_http_before_backend_writes() {
let f = Fixture::new().await;
let client = f.client(&f.source);
let (installed, tool) = ticket_tool_host(
&f._dir.path().join("management-ticket-tool"),
client.clone(),
)
.await;
assert!(installed);
let tool = tool.unwrap();
assert!(
client.requests.lock().unwrap().is_empty(),
"local registration is not authorization"
);
let backend = browser_ticket_backend(&f.api).unwrap();
for state in ["unset", "out-of-scope", "allowed", "revoked"] {
match state {
"unset" => {}
"out-of-scope" => {
// Intentionally migrated, unsupported kind: management may retain
// this exact saved entry but it must never authorize collection creation.
let mut wrong_kind = create_permission();
wrong_kind.resource = PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: "legacy-ticket-scope".into(),
};
f.seed_saved_policy(vec![wrong_kind.clone()], vec![]);
f.assign(vec![wrong_kind], vec![]).await;
}
"allowed" => {
f.assign(vec![create_permission()], vec![]).await;
}
"revoked" => {
f.assign(vec![], vec![]).await;
}
_ => unreachable!(),
}
let before = backend.list(Default::default()).unwrap().len();
let posts = ticket_posts(&client);
let result = tool
.execute(
&json!({"title":format!("Managed policy {state}")}).to_string(),
ToolExecutionContext::direct(),
)
.await;
if state == "allowed" {
let output = value(result.unwrap());
assert_eq!(ticket_posts(&client), posts + 1);
let records = backend.list(Default::default()).unwrap();
assert_eq!(records.len(), before + 1, "real SQLite write: {output}");
assert!(
records
.iter()
.any(|record| record.title == "Managed policy allowed")
);
} else {
assert!(result.is_err(), "{state}: Tool must deny");
assert_eq!(
ticket_posts(&client),
posts,
"{state}: no downstream create dispatch"
);
let (status, body) = response(
&f.api,
runtime_source_request(
&f.identity,
Some(&f.source.worker_id),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets"),
serde_json::to_vec(&NewTicket::new(format!("Raw denied {state}"))).unwrap(),
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{state}: direct Backend path also rechecks: {body}"
);
assert_eq!(
backend.list(Default::default()).unwrap().len(),
before,
"{state}: denied writes = 0"
);
}
}
}