feat: integrate Subject permission management with Worker contracts
This commit is contained in:
@@ -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(¤t, &activation("ticket")).await.0,
|
||||
f.check(¤t, &query_permission()).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
f.release_source_lease();
|
||||
assert_eq!(
|
||||
f.check(¤t, &activation("ticket")).await.0,
|
||||
f.check(¤t, &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(¤t).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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user