Implement authorization-fenced web workflows

This commit is contained in:
ms2k 2026-07-18 01:49:00 +09:00
commit 267665bb35
4 changed files with 3853 additions and 156 deletions

View file

@ -9,9 +9,9 @@ use remora_admission::{AdmissionDecision, AdmissionService};
use remora_attempt::{AttemptService, CommittedResultRecord, ProgressSnapshot};
use remora_contracts::{
ApiErrorCodeDto, ApiErrorDto, CredentialMaterialDto, DateTimeDto, JobSubmissionDto,
NullableDto, SourceCredentialCreateDto, SourceCredentialDto, SourceCredentialKindDto,
SourceCredentialPageDto, SourceCredentialRotateDto, SourceCredentialStatusDto,
SourceCredentialVersionDto,
NullableDto, ResultMemberAccessDto, SourceCredentialCreateDto, SourceCredentialDto,
SourceCredentialKindDto, SourceCredentialPageDto, SourceCredentialRotateDto,
SourceCredentialStatusDto, SourceCredentialVersionDto,
};
use remora_credentials::{
CreateCredential, CredentialCaller, CredentialError, CredentialRegistry, RotateTxOutcome,
@ -384,7 +384,10 @@ async fn list_jobs(
Ok(HttpResponse::Ok().json(json!({
"items": items,
"next_cursor": next_cursor,
"event_cursor": event_cursor
"event_cursor": event_cursor,
"management_capabilities": {
"revoke_execution_grants": principal.administrator
}
})))
}
@ -493,14 +496,20 @@ async fn access_result_member(
.targets
.resolve(member.object().store(), member.object().bucket())
.ok_or_else(PublicApiError::unavailable)?;
let issued_at = OffsetDateTime::now_utc();
let access = target
.presign_read(&member.exact_identity(), state.member_access_ttl)
.await
.map_err(|_| PublicApiError::unavailable())?;
Ok(HttpResponse::TemporaryRedirect()
.insert_header((header::LOCATION, access.url))
let duration =
time::Duration::try_from(access.expires_in).map_err(|_| PublicApiError::internal())?;
Ok(HttpResponse::Ok()
.insert_header((header::CACHE_CONTROL, "no-store"))
.finish())
.insert_header((header::PRAGMA, "no-cache"))
.json(ResultMemberAccessDto {
url: access.url,
expires_at: datetime(issued_at + duration)?,
}))
}
async fn cancel_job(

View file

@ -290,6 +290,10 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
assert_eq!(listed.status(), StatusCode::OK);
let page: Value = listed.json().await.unwrap();
serde_json::from_value::<JobPageDto>(page.clone()).unwrap();
assert_eq!(
page["management_capabilities"]["revoke_execution_grants"],
true
);
let first_page_id = page["items"][0]["id"].as_str().unwrap();
assert!(page["event_cursor"].as_str().unwrap().len() >= 16);
let next_cursor = page["next_cursor"].as_str().unwrap();
@ -616,8 +620,13 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
.send()
.await
.unwrap();
assert_eq!(access.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(access.status(), StatusCode::OK);
assert_eq!(access.headers()["cache-control"], "no-store");
assert_eq!(access.headers()["pragma"], "no-cache");
let access: Value = access.json().await.unwrap();
let access_url = reqwest::Url::parse(access["url"].as_str().unwrap()).unwrap();
assert!(matches!(access_url.scheme(), "http" | "https"));
assert!(access["expires_at"].is_string());
let attempts = harness
.request(reqwest::Method::GET, &format!("/jobs/{job_id}/attempts"))
@ -743,6 +752,16 @@ async fn public_routes_are_authenticated_replayable_authorized_and_redacted() {
.await
.unwrap();
assert_eq!(lost_scope.status(), StatusCode::NOT_FOUND);
let unprivileged_page = harness
.request_with_token(reqwest::Method::GET, "/jobs", &unprivileged_token)
.send()
.await
.unwrap();
assert_eq!(unprivileged_page.status(), StatusCode::OK);
assert_eq!(
unprivileged_page.json::<Value>().await.unwrap()["management_capabilities"]["revoke_execution_grants"],
false
);
let hidden_submit_replay = harness
.request_with_token(reqwest::Method::POST, "/jobs", &unprivileged_token)
.header("Idempotency-Key", &submit_key)

View file

@ -42,8 +42,9 @@ pub use public::{
DeletionProblemDto, DeletionStateDto as HttpDeletionStateDto, DurableDescriptorKindDto,
DurableDescriptorRefDto, HttpSourceProjectionDto, HuggingFaceSourceProjectionDto, JobDto,
JobKindDto, JobPageDto, JobResourceDto, JobTombstoneDto, LeaseProjectionDto,
ObjectStoreSourceProjectionDto, ProgressValueDto, ResultMemberDto, ResultMemberPageDto,
SourceProjectionDto, StagingTargetDto, TerminalJobPhaseDto, TombstoneKindDto,
ManagementCapabilitiesDto, ObjectStoreSourceProjectionDto, ProgressValueDto,
ResultMemberAccessDto, ResultMemberDto, ResultMemberPageDto, SourceProjectionDto,
StagingTargetDto, TerminalJobPhaseDto, TombstoneKindDto,
};
pub use wire::{
CursorDto, DateTimeDto, NullableDto, PositiveIntegerDto, SafeIntegerDto, SchemaVersionDto,

View file

@ -132,6 +132,13 @@ pub struct ResultMemberPageDto {
pub next_cursor: NullableDto<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ResultMemberAccessDto {
pub url: String,
pub expires_at: DateTimeDto,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JobKindDto {
@ -195,12 +202,19 @@ pub enum JobResourceDto {
Tombstone(Box<JobTombstoneDto>),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ManagementCapabilitiesDto {
pub revoke_execution_grants: bool,
}
#[derive(Clone, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct JobPageDto {
pub items: Vec<JobResourceDto>,
pub next_cursor: NullableDto<String>,
pub event_cursor: CursorDto,
pub management_capabilities: ManagementCapabilitiesDto,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]