diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 80cca83b3..cc3582ecf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -72,6 +72,10 @@ jobs: - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: cache-on-failure: true + - name: Verify legacy auth identity removal + run: | + ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*"disabled"' \ + lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml - run: cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings generated-docs: diff --git a/docs/internal/events-strategy.md b/docs/internal/events-strategy.md index 2ec664223..136c76cce 100644 --- a/docs/internal/events-strategy.md +++ b/docs/internal/events-strategy.md @@ -41,6 +41,12 @@ Each serialized `RunEvent` uses this canonical envelope: "parent_session_id": "ses_parent", "node_id": "code", "node_label": "Code", + "actor": { + "kind": "agent", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "model": "gpt-5.2" + }, "properties": { "tool_name": "read_file", "tool_call_id": "call_1", @@ -66,6 +72,7 @@ Optional top-level fields: | `parent_session_id` | Forwarded child-session events | | `node_id` | Events tied to a graph node or branch | | `node_label` | Display label for `node_id`; omitted when not applicable | +| `actor` | The principal responsible for the event | Everything else lives inside `properties`. @@ -73,6 +80,8 @@ Important rules: - Optional envelope fields are omitted, not serialized as `null`. - Event-specific fields do not get flattened into the top level. +- Actor identity lives only in top-level `actor: Principal`; never duplicate it in event-specific properties. +- User actors must carry canonical IdP identity through `Principal::User { identity, login, auth_method }`, not a login-only string. - `EventPayload` validation requires `id`, `ts`, `run_id`, and `event`. ## Naming diff --git a/docs/internal/logging-strategy.md b/docs/internal/logging-strategy.md index ae886ab5b..ee64476c9 100644 --- a/docs/internal/logging-strategy.md +++ b/docs/internal/logging-strategy.md @@ -118,6 +118,11 @@ Fields are key-value pairs that make events queryable. Include enough context th | `error` | Error value on failure | | `path` | File system path | | `duration_ms` | Elapsed time in milliseconds | +| `principal_kind` | HTTP caller category (`user`, `worker`, `webhook`, `anonymous`, etc.) | +| `auth_status` | HTTP authentication result (`missing`, `invalid`, `expired`, `authenticated`) | +| `idp_issuer`, `idp_subject` | Canonical user identity for authenticated user requests | + +For HTTP request logs, use the request `Principal` projection rather than hand-assembled auth strings. User identity fields are present only for `Principal::User`; worker and webhook requests use their variant-specific fields (`run_id`, `delivery_id`). | `input_tokens` | Token count for LLM input | | `output_tokens` | Token count for LLM output | diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 37f975acf..86460282f 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -4724,31 +4724,145 @@ components: items: type: string - ActorKind: - description: High-level category of an event actor. + AuthMethod: + description: Runtime user authentication method. type: string enum: - - user - - agent - - system + - github + - dev_token - ActorRef: - description: > - Optional primary actor associated with a run event. Present on control - actions and durable agent output where a stable user or agent identity - matters; omitted on routine runtime lifecycle events. + SystemActorKind: + type: string + enum: + - engine + - watchdog + - timeout + + IdpIdentity: + type: object + required: + - issuer + - subject + properties: + issuer: + type: string + subject: + type: string + + Principal: + oneOf: + - $ref: "#/components/schemas/PrincipalUser" + - $ref: "#/components/schemas/PrincipalWorker" + - $ref: "#/components/schemas/PrincipalWebhook" + - $ref: "#/components/schemas/PrincipalSlack" + - $ref: "#/components/schemas/PrincipalAgent" + - $ref: "#/components/schemas/PrincipalSystem" + - $ref: "#/components/schemas/PrincipalAnonymous" + discriminator: + propertyName: kind + mapping: + user: "#/components/schemas/PrincipalUser" + worker: "#/components/schemas/PrincipalWorker" + webhook: "#/components/schemas/PrincipalWebhook" + slack: "#/components/schemas/PrincipalSlack" + agent: "#/components/schemas/PrincipalAgent" + system: "#/components/schemas/PrincipalSystem" + anonymous: "#/components/schemas/PrincipalAnonymous" + + PrincipalUser: + type: object + required: + - kind + - identity + - login + - auth_method + properties: + kind: + type: string + enum: [user] + identity: + $ref: "#/components/schemas/IdpIdentity" + login: + type: string + auth_method: + $ref: "#/components/schemas/AuthMethod" + + PrincipalWorker: + type: object + required: + - kind + - run_id + properties: + kind: + type: string + enum: [worker] + run_id: + type: string + + PrincipalWebhook: + type: object + required: + - kind + - delivery_id + properties: + kind: + type: string + enum: [webhook] + delivery_id: + type: string + + PrincipalSlack: + type: object + required: + - kind + - team_id + - user_id + properties: + kind: + type: string + enum: [slack] + team_id: + type: string + user_id: + type: string + user_name: + type: ["string", "null"] + + PrincipalAgent: type: object required: - kind properties: kind: - $ref: "#/components/schemas/ActorKind" - id: type: string - description: Stable actor identifier when available. - display: + enum: [agent] + session_id: + type: ["string", "null"] + parent_session_id: + type: ["string", "null"] + model: + type: ["string", "null"] + + PrincipalSystem: + type: object + required: + - kind + - system_kind + properties: + kind: type: string - description: Display-friendly label for the actor. + enum: [system] + system_kind: + $ref: "#/components/schemas/SystemActorKind" + + PrincipalAnonymous: + type: object + required: + - kind + properties: + kind: + type: string + enum: [anonymous] RunEvent: description: > @@ -4797,7 +4911,7 @@ components: call. actor: oneOf: - - $ref: "#/components/schemas/ActorRef" + - $ref: "#/components/schemas/Principal" - type: "null" event: type: string diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index f95e77e20..58dbf0379 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -309,8 +309,11 @@ fn main() { "fabro_types::settings::server::WebhookStrategy", &[], ), - ("ActorKind", "fabro_types::ActorKind", &[]), - ("ActorRef", "fabro_types::ActorRef", &[]), + ("AuthMethod", "fabro_types::AuthMethod", &[]), + ("IdpIdentity", "fabro_types::IdpIdentity", &[]), + ("Principal", "fabro_types::Principal", &[]), + ("PrincipalUser", "fabro_types::UserPrincipal", &[]), + ("SystemActorKind", "fabro_types::SystemActorKind", &[]), ("QuestionType", "fabro_types::QuestionType", &[]), ("StageCompletion", "fabro_types::StageCompletion", &[]), ("StageOutcome", "fabro_types::StageOutcome", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 626e8cfb5..89799b68b 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -29,11 +29,12 @@ pub mod types { BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus, }; pub use fabro_types::{ - ActorKind, ActorRef, BilledTokenCounts, CommandOutputStream, CommandTermination, DiffStats, - DirtyStatus, EventEnvelope, GitContext, InterviewOption, InterviewQuestionRecord, - PendingInterviewRecord, PreRunPushOutcome, QuestionType, RepositoryReference, RunEvent, - RunProjection, RunSummary, SecretMetadata, SecretType, ServerSettings, StageCompletion, - StageOutcome, StageProjection, StageState, WorkflowSettings, + AuthMethod, BilledTokenCounts, CommandOutputStream, CommandTermination, DiffStats, + DirtyStatus, EventEnvelope, GitContext, IdpIdentity, InterviewOption, + InterviewQuestionRecord, PendingInterviewRecord, PreRunPushOutcome, Principal, + QuestionType, RepositoryReference, RunEvent, RunProjection, RunSummary, SecretMetadata, + SecretType, ServerSettings, StageCompletion, StageOutcome, StageProjection, StageState, + SystemActorKind, UserPrincipal, WorkflowSettings, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/actor_kind_round_trip.rs b/lib/crates/fabro-api/tests/actor_kind_round_trip.rs deleted file mode 100644 index 9a500ae2a..000000000 --- a/lib/crates/fabro-api/tests/actor_kind_round_trip.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::any::{TypeId, type_name}; - -use fabro_api::types::ActorKind as ApiActorKind; -use fabro_types::ActorKind; -use serde_json::json; - -#[test] -fn actor_kind_reuses_canonical_type() { - assert_same_type::(); -} - -#[test] -fn actor_kind_serializes_as_snake_case_strings() { - assert_eq!( - serde_json::to_value(ActorKind::User).unwrap(), - json!("user") - ); - assert_eq!( - serde_json::to_value(ActorKind::Agent).unwrap(), - json!("agent") - ); - assert_eq!( - serde_json::to_value(ActorKind::System).unwrap(), - json!("system") - ); -} - -#[test] -fn actor_kind_deserializes_each_variant() { - assert_eq!( - serde_json::from_value::(json!("user")).unwrap(), - ActorKind::User - ); - assert_eq!( - serde_json::from_value::(json!("agent")).unwrap(), - ActorKind::Agent - ); - assert_eq!( - serde_json::from_value::(json!("system")).unwrap(), - ActorKind::System - ); -} - -fn assert_same_type() { - assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() - ); -} diff --git a/lib/crates/fabro-api/tests/actor_ref_round_trip.rs b/lib/crates/fabro-api/tests/actor_ref_round_trip.rs deleted file mode 100644 index ff381bac5..000000000 --- a/lib/crates/fabro-api/tests/actor_ref_round_trip.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::any::{TypeId, type_name}; - -use fabro_api::types::ActorRef as ApiActorRef; -use fabro_types::{ActorKind, ActorRef}; -use serde_json::json; - -#[test] -fn actor_ref_reuses_canonical_type() { - assert_same_type::(); -} - -#[test] -fn actor_ref_round_trips_representative_json() { - let value = json!({ - "kind": "agent", - "id": "agent-1", - "display": "Agent 1" - }); - - let actor: ActorRef = serde_json::from_value(value.clone()).unwrap(); - assert_eq!(actor, ActorRef { - kind: ActorKind::Agent, - id: Some("agent-1".to_string()), - display: Some("Agent 1".to_string()), - }); - assert_eq!(serde_json::to_value(actor).unwrap(), value); -} - -#[test] -fn actor_ref_omits_absent_optional_fields() { - let actor = ActorRef { - kind: ActorKind::System, - id: None, - display: None, - }; - - assert_eq!( - serde_json::to_value(actor).unwrap(), - json!({"kind": "system"}) - ); -} - -fn assert_same_type() { - assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() - ); -} diff --git a/lib/crates/fabro-api/tests/principal_round_trip.rs b/lib/crates/fabro-api/tests/principal_round_trip.rs new file mode 100644 index 000000000..0d9831b61 --- /dev/null +++ b/lib/crates/fabro-api/tests/principal_round_trip.rs @@ -0,0 +1,57 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::Principal as ApiPrincipal; +use fabro_types::{AuthMethod, IdpIdentity, Principal, SystemActorKind}; +use serde_json::json; + +#[test] +fn principal_reuses_canonical_type() { + assert_same_type::(); +} + +#[test] +fn principal_round_trips_representative_json() { + let value = json!({ + "kind": "user", + "identity": { + "issuer": "https://github.com", + "subject": "12345" + }, + "login": "octocat", + "auth_method": "github" + }); + + let principal: Principal = serde_json::from_value(value.clone()).unwrap(); + assert_eq!( + principal, + Principal::user( + IdpIdentity::new("https://github.com", "12345").unwrap(), + "octocat".to_string(), + AuthMethod::Github, + ) + ); + assert_eq!(serde_json::to_value(principal).unwrap(), value); +} + +#[test] +fn principal_system_uses_system_kind_field() { + let principal = Principal::system(SystemActorKind::Watchdog); + + assert_eq!( + serde_json::to_value(principal).unwrap(), + json!({ + "kind": "system", + "system_kind": "watchdog" + }) + ); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-api/tests/run_event_round_trip.rs b/lib/crates/fabro-api/tests/run_event_round_trip.rs index 3232e4227..86f7aa7eb 100644 --- a/lib/crates/fabro-api/tests/run_event_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_event_round_trip.rs @@ -66,8 +66,9 @@ fn run_event_round_trips_agent_tool_started() { "tool_call_id": "call_1", "actor": { "kind": "agent", - "id": "ses_child", - "display": "claude-sonnet" + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "model": "claude-sonnet" }, "properties": { "tool_name": "Bash", diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 58e377bf6..e9dda77fa 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -245,9 +245,10 @@ async fn handle_pending_server_interview( } hide_progress(progress_ui, json_output); - let interviewer = ConsoleInterviewer::new(styles); - let answer = + let interviewer = ConsoleInterviewer::new(styles, fabro_types::Principal::anonymous()); + let submission = fabro_interview::Interviewer::ask(&interviewer, api_question_to_question(&question)).await; + let answer = submission.answer; show_progress(progress_ui, json_output); if answer_requires_reattach(&answer) { diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index 151774d39..947951247 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -14,12 +14,14 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use fabro_config::{ServerSettingsBuilder, Storage}; -use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage}; +use fabro_interview::{ + AnswerSubmission, ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage, +}; use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer}; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunMode; use fabro_types::{ - ActorRef, ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId, + ArtifactUpload, EventBody, FailureReason, Principal, RunBlobId, RunEvent, RunId, WorkflowSettings, }; use fabro_vault::Vault; @@ -235,8 +237,10 @@ async fn apply_worker_control_line( }; match message.message { - WorkerControlMessage::InterviewAnswer { qid, answer } => { - let _ = interviewer.submit(&qid, answer.into()).await; + WorkerControlMessage::InterviewAnswer { qid, answer, actor } => { + let _ = interviewer + .submit(&qid, AnswerSubmission::new(answer.into(), actor)) + .await; } WorkerControlMessage::RunCancel => { cancel_token.store(true, Ordering::SeqCst); @@ -403,14 +407,13 @@ impl RunStoreBackend for HttpRunStore { } async fn append_run_event(&self, event: &RunEvent) -> Result<()> { - let seq = self - .with_retries("append run event", || { - let client = self.client.clone_for_reuse(); - let run_id = self.run_id; - let event = event.clone(); - async move { client.append_run_event(&run_id, &event).await } - }) - .await?; + let seq = Box::pin(self.with_retries("append run event", || { + let client = self.client.clone_for_reuse(); + let run_id = self.run_id; + let event = event.clone(); + async move { client.append_run_event(&run_id, &event).await } + })) + .await?; self.apply_acknowledged_event(seq, event).await } @@ -498,7 +501,7 @@ fn update_worker_title_from_event(event: &RunEvent) { fn stamp_system_worker(mut event: RunEvent) -> RunEvent { if event.actor.is_none() { - event.actor = Some(ActorRef::system_worker()); + event.actor = Some(Principal::worker(event.run_id)); } event } @@ -609,7 +612,10 @@ mod tests { InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps, RunFailedProps, RunStatusTransitionProps, }; - use fabro_types::{ActorRef, EventBody, FailureReason, QuestionType, SuccessReason, fixtures}; + use fabro_types::{ + AuthMethod, EventBody, FailureReason, IdpIdentity, Principal, QuestionType, SuccessReason, + fixtures, + }; use fabro_vault::{SecretType, Vault}; use fabro_workflow::event::RunEventSink; @@ -628,7 +634,15 @@ mod tests { assert!(!super::clone_sandbox_requires_github_credentials("local")); } - fn running_event(actor: Option) -> fabro_types::RunEvent { + fn test_user_principal(login: &str) -> Principal { + Principal::user( + IdpIdentity::new("https://github.com", "12345").unwrap(), + login.to_string(), + AuthMethod::Github, + ) + } + + fn running_event(actor: Option) -> fabro_types::RunEvent { fabro_types::RunEvent { id: "evt_1".to_string(), ts: Utc::now(), @@ -744,9 +758,9 @@ mod tests { fn stamp_system_worker_fills_missing_actor_only() { let stamped = stamp_system_worker(running_event(None)); - assert_eq!(stamped.actor, Some(ActorRef::system_worker())); + assert_eq!(stamped.actor, Some(Principal::worker(fixtures::RUN_1))); - let existing_actor = ActorRef::user("octocat".to_string()); + let existing_actor = test_user_principal("octocat"); let stamped = stamp_system_worker(running_event(Some(existing_actor.clone()))); assert_eq!(stamped.actor, Some(existing_actor)); } @@ -782,8 +796,8 @@ mod tests { let first = first.lock().await; let second = second.lock().await; - assert_eq!(first[0].actor, Some(ActorRef::system_worker())); - assert_eq!(second[0].actor, Some(ActorRef::system_worker())); + assert_eq!(first[0].actor, Some(Principal::worker(fixtures::RUN_1))); + assert_eq!(second[0].actor, Some(Principal::worker(fixtures::RUN_1))); } #[tokio::test] @@ -798,11 +812,11 @@ mod tests { apply_worker_control_line( &interviewer, &cancel_token, - r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"}}"#, + r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"},"actor":{"kind":"system","system_kind":"engine"}}"#, ) .await; - let answer: fabro_interview::Answer = answer_task.await.unwrap(); + let answer = answer_task.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Yes); assert!(!cancel_token.load(Ordering::SeqCst)); } @@ -824,7 +838,7 @@ mod tests { ) .await; - let answer: fabro_interview::Answer = answer_task.await.unwrap(); + let answer = answer_task.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Interrupted); assert!(cancel_token.load(Ordering::SeqCst)); } @@ -835,7 +849,7 @@ mod tests { read_worker_control_stream_blocking( std::io::Cursor::new( - b"{\"v\":1,\"type\":\"run.cancel\"}\n{\"v\":1,\"type\":\"interview.answer\",\"qid\":\"q-1\",\"answer\":{\"kind\":\"yes\"}}\n", + b"{\"v\":1,\"type\":\"run.cancel\"}\n{\"v\":1,\"type\":\"interview.answer\",\"qid\":\"q-1\",\"answer\":{\"kind\":\"yes\"},\"actor\":{\"kind\":\"system\",\"system_kind\":\"engine\"}}\n", ), &event_tx, ); @@ -849,7 +863,7 @@ mod tests { assert_eq!( event_rx.try_recv(), Ok(WorkerControlStreamEvent::Line( - r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"}}"# + r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"yes"},"actor":{"kind":"system","system_kind":"engine"}}"# .to_string() )) ); @@ -876,7 +890,7 @@ mod tests { ) .await; - let answer: fabro_interview::Answer = answer_task.await.unwrap(); + let answer = answer_task.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Interrupted); assert!(!cancel_token.load(Ordering::SeqCst)); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 5a0ad3db2..71028c2f5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -446,9 +446,13 @@ fn attach_json_errors_without_prompting_for_human_input() { [ { "actor": { - "display": "dev", - "id": "dev", - "kind": "user" + "auth_method": "dev_token", + "identity": { + "issuer": "fabro:dev", + "subject": "dev" + }, + "kind": "user", + "login": "dev" }, "event": "run.created", "id": "[EVENT_ID]", @@ -566,6 +570,11 @@ fn attach_json_errors_without_prompting_for_human_input() { }, "subject": { "auth_method": "dev_token", + "identity": { + "issuer": "fabro:dev", + "subject": "dev" + }, + "kind": "user", "login": "dev" } }, @@ -678,9 +687,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "run.starting", "id": "[EVENT_ID]", @@ -690,9 +698,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "sandbox.initializing", "id": "[EVENT_ID]", @@ -704,9 +711,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "sandbox.ready", "id": "[EVENT_ID]", @@ -719,9 +725,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "sandbox.initialized", "id": "[EVENT_ID]", @@ -734,9 +739,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "run.started", "id": "[EVENT_ID]", @@ -749,9 +753,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "run.running", "id": "[EVENT_ID]", @@ -761,9 +764,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "stage.started", "id": "[EVENT_ID]", @@ -781,9 +783,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "stage.completed", "id": "[EVENT_ID]", @@ -814,9 +815,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "edge.selected", "id": "[EVENT_ID]", @@ -832,9 +832,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "checkpoint.completed", "id": "[EVENT_ID]", @@ -875,9 +874,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "stage.started", "id": "[EVENT_ID]", @@ -895,9 +893,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "interview.started", "id": "[EVENT_ID]", @@ -926,9 +923,8 @@ fn attach_json_errors_without_prompting_for_human_input() { }, { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "run.blocked", "id": "[EVENT_ID]", diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/crates/fabro-cli/tests/it/cmd/logs.rs index 1b0268a0b..87d740bab 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/logs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/logs.rs @@ -131,8 +131,8 @@ fn logs_completed_run_reads_store_without_progress_jsonl() { success: true exit_code: 0 ----- stdout ----- - {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} ----- stderr ----- "#); } @@ -165,8 +165,8 @@ fn logs_tail_limits_output() { success: true exit_code: 0 ----- stdout ----- - {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"kind":"worker","run_id":"[ULID]"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} ----- stderr ----- "#); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 0b4df300e..502f398b7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -844,9 +844,8 @@ fn dry_run_persists_event_history_in_store() { fabro_json_snapshot!(context, &live_content, @r#" { "actor": { - "display": "system:worker", - "id": "worker", - "kind": "system" + "kind": "worker", + "run_id": "[ULID]" }, "event": "sandbox.cleanup.completed", "id": "[EVENT_ID]", diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 09a187518..f27159af5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -1468,9 +1468,8 @@ async fn append_run_event( "event": event_name, "properties": properties, "actor": { - "kind": "system", - "id": "worker", - "display": "system:worker", + "kind": "worker", + "run_id": run_id, }, }); if let Some(node_id) = node_id { diff --git a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs index 956684d69..b325cbade 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs @@ -369,12 +369,11 @@ async fn github_only_server_dispatched_worker_succeeds_without_worker_auth_store let events = wait_for_completed_events(&server.api_base_url, &run_id, &access_token).await; assert!(events.iter().any(|event| { - event - .event - .actor - .as_ref() - .and_then(|actor| actor.display.as_deref()) - == Some("system:worker") + matches!( + event.event.actor.as_ref(), + Some(fabro_api::types::Principal::Worker { run_id: actor_run_id }) + if actor_run_id.to_string() == run_id + ) })); assert!(!server.worker_home.join("auth.json").exists()); assert!(!server.worker_home.join("auth.lock").exists()); diff --git a/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs index 6638c3c20..e4c39e1b2 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs +++ b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs @@ -1,5 +1,5 @@ use chrono::{Duration as ChronoDuration, Utc}; -use fabro_types::RunAuthMethod; +use fabro_types::AuthMethod; use hkdf::Hkdf; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use sha2::Sha256; @@ -50,7 +50,7 @@ struct TestJwtClaims { email: String, avatar_url: String, user_url: String, - auth_method: RunAuthMethod, + auth_method: AuthMethod, } pub(crate) fn issue_test_github_jwt(issuer: &str) -> String { @@ -103,7 +103,7 @@ fn issue_github_jwt( email: subject.email, avatar_url: subject.avatar_url, user_url: subject.user_url, - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, }; jsonwebtoken::encode( &Header::new(Algorithm::HS256), diff --git a/lib/crates/fabro-interview/src/auto_approve.rs b/lib/crates/fabro-interview/src/auto_approve.rs index 8c8584e70..93290241c 100644 --- a/lib/crates/fabro-interview/src/auto_approve.rs +++ b/lib/crates/fabro-interview/src/auto_approve.rs @@ -1,16 +1,30 @@ use async_trait::async_trait; -use fabro_types::QuestionType; +use fabro_types::{Principal, QuestionType, SystemActorKind}; -use crate::{Answer, AnswerValue, Interviewer, Question}; +use crate::{Answer, AnswerSubmission, AnswerValue, Interviewer, Question}; /// Always approves: YES for yes/no, first option for multiple choice, /// "auto-approved" for freeform. -pub struct AutoApproveInterviewer; +pub struct AutoApproveInterviewer { + actor: Principal, +} + +impl AutoApproveInterviewer { + #[must_use] + pub fn new(actor: Principal) -> Self { + Self { actor } + } + + #[must_use] + pub fn engine() -> Self { + Self::new(Principal::system(SystemActorKind::Engine)) + } +} #[async_trait] impl Interviewer for AutoApproveInterviewer { - async fn ask(&self, question: Question) -> Answer { - match question.question_type { + async fn ask(&self, question: Question) -> AnswerSubmission { + let answer = match question.question_type { QuestionType::YesNo | QuestionType::Confirmation => Answer::yes(), QuestionType::MultipleChoice | QuestionType::MultiSelect => { question.options.first().map_or_else( @@ -23,7 +37,8 @@ impl Interviewer for AutoApproveInterviewer { ) } QuestionType::Freeform => Answer::text("auto-approved"), - } + }; + AnswerSubmission::new(answer, self.actor.clone()) } } @@ -35,23 +50,23 @@ mod tests { #[tokio::test] async fn yes_no_returns_yes() { - let interviewer = AutoApproveInterviewer; + let interviewer = AutoApproveInterviewer::engine(); let q = Question::new("Approve?", QuestionType::YesNo); - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Yes); } #[tokio::test] async fn confirmation_returns_yes() { - let interviewer = AutoApproveInterviewer; + let interviewer = AutoApproveInterviewer::engine(); let q = Question::new("Confirm?", QuestionType::Confirmation); - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Yes); } #[tokio::test] async fn multiple_choice_returns_first_option() { - let interviewer = AutoApproveInterviewer; + let interviewer = AutoApproveInterviewer::engine(); let mut q = Question::new("Choose:", QuestionType::MultipleChoice); q.options = vec![ InterviewOption { @@ -63,7 +78,7 @@ mod tests { label: "Beta".to_string(), }, ]; - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Selected("A".to_string())); assert_eq!( answer.selected_option, @@ -76,17 +91,17 @@ mod tests { #[tokio::test] async fn multiple_choice_no_options_returns_auto_approved() { - let interviewer = AutoApproveInterviewer; + let interviewer = AutoApproveInterviewer::engine(); let q = Question::new("Choose:", QuestionType::MultipleChoice); - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Text("auto-approved".to_string())); } #[tokio::test] async fn freeform_returns_auto_approved() { - let interviewer = AutoApproveInterviewer; + let interviewer = AutoApproveInterviewer::engine(); let q = Question::new("Enter text:", QuestionType::Freeform); - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Text("auto-approved".to_string())); assert_eq!(answer.text, Some("auto-approved".to_string())); } diff --git a/lib/crates/fabro-interview/src/callback.rs b/lib/crates/fabro-interview/src/callback.rs index 990911318..b3d30af1d 100644 --- a/lib/crates/fabro-interview/src/callback.rs +++ b/lib/crates/fabro-interview/src/callback.rs @@ -1,24 +1,34 @@ use async_trait::async_trait; +use fabro_types::{Principal, SystemActorKind}; -use crate::{Answer, Interviewer, Question}; +use crate::{Answer, AnswerSubmission, Interviewer, Question}; /// Delegates question answering to a provided callback function. pub struct CallbackInterviewer { callback: Box Answer + Send + Sync>, + actor: Principal, } impl CallbackInterviewer { pub fn new(callback: impl Fn(Question) -> Answer + Send + Sync + 'static) -> Self { + Self::with_actor(Principal::system(SystemActorKind::Engine), callback) + } + + pub fn with_actor( + actor: Principal, + callback: impl Fn(Question) -> Answer + Send + Sync + 'static, + ) -> Self { Self { callback: Box::new(callback), + actor, } } } #[async_trait] impl Interviewer for CallbackInterviewer { - async fn ask(&self, question: Question) -> Answer { - (self.callback)(question) + async fn ask(&self, question: Question) -> AnswerSubmission { + AnswerSubmission::new((self.callback)(question), self.actor.clone()) } } @@ -40,11 +50,11 @@ mod tests { }); let yes_q = Question::new("approve?", QuestionType::YesNo); - let answer = interviewer.ask(yes_q).await; + let answer = interviewer.ask(yes_q).await.answer; assert_eq!(answer.value, AnswerValue::Yes); let no_q = Question::new("choose:", QuestionType::MultipleChoice); - let answer = interviewer.ask(no_q).await; + let answer = interviewer.ask(no_q).await.answer; assert_eq!(answer.value, AnswerValue::No); } @@ -52,7 +62,7 @@ mod tests { async fn callback_receives_question_text() { let interviewer = CallbackInterviewer::new(|q| Answer::text(q.text)); let q = Question::new("hello world", QuestionType::Freeform); - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.text, Some("hello world".to_string())); } } diff --git a/lib/crates/fabro-interview/src/console.rs b/lib/crates/fabro-interview/src/console.rs index e30b72d88..437025041 100644 --- a/lib/crates/fabro-interview/src/console.rs +++ b/lib/crates/fabro-interview/src/console.rs @@ -3,12 +3,12 @@ use std::io::IsTerminal; use async_trait::async_trait; use dialoguer::console::Term; use dialoguer::theme::ColorfulTheme; -use fabro_types::{InterviewOption, QuestionType}; +use fabro_types::{InterviewOption, Principal, QuestionType}; use fabro_util::terminal::Styles; use tokio::io::{self, AsyncBufReadExt, BufReader}; use tokio::task; -use crate::{Answer, AnswerValue, Interviewer, Question}; +use crate::{Answer, AnswerSubmission, AnswerValue, Interviewer, Question}; enum PromptRead { Line(String), @@ -20,12 +20,13 @@ enum PromptRead { /// 6.4. pub struct ConsoleInterviewer { styles: &'static Styles, + actor: Principal, } impl ConsoleInterviewer { #[must_use] - pub fn new(styles: &'static Styles) -> Self { - Self { styles } + pub fn new(styles: &'static Styles, actor: Principal) -> Self { + Self { styles, actor } } } @@ -222,7 +223,7 @@ impl Interviewer for ConsoleInterviewer { clippy::print_stderr, reason = "Interactive questions and options belong on stderr, not captured stdout." )] - async fn ask(&self, question: Question) -> Answer { + async fn ask(&self, question: Question) -> AnswerSubmission { // If stdin is a TTY, use dialoguer for interactive arrow-key navigation. // Otherwise, fall back to the line-based reader for piped input. #[expect( @@ -237,7 +238,7 @@ impl Interviewer for ConsoleInterviewer { eprint!("{rendered}"); } let q = question; - return task::spawn_blocking(move || match q.question_type { + let answer = task::spawn_blocking(move || match q.question_type { QuestionType::MultipleChoice => ask_select_interactive(&q), QuestionType::MultiSelect => ask_multi_select_interactive(&q), QuestionType::YesNo | QuestionType::Confirmation => ask_confirm_interactive(&q), @@ -245,13 +246,14 @@ impl Interviewer for ConsoleInterviewer { }) .await .unwrap_or_else(|_| Answer::interrupted()); + return AnswerSubmission::new(answer, self.actor.clone()); } // Non-TTY fallback: line-based stdin reading let s = self.styles; eprintln!("{} {}", s.bold_cyan.apply_to("?"), question.text); - match question.question_type { + let answer = match question.question_type { QuestionType::MultipleChoice | QuestionType::MultiSelect => { for (i, opt) in question.options.iter().enumerate() { eprintln!( @@ -272,7 +274,8 @@ impl Interviewer for ConsoleInterviewer { parse_non_tty_confirm_response(read_line("[Y/N]: ").await) } QuestionType::Freeform => parse_non_tty_freeform_response(read_line("> ").await), - } + }; + AnswerSubmission::new(answer, self.actor.clone()) } #[allow( diff --git a/lib/crates/fabro-interview/src/control.rs b/lib/crates/fabro-interview/src/control.rs index 46d307e07..7ce5120ee 100644 --- a/lib/crates/fabro-interview/src/control.rs +++ b/lib/crates/fabro-interview/src/control.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use async_trait::async_trait; use tokio::sync::{Mutex, oneshot}; -use crate::{Answer, Interviewer, Question}; +use crate::{Answer, AnswerSubmission, Interviewer, Question}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SubmitError { @@ -12,9 +12,9 @@ pub enum SubmitError { #[derive(Default)] struct ControlInterviewerState { - pending: HashMap>, - queued: HashMap, - terminal_answer: Option, + pending: HashMap>, + queued: HashMap, + terminal_submission: Option, } #[derive(Default)] @@ -28,16 +28,16 @@ impl ControlInterviewer { Self::default() } - async fn register(&self, question_id: String) -> oneshot::Receiver { + async fn register(&self, question_id: String) -> oneshot::Receiver { let mut state = self.state.lock().await; - if let Some(answer) = state.terminal_answer.clone() { + if let Some(submission) = state.terminal_submission.clone() { let (tx, rx) = oneshot::channel(); - let _ = tx.send(answer); + let _ = tx.send(submission); return rx; } - if let Some(answer) = state.queued.remove(&question_id) { + if let Some(submission) = state.queued.remove(&question_id) { let (tx, rx) = oneshot::channel(); - let _ = tx.send(answer); + let _ = tx.send(submission); return rx; } @@ -46,10 +46,14 @@ impl ControlInterviewer { rx } - pub async fn submit(&self, question_id: &str, answer: Answer) -> Result<(), SubmitError> { + pub async fn submit( + &self, + question_id: &str, + submission: AnswerSubmission, + ) -> Result<(), SubmitError> { let pending_sender = { let mut state = self.state.lock().await; - if state.terminal_answer.is_some() { + if state.terminal_submission.is_some() { return Err(SubmitError::AlreadyResolved); } if let Some(sender) = state.pending.remove(question_id) { @@ -57,31 +61,39 @@ impl ControlInterviewer { } else if state.queued.contains_key(question_id) { return Err(SubmitError::AlreadyResolved); } else { - state.queued.insert(question_id.to_string(), answer); + state.queued.insert(question_id.to_string(), submission); return Ok(()); } }; match pending_sender { Some(sender) => sender - .send(answer) + .send(submission) .map_err(|_| SubmitError::AlreadyResolved), None => Err(SubmitError::AlreadyResolved), } } pub async fn interrupt_all(&self) { - self.resolve_all(Answer::interrupted()).await; + self.resolve_all(AnswerSubmission::system( + Answer::interrupted(), + fabro_types::SystemActorKind::Engine, + )) + .await; } pub async fn cancel_all(&self) { - self.resolve_all(Answer::cancelled()).await; + self.resolve_all(AnswerSubmission::system( + Answer::cancelled(), + fabro_types::SystemActorKind::Engine, + )) + .await; } - async fn resolve_all(&self, answer: Answer) { + async fn resolve_all(&self, submission: AnswerSubmission) { let (pending, queued) = { let mut state = self.state.lock().await; - state.terminal_answer = Some(answer.clone()); + state.terminal_submission = Some(submission.clone()); let pending = state .pending .drain() @@ -93,7 +105,7 @@ impl ControlInterviewer { }; for sender in pending { - let _ = sender.send(answer.clone()); + let _ = sender.send(submission.clone()); } if queued > 0 { @@ -107,11 +119,14 @@ impl ControlInterviewer { #[async_trait] impl Interviewer for ControlInterviewer { - async fn ask(&self, question: Question) -> Answer { + async fn ask(&self, question: Question) -> AnswerSubmission { let receiver = self.register(question.id.clone()).await; match receiver.await { - Ok(answer) => answer, - Err(_) => Answer::interrupted(), + Ok(submission) => submission, + Err(_) => AnswerSubmission::system( + Answer::interrupted(), + fabro_types::SystemActorKind::Engine, + ), } } @@ -130,10 +145,14 @@ mod tests { use super::*; use crate::AnswerValue; + fn submission(answer: Answer) -> AnswerSubmission { + AnswerSubmission::system(answer, fabro_types::SystemActorKind::Engine) + } + #[tokio::test] async fn submit_before_ask_buffers_answer() { let interviewer = ControlInterviewer::new(); - let result = interviewer.submit("q-1", Answer::yes()).await; + let result = interviewer.submit("q-1", submission(Answer::yes())).await; assert_eq!(result, Ok(())); } @@ -146,30 +165,36 @@ mod tests { let ask_interviewer = Arc::clone(&interviewer); let ask = tokio::spawn(async move { ask_interviewer.ask(question).await }); - let submit_result = interviewer.submit("q-1", Answer::yes()).await; + let submit_result = interviewer.submit("q-1", submission(Answer::yes())).await; assert_eq!(submit_result, Ok(())); - let answer = ask.await.unwrap(); + let answer = ask.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Yes); } #[tokio::test] async fn submit_before_register_buffers_answer() { let interviewer = Arc::new(ControlInterviewer::new()); - assert_eq!(interviewer.submit("q-1", Answer::no()).await, Ok(())); + assert_eq!( + interviewer.submit("q-1", submission(Answer::no())).await, + Ok(()) + ); let mut question = Question::new("approve?", QuestionType::YesNo); question.id = "q-1".to_string(); - let answer = interviewer.ask(question).await; + let answer = interviewer.ask(question).await.answer; assert_eq!(answer.value, AnswerValue::No); } #[tokio::test] async fn duplicate_buffered_answer_is_rejected() { let interviewer = ControlInterviewer::new(); - assert_eq!(interviewer.submit("q-1", Answer::yes()).await, Ok(())); assert_eq!( - interviewer.submit("q-1", Answer::no()).await, + interviewer.submit("q-1", submission(Answer::yes())).await, + Ok(()) + ); + assert_eq!( + interviewer.submit("q-1", submission(Answer::no())).await, Err(SubmitError::AlreadyResolved) ); } @@ -186,7 +211,7 @@ mod tests { interviewer.interrupt_all().await; - let answer = ask.await.unwrap(); + let answer = ask.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Interrupted); } @@ -198,7 +223,7 @@ mod tests { let mut question = Question::new("approve?", QuestionType::YesNo); question.id = "q-1".to_string(); - let answer = interviewer.ask(question).await; + let answer = interviewer.ask(question).await.answer; assert_eq!(answer.value, AnswerValue::Interrupted); } @@ -214,7 +239,7 @@ mod tests { interviewer.cancel_all().await; - let answer = ask.await.unwrap(); + let answer = ask.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Cancelled); } @@ -226,7 +251,7 @@ mod tests { let mut question = Question::new("approve?", QuestionType::YesNo); question.id = "q-1".to_string(); - let answer = interviewer.ask(question).await; + let answer = interviewer.ask(question).await.answer; assert_eq!(answer.value, AnswerValue::Cancelled); } } diff --git a/lib/crates/fabro-interview/src/control_protocol.rs b/lib/crates/fabro-interview/src/control_protocol.rs index 56284cb70..e28dc4ecb 100644 --- a/lib/crates/fabro-interview/src/control_protocol.rs +++ b/lib/crates/fabro-interview/src/control_protocol.rs @@ -1,6 +1,7 @@ +use fabro_types::Principal; use serde::{Deserialize, Serialize}; -use crate::{Answer, AnswerValue}; +use crate::{Answer, AnswerSubmission, AnswerValue}; pub const WORKER_CONTROL_PROTOCOL_VERSION: u8 = 1; @@ -13,12 +14,13 @@ pub struct WorkerControlEnvelope { impl WorkerControlEnvelope { #[must_use] - pub fn interview_answer(qid: impl Into, answer: Answer) -> Self { + pub fn interview_answer(qid: impl Into, submission: AnswerSubmission) -> Self { Self { v: WORKER_CONTROL_PROTOCOL_VERSION, message: WorkerControlMessage::InterviewAnswer { qid: qid.into(), - answer: answer.into(), + answer: submission.answer.into(), + actor: submission.actor, }, } } @@ -39,6 +41,7 @@ pub enum WorkerControlMessage { InterviewAnswer { qid: String, answer: WorkerControlAnswer, + actor: Principal, }, #[serde(rename = "run.cancel")] RunCancel, @@ -100,11 +103,17 @@ mod tests { #[test] fn interview_answer_round_trips_through_json() { - let envelope = WorkerControlEnvelope::interview_answer("q-1", Answer::text("ship it")); + let envelope = WorkerControlEnvelope::interview_answer( + "q-1", + AnswerSubmission::system( + Answer::text("ship it"), + fabro_types::SystemActorKind::Engine, + ), + ); let json = serde_json::to_string(&envelope).unwrap(); assert_eq!( json, - r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"text","text":"ship it"}}"# + r#"{"v":1,"type":"interview.answer","qid":"q-1","answer":{"kind":"text","text":"ship it"},"actor":{"kind":"system","system_kind":"engine"}}"# ); let parsed: WorkerControlEnvelope = serde_json::from_str(&json).unwrap(); diff --git a/lib/crates/fabro-interview/src/lib.rs b/lib/crates/fabro-interview/src/lib.rs index b61fbaabc..5aae86815 100644 --- a/lib/crates/fabro-interview/src/lib.rs +++ b/lib/crates/fabro-interview/src/lib.rs @@ -10,7 +10,7 @@ mod replay; use std::collections::HashMap; use async_trait::async_trait; -use fabro_types::{InterviewOption, QuestionType}; +use fabro_types::{InterviewOption, Principal, QuestionType, SystemActorKind}; use serde::{Deserialize, Serialize}; use tokio::time; @@ -152,10 +152,35 @@ impl Answer { } } +/// An answer plus the principal that supplied it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnswerSubmission { + pub answer: Answer, + pub actor: Principal, +} + +impl AnswerSubmission { + #[must_use] + pub fn new(answer: Answer, actor: Principal) -> Self { + Self { answer, actor } + } + + #[must_use] + pub fn system(answer: Answer, system_kind: SystemActorKind) -> Self { + Self { + answer, + actor: Principal::system(system_kind), + } + } +} + /// Apply timeout enforcement to an interviewer ask call. /// Per spec 6.5: if `timeout_seconds` is set, returns default answer or /// `Answer::timeout()`. -pub async fn ask_with_timeout(interviewer: &dyn Interviewer, question: Question) -> Answer { +pub async fn ask_with_timeout( + interviewer: &dyn Interviewer, + question: Question, +) -> AnswerSubmission { let timeout_secs = question.timeout_seconds; let default_answer = question.default.clone(); @@ -163,7 +188,10 @@ pub async fn ask_with_timeout(interviewer: &dyn Interviewer, question: Question) let duration = std::time::Duration::from_secs_f64(secs); match time::timeout(duration, interviewer.ask(question)).await { Ok(answer) => answer, - Err(_elapsed) => default_answer.unwrap_or_else(Answer::timeout), + Err(_elapsed) => AnswerSubmission::system( + default_answer.unwrap_or_else(Answer::timeout), + SystemActorKind::Timeout, + ), } } else { interviewer.ask(question).await @@ -173,9 +201,9 @@ pub async fn ask_with_timeout(interviewer: &dyn Interviewer, question: Question) /// The interviewer trait for human-in-the-loop interactions. #[async_trait] pub trait Interviewer: Send + Sync { - async fn ask(&self, question: Question) -> Answer; + async fn ask(&self, question: Question) -> AnswerSubmission; - async fn ask_multiple(&self, questions: Vec) -> Vec { + async fn ask_multiple(&self, questions: Vec) -> Vec { let mut answers = Vec::with_capacity(questions.len()); for q in questions { answers.push(self.ask(q).await); @@ -328,9 +356,9 @@ mod tests { #[async_trait] impl Interviewer for SlowInterviewer { - async fn ask(&self, _question: Question) -> Answer { + async fn ask(&self, _question: Question) -> AnswerSubmission { time::sleep(std::time::Duration::from_mins(1)).await; - Answer::yes() + AnswerSubmission::system(Answer::yes(), SystemActorKind::Engine) } } @@ -340,7 +368,7 @@ mod tests { let mut q = Question::new("approve?", QuestionType::YesNo); q.timeout_seconds = Some(0.01); - let answer = ask_with_timeout(&interviewer, q).await; + let answer = ask_with_timeout(&interviewer, q).await.answer; assert_eq!(answer.value, AnswerValue::Timeout); } @@ -351,16 +379,16 @@ mod tests { q.timeout_seconds = Some(0.01); q.default = Some(Answer::no()); - let answer = ask_with_timeout(&interviewer, q).await; + let answer = ask_with_timeout(&interviewer, q).await.answer; assert_eq!(answer.value, AnswerValue::No); } #[tokio::test] async fn ask_with_timeout_no_timeout_returns_normally() { - let interviewer = AutoApproveInterviewer; + let interviewer = AutoApproveInterviewer::engine(); let q = Question::new("approve?", QuestionType::YesNo); - let answer = ask_with_timeout(&interviewer, q).await; + let answer = ask_with_timeout(&interviewer, q).await.answer; assert_eq!(answer.value, AnswerValue::Yes); } @@ -375,9 +403,15 @@ mod tests { let ask = tokio::spawn(async move { ask_interviewer.ask(question).await }); time::sleep(std::time::Duration::from_millis(10)).await; - interviewer.submit("q-1", Answer::yes()).await.unwrap(); + interviewer + .submit( + "q-1", + AnswerSubmission::system(Answer::yes(), SystemActorKind::Engine), + ) + .await + .unwrap(); - let answer = ask.await.unwrap(); + let answer = ask.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Yes); } } diff --git a/lib/crates/fabro-interview/src/queue.rs b/lib/crates/fabro-interview/src/queue.rs index 431451cad..f15edc7d0 100644 --- a/lib/crates/fabro-interview/src/queue.rs +++ b/lib/crates/fabro-interview/src/queue.rs @@ -2,28 +2,39 @@ use std::collections::VecDeque; use std::sync::Mutex; use async_trait::async_trait; +use fabro_types::{Principal, SystemActorKind}; -use crate::{Answer, Interviewer, Question}; +use crate::{Answer, AnswerSubmission, Interviewer, Question}; /// Reads answers from a pre-filled queue. Returns Interrupted when empty. pub struct QueueInterviewer { answers: Mutex>, + actor: Principal, } impl QueueInterviewer { #[must_use] - pub const fn new(answers: VecDeque) -> Self { + pub fn new(answers: VecDeque) -> Self { + Self::with_actor(answers, Principal::system(SystemActorKind::Engine)) + } + + #[must_use] + pub fn with_actor(answers: VecDeque, actor: Principal) -> Self { Self { answers: Mutex::new(answers), + actor, } } } #[async_trait] impl Interviewer for QueueInterviewer { - async fn ask(&self, _question: Question) -> Answer { + async fn ask(&self, _question: Question) -> AnswerSubmission { let mut queue = self.answers.lock().expect("queue lock poisoned"); - queue.pop_front().unwrap_or_else(Answer::interrupted) + AnswerSubmission::new( + queue.pop_front().unwrap_or_else(Answer::interrupted), + self.actor.clone(), + ) } } @@ -40,10 +51,10 @@ mod tests { let interviewer = QueueInterviewer::new(answers); let q = Question::new("q1", QuestionType::YesNo); - let a1 = interviewer.ask(q.clone()).await; + let a1 = interviewer.ask(q.clone()).await.answer; assert_eq!(a1.value, AnswerValue::Yes); - let a2 = interviewer.ask(q).await; + let a2 = interviewer.ask(q).await.answer; assert_eq!(a2.value, AnswerValue::No); } @@ -51,7 +62,7 @@ mod tests { async fn returns_interrupted_when_empty() { let interviewer = QueueInterviewer::new(VecDeque::new()); let q = Question::new("q", QuestionType::YesNo); - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Interrupted); } @@ -62,7 +73,7 @@ mod tests { let q = Question::new("q", QuestionType::YesNo); let _ = interviewer.ask(q.clone()).await; - let answer = interviewer.ask(q).await; + let answer = interviewer.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Interrupted); } } diff --git a/lib/crates/fabro-interview/src/recording.rs b/lib/crates/fabro-interview/src/recording.rs index 87ebfc8e2..66a5a19cc 100644 --- a/lib/crates/fabro-interview/src/recording.rs +++ b/lib/crates/fabro-interview/src/recording.rs @@ -3,12 +3,12 @@ use std::sync::Mutex; use async_trait::async_trait; -use crate::{Answer, Interviewer, Question}; +use crate::{AnswerSubmission, Interviewer, Question}; /// Wraps another interviewer and records all question-answer pairs. pub struct RecordingInterviewer { - inner: Box, - recordings: Mutex>, + inner: Box, + submissions: Mutex>, } impl RecordingInterviewer { @@ -16,15 +16,15 @@ impl RecordingInterviewer { pub fn new(inner: Box) -> Self { Self { inner, - recordings: Mutex::new(Vec::new()), + submissions: Mutex::new(Vec::new()), } } /// # Panics /// Panics if the internal mutex is poisoned. #[must_use] - pub fn recordings(&self) -> Vec<(Question, Answer)> { - self.recordings + pub fn recordings(&self) -> Vec<(Question, AnswerSubmission)> { + self.submissions .lock() .expect("recordings lock poisoned") .clone() @@ -43,7 +43,7 @@ impl RecordingInterviewer { /// /// # Errors /// Returns an error if deserialization fails. - pub fn from_json(json: &str) -> std::io::Result> { + pub fn from_json(json: &str) -> std::io::Result> { serde_json::from_str(json).map_err(std::io::Error::other) } @@ -74,7 +74,7 @@ impl RecordingInterviewer { clippy::disallowed_methods, reason = "sync helper for test-mode interview recording storage; not on a Tokio path" )] - pub fn load_from_file(path: &Path) -> std::io::Result> { + pub fn load_from_file(path: &Path) -> std::io::Result> { let json = std::fs::read_to_string(path).map_err(|err| { std::io::Error::new( err.kind(), @@ -87,13 +87,13 @@ impl RecordingInterviewer { #[async_trait] impl Interviewer for RecordingInterviewer { - async fn ask(&self, question: Question) -> Answer { - let answer = self.inner.ask(question.clone()).await; - self.recordings + async fn ask(&self, question: Question) -> AnswerSubmission { + let submission = self.inner.ask(question.clone()).await; + self.submissions .lock() .expect("recordings lock poisoned") - .push((question, answer.clone())); - answer + .push((question, submission.clone())); + submission } } @@ -106,16 +106,16 @@ mod tests { #[tokio::test] async fn records_question_answer_pairs() { - let inner = Box::new(AutoApproveInterviewer); + let inner = Box::new(AutoApproveInterviewer::engine()); let recorder = RecordingInterviewer::new(inner); let q1 = Question::new("approve?", QuestionType::YesNo); let q2 = Question::new("confirm?", QuestionType::Confirmation); - let a1 = recorder.ask(q1).await; + let a1 = recorder.ask(q1).await.answer; assert_eq!(a1.value, AnswerValue::Yes); - let a2 = recorder.ask(q2).await; + let a2 = recorder.ask(q2).await.answer; assert_eq!(a2.value, AnswerValue::Yes); let recs = recorder.recordings(); @@ -126,24 +126,24 @@ mod tests { #[tokio::test] async fn delegates_to_inner() { - let inner = Box::new(AutoApproveInterviewer); + let inner = Box::new(AutoApproveInterviewer::engine()); let recorder = RecordingInterviewer::new(inner); let q = Question::new("text input", QuestionType::Freeform); - let answer = recorder.ask(q).await; + let answer = recorder.ask(q).await.answer; assert_eq!(answer.value, AnswerValue::Text("auto-approved".to_string())); } #[tokio::test] async fn recordings_empty_initially() { - let inner = Box::new(AutoApproveInterviewer); + let inner = Box::new(AutoApproveInterviewer::engine()); let recorder = RecordingInterviewer::new(inner); assert!(recorder.recordings().is_empty()); } #[tokio::test] async fn to_json_serializes_recordings() { - let inner = Box::new(AutoApproveInterviewer); + let inner = Box::new(AutoApproveInterviewer::engine()); let recorder = RecordingInterviewer::new(inner); let q = Question::new("approve?", QuestionType::YesNo); @@ -159,19 +159,22 @@ mod tests { let json = r#"[ [ {"text":"approve?","question_type":"yes_no","options":[],"allow_freeform":false,"default":null,"timeout_seconds":null,"stage":"","metadata":{}}, - {"value":"Yes","selected_option":null,"text":null} + { + "answer":{"value":"Yes","selected_option":null,"text":null}, + "actor":{"kind":"system","system_kind":"engine"} + } ] ]"#; let recordings = RecordingInterviewer::from_json(json).unwrap(); assert_eq!(recordings.len(), 1); assert_eq!(recordings[0].0.text, "approve?"); - assert_eq!(recordings[0].1.value, AnswerValue::Yes); + assert_eq!(recordings[0].1.answer.value, AnswerValue::Yes); } #[tokio::test] async fn save_to_file_and_load_from_file() { - let inner = Box::new(AutoApproveInterviewer); + let inner = Box::new(AutoApproveInterviewer::engine()); let recorder = RecordingInterviewer::new(inner); let q = Question::new("approve?", QuestionType::YesNo); @@ -185,12 +188,12 @@ mod tests { assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].0.text, "approve?"); - assert_eq!(loaded[0].1.value, AnswerValue::Yes); + assert_eq!(loaded[0].1.answer.value, AnswerValue::Yes); } #[tokio::test] async fn round_trip_serialize_deserialize() { - let inner = Box::new(AutoApproveInterviewer); + let inner = Box::new(AutoApproveInterviewer::engine()); let recorder = RecordingInterviewer::new(inner); let q1 = Question::new("approve?", QuestionType::YesNo); @@ -204,9 +207,9 @@ mod tests { assert_eq!(restored.len(), 2); assert_eq!(restored[0].0.text, "approve?"); assert_eq!(restored[0].0.question_type, QuestionType::YesNo); - assert_eq!(restored[0].1.value, AnswerValue::Yes); + assert_eq!(restored[0].1.answer.value, AnswerValue::Yes); assert_eq!(restored[1].0.text, "confirm?"); assert_eq!(restored[1].0.question_type, QuestionType::Confirmation); - assert_eq!(restored[1].1.value, AnswerValue::Yes); + assert_eq!(restored[1].1.answer.value, AnswerValue::Yes); } } diff --git a/lib/crates/fabro-interview/src/replay.rs b/lib/crates/fabro-interview/src/replay.rs index 50a70f917..b9e6e39c7 100644 --- a/lib/crates/fabro-interview/src/replay.rs +++ b/lib/crates/fabro-interview/src/replay.rs @@ -1,13 +1,14 @@ use std::sync::Mutex; use async_trait::async_trait; +use fabro_types::{Principal, SystemActorKind}; -use crate::{Answer, Interviewer, Question}; +use crate::{Answer, AnswerSubmission, Interviewer, Question}; /// Replays recorded answers in sequence. When recordings are exhausted, /// returns `Answer::interrupted()`. pub struct ReplayInterviewer { - answers: Mutex>, + submissions: Mutex>, } impl ReplayInterviewer { @@ -15,21 +16,28 @@ impl ReplayInterviewer { /// question-answer pairs. Only the answers are retained for replay. #[must_use] pub fn new(recordings: Vec<(Question, Answer)>) -> Self { - let answers: Vec = recordings.into_iter().map(|(_, a)| a).collect(); + let actor = Principal::system(SystemActorKind::Engine); + let submissions: Vec = recordings + .into_iter() + .map(|(_, answer)| AnswerSubmission::new(answer, actor.clone())) + .collect(); Self { - answers: Mutex::new(answers), + submissions: Mutex::new(submissions), } } } #[async_trait] impl Interviewer for ReplayInterviewer { - async fn ask(&self, _question: Question) -> Answer { - let mut answers = self.answers.lock().expect("answers lock poisoned"); - if answers.is_empty() { - Answer::interrupted() + async fn ask(&self, _question: Question) -> AnswerSubmission { + let mut submissions = self.submissions.lock().expect("answers lock poisoned"); + if submissions.is_empty() { + AnswerSubmission::new( + Answer::interrupted(), + Principal::system(SystemActorKind::Engine), + ) } else { - answers.remove(0) + submissions.remove(0) } } } @@ -58,12 +66,14 @@ mod tests { let a1 = replayer .ask(Question::new("anything", QuestionType::YesNo)) - .await; + .await + .answer; assert_eq!(a1.value, AnswerValue::Yes); let a2 = replayer .ask(Question::new("anything", QuestionType::Freeform)) - .await; + .await + .answer; assert_eq!(a2.value, AnswerValue::Text("Alice".to_string())); } @@ -78,17 +88,20 @@ mod tests { let a1 = replayer .ask(Question::new("first", QuestionType::YesNo)) - .await; + .await + .answer; assert_eq!(a1.value, AnswerValue::Yes); let a2 = replayer .ask(Question::new("second", QuestionType::YesNo)) - .await; + .await + .answer; assert_eq!(a2.value, AnswerValue::Interrupted); let a3 = replayer .ask(Question::new("third", QuestionType::YesNo)) - .await; + .await + .answer; assert_eq!(a3.value, AnswerValue::Interrupted); } } diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index 68bb840d4..9e231704a 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -16,7 +16,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use cookie::time::Duration; use cookie::{Cookie, CookieJar, Key, SameSite}; -use fabro_types::RunAuthMethod; +use fabro_types::AuthMethod; use fabro_types::settings::ServerAuthMethod; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; use rand::TryRngCore; @@ -475,7 +475,7 @@ async fn token( email: entry.email.clone(), avatar_url: String::new(), user_url: String::new(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, }, chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES), ); @@ -620,7 +620,7 @@ async fn refresh( email: old.email.clone(), avatar_url: String::new(), user_url: String::new(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, }, chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES), ); @@ -701,7 +701,7 @@ fn github_config(auth_mode: &AuthMode) -> Option<&ConfiguredAuth> { AuthMode::Enabled(config) if config.methods.contains(&ServerAuthMethod::Github) => { Some(config) } - _ => None, + AuthMode::Enabled(_) => None, } } @@ -722,9 +722,8 @@ fn session_cookie_secure(state: &AppState) -> bool { } fn eligible_session(session: Option<&SessionCookie>) -> Option<&SessionCookie> { - session.filter(|session| { - session.identity.is_some() && session.auth_method == RunAuthMethod::Github - }) + session + .filter(|session| session.identity.is_some() && session.auth_method == AuthMethod::Github) } fn valid_state_token(state: &str) -> bool { @@ -1133,7 +1132,7 @@ mod tests { use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use fabro_config::{RunLayer, ServerSettingsBuilder}; - use fabro_types::RunAuthMethod; + use fabro_types::AuthMethod; use fabro_types::settings::server::ServerAuthMethod; use serde_json::json; use sha2::{Digest, Sha256}; @@ -1229,7 +1228,7 @@ client_id = "github-client-id" let session = SessionCookie { v: 2, login: "octocat".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, identity: Some( fabro_types::IdpIdentity::new("https://github.com", "12345") .expect("identity should be valid"), diff --git a/lib/crates/fabro-server/src/auth/jwt.rs b/lib/crates/fabro-server/src/auth/jwt.rs index f64889430..1e757fa0d 100644 --- a/lib/crates/fabro-server/src/auth/jwt.rs +++ b/lib/crates/fabro-server/src/auth/jwt.rs @@ -1,5 +1,5 @@ use chrono::{Duration, Utc}; -use fabro_types::{IdpIdentity, RunAuthMethod}; +use fabro_types::{AuthMethod, IdpIdentity}; use jsonwebtoken::errors::{Error as JwtDecodeError, ErrorKind}; use jsonwebtoken::{Algorithm, Header, Validation, decode, decode_header, encode}; use serde::{Deserialize, Serialize}; @@ -18,7 +18,7 @@ pub(crate) struct JwtSubject { pub email: String, pub avatar_url: String, pub user_url: String, - pub auth_method: RunAuthMethod, + pub auth_method: AuthMethod, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -38,7 +38,7 @@ pub(crate) struct Claims { pub avatar_url: String, #[serde(default)] pub user_url: String, - pub auth_method: RunAuthMethod, + pub auth_method: AuthMethod, } #[derive(Debug, thiserror::Error, PartialEq, Eq)] @@ -127,7 +127,7 @@ mod tests { use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::{Duration, Utc}; - use fabro_types::RunAuthMethod; + use fabro_types::AuthMethod; use jsonwebtoken::{Algorithm, Header, encode}; use serde::Serialize; use uuid::Uuid; @@ -148,7 +148,7 @@ mod tests { email: "octocat@example.com".to_string(), avatar_url: "https://example.com/octocat.png".to_string(), user_url: "https://github.com/octocat".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, } } @@ -167,7 +167,7 @@ mod tests { email: "octocat@example.com".to_string(), avatar_url: "https://example.com/octocat.png".to_string(), user_url: "https://github.com/octocat".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, } } @@ -202,7 +202,7 @@ mod tests { assert_eq!(claims.idp_subject, "12345"); assert_eq!(claims.avatar_url, "https://example.com/octocat.png"); assert_eq!(claims.user_url, "https://github.com/octocat"); - assert_eq!(claims.auth_method, RunAuthMethod::Github); + assert_eq!(claims.auth_method, AuthMethod::Github); assert!(Uuid::parse_str(&claims.jti).is_ok()); } @@ -221,7 +221,7 @@ mod tests { login: String, name: String, email: String, - auth_method: RunAuthMethod, + auth_method: AuthMethod, } let now = Utc::now().timestamp(); @@ -239,7 +239,7 @@ mod tests { login: "octocat".to_string(), name: "The Octocat".to_string(), email: "octocat@example.com".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, }, &signing_key().encoding_key(), ); diff --git a/lib/crates/fabro-server/src/auth/translate.rs b/lib/crates/fabro-server/src/auth/translate.rs index d715eaaec..28b3594f7 100644 --- a/lib/crates/fabro-server/src/auth/translate.rs +++ b/lib/crates/fabro-server/src/auth/translate.rs @@ -5,7 +5,7 @@ use axum::http::{HeaderMap, HeaderValue, header}; use axum::middleware::Next; use axum::response::Response; use chrono::{Duration, Utc}; -use fabro_types::{IdpIdentity, RunAuthMethod}; +use fabro_types::{AuthMethod, IdpIdentity}; use fabro_util::dev_token::validate_dev_token_format; use tracing::trace; @@ -35,23 +35,18 @@ pub(crate) async fn auth_translation_middleware( mut req: Request, next: Next, ) -> Response { - let translated = match req + let AuthMode::Enabled(config) = req .extensions() .get::() - .expect("AuthMode extension must be added to the router") - { - AuthMode::Disabled => return next.run(req).await, - AuthMode::Enabled(config) => { - if let Some(auth_header) = req.headers().get(header::AUTHORIZATION) { - auth_header - .to_str() - .ok() - .and_then(|value| value.strip_prefix("Bearer ")) - .and_then(|token| translate_bearer_token(token, config)) - } else { - translate_session_cookie(req.headers(), state.as_ref(), config) - } - } + .expect("AuthMode extension must be added to the router"); + let translated = if let Some(auth_header) = req.headers().get(header::AUTHORIZATION) { + auth_header + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .and_then(|token| translate_bearer_token(token, config)) + } else { + translate_session_cookie(req.headers(), state.as_ref(), config) }; if let Some(token) = translated { @@ -85,7 +80,7 @@ fn translate_bearer_token(token: &str, config: &ConfiguredAuth) -> Option Option { } fn legacy_dev_identity(session: &SessionCookie) -> Option { - (session.auth_method == RunAuthMethod::DevToken).then(dev_identity) + (session.auth_method == AuthMethod::DevToken).then(dev_identity) } fn dev_identity() -> IdpIdentity { @@ -155,7 +150,7 @@ mod tests { use cookie::{Cookie, CookieJar}; use fabro_config::{RunLayer, ServerSettingsBuilder}; use fabro_types::settings::ServerAuthMethod; - use fabro_types::{IdpIdentity, RunAuthMethod}; + use fabro_types::{AuthMethod, IdpIdentity}; use serde_json::json; use tower::ServiceExt; @@ -260,7 +255,7 @@ methods = ["dev-token"] SessionCookie { v: 2, login: "octocat".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, identity: Some(IdpIdentity::new("https://github.com", "12345").unwrap()), name: "The Octocat".to_string(), email: "octocat@example.com".to_string(), @@ -275,7 +270,7 @@ methods = ["dev-token"] SessionCookie { v: 2, login: "dev".to_string(), - auth_method: RunAuthMethod::DevToken, + auth_method: AuthMethod::DevToken, identity, name: "Development User".to_string(), email: "dev@localhost".to_string(), @@ -297,7 +292,7 @@ methods = ["dev-token"] email: "octocat@example.com".to_string(), avatar_url: "https://example.com/octocat.png".to_string(), user_url: "https://github.com/octocat".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, }, chrono::Duration::minutes(10), ) @@ -379,7 +374,7 @@ methods = ["dev-token"] assert_eq!(claims.email, "dev@fabro.local"); assert_eq!(claims.idp_issuer, "fabro:dev"); assert_eq!(claims.idp_subject, "dev"); - assert_eq!(claims.auth_method, RunAuthMethod::DevToken); + assert_eq!(claims.auth_method, AuthMethod::DevToken); } #[tokio::test] @@ -410,7 +405,7 @@ methods = ["dev-token"] assert_eq!(claims.idp_subject, "12345"); assert_eq!(claims.avatar_url, "https://example.com/octocat.png"); assert_eq!(claims.user_url, "https://github.com/octocat"); - assert_eq!(claims.auth_method, RunAuthMethod::Github); + assert_eq!(claims.auth_method, AuthMethod::Github); } #[tokio::test] @@ -436,7 +431,7 @@ methods = ["dev-token"] let claims = auth::verify(&signing_key(), "https://fabro.example", token).unwrap(); assert_eq!(claims.idp_issuer, "fabro:dev"); assert_eq!(claims.idp_subject, "dev"); - assert_eq!(claims.auth_method, RunAuthMethod::DevToken); + assert_eq!(claims.auth_method, AuthMethod::DevToken); } #[tokio::test] @@ -538,7 +533,7 @@ methods = ["dev-token"] .expect("authorization should be minted despite demo header"); let claims = auth::verify(&signing_key(), "https://fabro.example", token).unwrap(); assert_eq!(claims.login, "octocat"); - assert_eq!(claims.auth_method, RunAuthMethod::Github); + assert_eq!(claims.auth_method, AuthMethod::Github); assert_eq!(json["demo"], "1"); } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index b1a56658f..7327d694a 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -21,7 +21,7 @@ use fabro_api::types::{ use serde_json::json; use crate::error::ApiError; -use crate::jwt_auth::AuthenticatedService; +use crate::principal_middleware::RequiredUser; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; use crate::server::{AppState, PaginationParams}; @@ -44,7 +44,7 @@ fn paginated_response( // ── Runs ─────────────────────────────────────────────────────────────── pub(crate) async fn list_runs( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Query(pagination): Query, ) -> Response { @@ -52,7 +52,7 @@ pub(crate) async fn list_runs( } pub(crate) async fn list_board_runs( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Query(pagination): Query, ) -> Response { @@ -74,7 +74,7 @@ pub(crate) async fn list_board_runs( } pub(crate) async fn create_run_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { ( @@ -85,7 +85,7 @@ pub(crate) async fn create_run_stub( } pub(crate) async fn resolve_run( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Query(params): Query, ) -> Response { @@ -111,7 +111,7 @@ pub(crate) async fn resolve_run( } pub(crate) async fn start_run_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(id): Path, ) -> Response { @@ -125,7 +125,7 @@ pub(crate) async fn start_run_stub( } pub(crate) async fn get_run_stages( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, Query(pagination): Query, @@ -134,7 +134,7 @@ pub(crate) async fn get_run_stages( } pub(crate) async fn get_stage_turns( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path((_id, _stage_id)): Path<(String, String)>, Query(pagination): Query, @@ -143,7 +143,7 @@ pub(crate) async fn get_stage_turns( } pub(crate) async fn list_run_artifacts_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -159,7 +159,7 @@ pub(crate) async fn list_run_artifacts_stub( /// `_state` parameters are intentionally ignored so demo responses cannot /// cross-contaminate with real run data (R34). pub(crate) async fn list_run_files_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -239,7 +239,7 @@ fn demo_run_files() -> PaginatedRunFileList { } pub(crate) async fn get_run_billing( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -247,7 +247,7 @@ pub(crate) async fn get_run_billing( } pub(crate) async fn get_run_settings( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -255,7 +255,7 @@ pub(crate) async fn get_run_settings( } pub(crate) async fn generate_preview_url_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -267,7 +267,7 @@ pub(crate) async fn generate_preview_url_stub( } pub(crate) async fn create_ssh_access_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -279,7 +279,7 @@ pub(crate) async fn create_ssh_access_stub( } pub(crate) async fn list_sandbox_files_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -296,7 +296,7 @@ pub(crate) async fn list_sandbox_files_stub( } pub(crate) async fn get_sandbox_file_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -304,7 +304,7 @@ pub(crate) async fn get_sandbox_file_stub( } pub(crate) async fn put_sandbox_file_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -312,7 +312,7 @@ pub(crate) async fn put_sandbox_file_stub( } pub(crate) async fn get_run_status( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(id): Path, ) -> Response { @@ -331,7 +331,7 @@ pub(crate) struct ResolveRunParams { } pub(crate) async fn get_questions_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, Query(pagination): Query, @@ -340,7 +340,7 @@ pub(crate) async fn get_questions_stub( } pub(crate) async fn answer_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path((_id, _qid)): Path<(String, String)>, ) -> Response { @@ -348,7 +348,7 @@ pub(crate) async fn answer_stub( } pub(crate) async fn run_events_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -373,7 +373,7 @@ pub(crate) async fn run_events_stub( } pub(crate) async fn checkpoint_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -381,7 +381,7 @@ pub(crate) async fn checkpoint_stub( } pub(crate) async fn cancel_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(id): Path, ) -> Response { @@ -397,7 +397,7 @@ pub(crate) async fn cancel_stub( } pub(crate) async fn pause_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(id): Path, ) -> Response { @@ -411,7 +411,7 @@ pub(crate) async fn pause_stub( } pub(crate) async fn unpause_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(id): Path, ) -> Response { @@ -421,7 +421,7 @@ pub(crate) async fn unpause_stub( const DEMO_GRAPH_DOT: &str = "digraph demo {\n graph [goal=\"Demo\"]\n rankdir=LR\n start [shape=Mdiamond, label=\"Start\"]\n detect [label=\"Detect\\nDrift\"]\n exit [shape=Msquare, label=\"Exit\"]\n propose [label=\"Propose\\nChanges\"]\n review [label=\"Review\\nChanges\"]\n apply [label=\"Apply\\nChanges\"]\n start -> detect\n detect -> exit [label=\"No drift\"]\n detect -> propose [label=\"Drift found\"]\n propose -> review\n review -> propose [label=\"Revise\"]\n review -> apply [label=\"Accept\"]\n apply -> exit\n}"; pub(crate) async fn get_run_graph( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -429,7 +429,7 @@ pub(crate) async fn get_run_graph( } pub(crate) async fn get_run_graph_source( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -442,7 +442,7 @@ pub(crate) async fn get_run_graph_source( } pub(crate) async fn list_secrets( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { ( @@ -468,7 +468,7 @@ pub(crate) async fn list_secrets( } pub(crate) async fn create_secret( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Json(body): Json, ) -> Response { @@ -485,7 +485,7 @@ pub(crate) async fn create_secret( } pub(crate) async fn delete_secret_by_name( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Json(_body): Json, ) -> Response { @@ -493,7 +493,7 @@ pub(crate) async fn delete_secret_by_name( } pub(crate) async fn get_github_repo( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path((owner, name)): Path<(String, String)>, ) -> Response { @@ -513,7 +513,7 @@ pub(crate) async fn get_github_repo( } pub(crate) async fn run_diagnostics( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { ( @@ -545,7 +545,7 @@ pub(crate) async fn run_diagnostics( // ── Insights ─────────────────────────────────────────────────────────── pub(crate) async fn list_saved_queries( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Query(pagination): Query, ) -> Response { @@ -553,7 +553,7 @@ pub(crate) async fn list_saved_queries( } pub(crate) async fn save_query_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { ( @@ -564,7 +564,7 @@ pub(crate) async fn save_query_stub( } pub(crate) async fn get_saved_query( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(id): Path, ) -> Response { @@ -575,7 +575,7 @@ pub(crate) async fn get_saved_query( } pub(crate) async fn update_query_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -587,7 +587,7 @@ pub(crate) async fn update_query_stub( } pub(crate) async fn delete_query_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Path(_id): Path, ) -> Response { @@ -595,7 +595,7 @@ pub(crate) async fn delete_query_stub( } pub(crate) async fn execute_query_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { ( @@ -611,7 +611,7 @@ pub(crate) async fn execute_query_stub( } pub(crate) async fn list_query_history( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Query(pagination): Query, ) -> Response { @@ -621,7 +621,7 @@ pub(crate) async fn list_query_history( // ── Settings ─────────────────────────────────────────────────────────── pub(crate) async fn get_server_settings( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { (StatusCode::OK, Json(settings::server_settings())).into_response() @@ -630,7 +630,7 @@ pub(crate) async fn get_server_settings( // ── System ──────────────────────────────────────────────────────────── pub(crate) async fn attach_events_stub( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { let events = vec![ @@ -663,7 +663,7 @@ pub(crate) async fn attach_events_stub( } pub(crate) async fn get_system_info( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { ( @@ -688,7 +688,7 @@ pub(crate) async fn get_system_info( } pub(crate) async fn get_system_disk_usage( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, Query(params): Query, ) -> Response { @@ -732,7 +732,7 @@ pub(crate) async fn get_system_disk_usage( } pub(crate) async fn prune_runs( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { ( @@ -759,7 +759,7 @@ pub(crate) async fn prune_runs( // ── Usage ────────────────────────────────────────────────────────────── pub(crate) async fn get_aggregate_billing( - _auth: AuthenticatedService, + _auth: RequiredUser, State(_state): State>, ) -> Response { (StatusCode::OK, Json(billing::aggregate())).into_response() diff --git a/lib/crates/fabro-server/src/error.rs b/lib/crates/fabro-server/src/error.rs index 04bd110fd..ba66d3198 100644 --- a/lib/crates/fabro-server/src/error.rs +++ b/lib/crates/fabro-server/src/error.rs @@ -124,6 +124,10 @@ impl ApiError { pub fn status(&self) -> StatusCode { self.status } + + pub(crate) fn code(&self) -> Option<&str> { + self.code.as_deref() + } } impl From for ApiError { diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index f791189b4..984314262 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -1,13 +1,15 @@ use anyhow::{Result, anyhow}; -use axum::extract::FromRequestParts; +#[cfg(test)] use axum::http::header; +#[cfg(test)] use axum::http::request::Parts; use fabro_static::EnvVars; use fabro_types::settings::{ServerAuthMethod, ServerNamespace}; -use fabro_types::{IdpIdentity, RunAuthMethod}; +use fabro_types::{AuthMethod, IdpIdentity}; use fabro_util::dev_token::validate_dev_token_format; use hmac::{Hmac, Mac}; use sha2::Sha256; +#[cfg(test)] use tracing::info; use crate::auth::{self, JwtError, JwtSigningKey, KeyDeriveError}; @@ -23,7 +25,7 @@ pub struct VerifiedAuth { pub email: String, pub avatar_url: String, pub user_url: String, - pub auth_method: RunAuthMethod, + pub auth_method: AuthMethod, pub identity: Option, } @@ -49,7 +51,6 @@ impl ConfiguredAuth { #[derive(Clone, Debug, PartialEq, Eq)] pub enum AuthMode { Enabled(ConfiguredAuth), - Disabled, } pub fn resolve_auth_mode(settings: &ServerNamespace) -> Result { @@ -183,14 +184,14 @@ pub(crate) fn dev_token_matches(provided: &str, expected: &str) -> bool { expected_mac.verify_slice(&provided_mac).is_ok() } -fn config_allows_run_auth_method(config: &ConfiguredAuth, method: RunAuthMethod) -> bool { +fn config_allows_run_auth_method(config: &ConfiguredAuth, method: AuthMethod) -> bool { match method { - RunAuthMethod::Disabled => false, - RunAuthMethod::DevToken => config.methods.contains(&ServerAuthMethod::DevToken), - RunAuthMethod::Github => config.methods.contains(&ServerAuthMethod::Github), + AuthMethod::DevToken => config.methods.contains(&ServerAuthMethod::DevToken), + AuthMethod::Github => config.methods.contains(&ServerAuthMethod::Github), } } +#[cfg(test)] pub(crate) fn bearer_token(parts: &Parts) -> Option> { let value = parts.headers.get(header::AUTHORIZATION)?; let Ok(value) = value.to_str() else { @@ -203,7 +204,10 @@ pub(crate) fn bearer_token(parts: &Parts) -> Option> { ) } -fn authenticate_jwt_bearer(token: &str, config: &ConfiguredAuth) -> Result { +pub(crate) fn authenticate_jwt_bearer( + token: &str, + config: &ConfiguredAuth, +) -> Result { let Some(jwt_key) = config.jwt_key.as_ref() else { return Err(ApiError::unauthorized()); }; @@ -252,6 +256,7 @@ fn authenticate_jwt_bearer(token: &str, config: &ConfiguredAuth) -> Result bool { +pub(crate) fn looks_like_jwt(token: &str) -> bool { let mut segments = token.split('.'); matches!( ( @@ -285,15 +290,14 @@ fn looks_like_jwt(token: &str) -> bool { ) } +#[cfg(test)] fn authenticate_parts(parts: &Parts) -> Result, ApiError> { let auth_mode = parts .extensions .get::() .expect("AuthMode extension must be added to the router"); - let AuthMode::Enabled(config) = auth_mode else { - return Ok(None); - }; + let AuthMode::Enabled(config) = auth_mode; authenticate_bearer( parts, @@ -303,70 +307,6 @@ fn authenticate_parts(parts: &Parts) -> Result, ApiError> { .map(Some) } -pub struct AuthenticatedService; - -pub fn authenticate_service_parts(parts: &Parts) -> Result<(), ApiError> { - authenticate_parts(parts).map(|_| ()) -} - -impl FromRequestParts for AuthenticatedService { - type Rejection = ApiError; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - authenticate_service_parts(parts)?; - Ok(Self) - } -} - -pub struct AuthenticatedSubject { - pub login: Option, - pub name: String, - pub email: String, - pub avatar_url: String, - pub user_url: String, - pub identity: Option, - pub auth_method: RunAuthMethod, -} - -impl FromRequestParts for AuthenticatedSubject { - type Rejection = ApiError; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let auth_mode = parts - .extensions - .get::() - .expect("AuthMode extension must be added to the router"); - - match auth_mode { - AuthMode::Disabled => Ok(Self { - login: None, - name: String::new(), - email: String::new(), - avatar_url: String::new(), - user_url: String::new(), - identity: None, - auth_method: RunAuthMethod::Disabled, - }), - AuthMode::Enabled(config) => { - let auth = authenticate_bearer( - parts, - bearer_token(parts).ok_or_else(ApiError::unauthorized)??, - config, - )?; - Ok(Self { - login: Some(auth.login), - name: auth.name, - email: auth.email, - avatar_url: auth.avatar_url, - user_url: auth.user_url, - identity: auth.identity, - auth_method: auth.auth_method, - }) - } - } - } -} - pub fn auth_method_name(method: ServerAuthMethod) -> &'static str { match method { ServerAuthMethod::DevToken => "dev-token", @@ -381,14 +321,11 @@ mod tests { use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use axum::response::IntoResponse; - use axum::routing::get; - use axum::{Json, Router}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use fabro_config::{Error as ConfigError, ServerSettingsBuilder}; use fabro_types::IdpIdentity; use fabro_types::settings::ServerAuthMethod; - use tower::ServiceExt; use tracing::field::{Field, Visit}; use tracing::{Event, Subscriber, subscriber}; use tracing_subscriber::layer::{Context, SubscriberExt}; @@ -405,47 +342,6 @@ mod tests { None } - async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse { - "ok" - } - - async fn subject_handler(subject: AuthenticatedSubject) -> impl IntoResponse { - Json(serde_json::json!({ - "login": subject.login, - "name": subject.name, - "email": subject.email, - "avatar_url": subject.avatar_url, - "user_url": subject.user_url, - "idp_issuer": subject.identity.as_ref().map(|identity| identity.issuer().to_string()), - "idp_subject": subject.identity.as_ref().map(|identity| identity.subject().to_string()), - "auth_method": subject.auth_method, - })) - } - - fn test_router(mode: AuthMode) -> Router { - Router::new() - .route("/test", get(protected_handler)) - .layer(axum::Extension(mode)) - } - - fn subject_router(mode: AuthMode) -> Router { - Router::new() - .route("/subject", get(subject_handler)) - .layer(axum::Extension(mode)) - } - - macro_rules! response_json { - ($response:expr) => { - fabro_test::expect_axum_json($response, StatusCode::OK, concat!(file!(), ":", line!())) - }; - } - - macro_rules! assert_status { - ($response:expr, $expected:expr) => { - fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!())) - }; - } - async fn error_json(err: ApiError) -> serde_json::Value { let response = err.into_response(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); @@ -491,7 +387,7 @@ mod tests { email: "octocat@example.com".to_string(), avatar_url: "https://example.com/octocat.png".to_string(), user_url: "https://github.com/octocat".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, } } @@ -636,9 +532,7 @@ methods = ["dev-token"] _ => None, }) .expect("dev-token auth should resolve"); - let AuthMode::Enabled(config) = mode else { - panic!("expected enabled mode"); - }; + let AuthMode::Enabled(config) = mode; assert_eq!(config.methods, vec![ServerAuthMethod::DevToken]); assert!(config.dev_token.is_some()); assert!(config.jwt_key.is_some()); @@ -672,9 +566,7 @@ url = "http://localhost:4000" _ => None, }) .expect("dev-token auth should resolve"); - let AuthMode::Enabled(config) = mode else { - panic!("expected enabled mode"); - }; + let AuthMode::Enabled(config) = mode; assert_eq!(config.jwt_issuer.as_deref(), Some("http://localhost:4000")); } @@ -705,9 +597,7 @@ url = "" _ => None, }) .expect("dev-token auth should resolve"); - let AuthMode::Enabled(config) = mode else { - panic!("expected enabled mode"); - }; + let AuthMode::Enabled(config) = mode; assert_eq!(config.jwt_issuer.as_deref(), Some("fabro-server")); } @@ -816,76 +706,61 @@ client_id = "Iv1.test" }) .expect("github auth should resolve"); - let AuthMode::Enabled(config) = mode else { - panic!("expected enabled mode"); - }; + let AuthMode::Enabled(config) = mode; assert_eq!(config.methods, vec![ServerAuthMethod::Github]); assert!(config.jwt_key.is_some()); assert_eq!(config.jwt_issuer.as_deref(), Some("http://localhost:3000")); } - #[tokio::test] - async fn disabled_mode_allows_request() { - let app = test_router(AuthMode::Disabled); - let response = app - .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_status!(response, StatusCode::OK).await; - } - #[tokio::test] async fn rejects_missing_credentials() { - let app = test_router(dev_token_mode()); - let response = app - .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_status!(response, StatusCode::UNAUTHORIZED).await; + let parts = request_parts( + dev_token_mode(), + Request::builder().uri("/test").body(Body::empty()).unwrap(), + ); + let err = authenticate_parts(&parts).unwrap_err(); + assert_eq!(err.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn rejects_dev_token_bearer_without_translation() { - let app = subject_router(dev_token_mode()); - let response = app - .oneshot( - Request::builder() - .uri("/subject") - .header( - "authorization", - "Bearer fabro_dev_abababababababababababababababababababababababababababababababab", - ) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_status!(response, StatusCode::UNAUTHORIZED).await; + let parts = request_parts( + dev_token_mode(), + Request::builder() + .uri("/subject") + .header( + "authorization", + "Bearer fabro_dev_abababababababababababababababababababababababababababababab", + ) + .body(Body::empty()) + .unwrap(), + ); + let err = authenticate_parts(&parts).unwrap_err(); + assert_eq!(err.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn subject_reports_profile_fields_from_jwt() { - let app = subject_router(github_jwt_mode()); let token = issue_github_token(chrono::Duration::minutes(10)); - let response = app - .oneshot( - Request::builder() - .uri("/subject") - .header("authorization", format!("Bearer {token}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let json = response_json!(response).await; - assert_eq!(json["login"], "octocat"); - assert_eq!(json["name"], "The Octocat"); - assert_eq!(json["email"], "octocat@example.com"); - assert_eq!(json["avatar_url"], "https://example.com/octocat.png"); - assert_eq!(json["user_url"], "https://github.com/octocat"); - assert_eq!(json["idp_issuer"], "https://github.com"); - assert_eq!(json["idp_subject"], "12345"); - assert_eq!(json["auth_method"], "github"); + let parts = request_parts( + github_jwt_mode(), + Request::builder() + .uri("/subject") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ); + let auth = authenticate_parts(&parts).unwrap().unwrap(); + assert_eq!(auth.login, "octocat"); + assert_eq!(auth.name, "The Octocat"); + assert_eq!(auth.email, "octocat@example.com"); + assert_eq!(auth.avatar_url, "https://example.com/octocat.png"); + assert_eq!(auth.user_url, "https://github.com/octocat"); + assert_eq!( + auth.identity, + Some(IdpIdentity::new("https://github.com", "12345").unwrap()) + ); + assert_eq!(auth.auth_method, AuthMethod::Github); } #[test] @@ -902,7 +777,7 @@ client_id = "Iv1.test" let auth = authenticate_parts(&parts).unwrap().unwrap(); assert_eq!(auth.login, "octocat"); - assert_eq!(auth.auth_method, RunAuthMethod::Github); + assert_eq!(auth.auth_method, AuthMethod::Github); assert_eq!( auth.identity, Some(IdpIdentity::new("https://github.com", "12345").unwrap()) diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index e6fe31b41..d9ff230bd 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -25,6 +25,7 @@ pub mod install; pub mod ip_allowlist; pub mod jwt_auth; pub mod manifest_validation; +mod principal_middleware; mod request_id; mod run_files; mod run_files_security; @@ -37,6 +38,7 @@ mod server_secrets; mod spawn_env; mod startup; pub mod static_files; +pub mod test_support; pub mod web_auth; mod worker_token; diff --git a/lib/crates/fabro-server/src/principal_middleware.rs b/lib/crates/fabro-server/src/principal_middleware.rs new file mode 100644 index 000000000..27914e119 --- /dev/null +++ b/lib/crates/fabro-server/src/principal_middleware.rs @@ -0,0 +1,433 @@ +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; + +use axum::extract::{FromRequestParts, Path, Request, State}; +use axum::http::request::Parts; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use fabro_types::{CommandOutputStream, Principal, RunBlobId, RunId, StageId, UserPrincipal}; +use jsonwebtoken::dangerous::insecure_decode; +use serde::Deserialize; + +use crate::auth::JwtError; +use crate::error::ApiError; +use crate::jwt_auth::{self, AuthMode, ConfiguredAuth}; +use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path}; +use crate::worker_token::{self, WORKER_TOKEN_ISSUER}; + +#[derive(Clone, Debug)] +pub(crate) struct RequestAuthContext { + pub principal: Principal, + pub auth_status: AuthStatus, + pub auth_error_code: Option<&'static str>, + pub user_profile: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct UserProfile { + pub name: String, + pub email: String, + pub avatar_url: String, + pub user_url: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AuthStatus { + Missing, + Invalid, + Expired, + Authenticated, +} + +#[derive(Clone)] +pub(crate) struct AuthContextSlot(pub(crate) Arc>); + +#[allow( + dead_code, + reason = "Route migrations use RequestAuth first; retained for principal-only guards." +)] +pub(crate) struct RequestPrincipal(pub(crate) Principal); + +pub(crate) struct RequestAuth(pub(crate) AuthContextSlot); + +pub(crate) struct RequiredUser(pub(crate) UserPrincipal); +pub(crate) struct RequireRunScoped(pub(crate) RunId); +pub(crate) struct RequireRunBlob(pub(crate) RunId, pub(crate) RunBlobId); +pub(crate) struct RequireStageArtifact(pub(crate) RunId, pub(crate) StageId); +pub(crate) struct RequireCommandLog( + pub(crate) RunId, + pub(crate) StageId, + pub(crate) CommandOutputStream, +); + +#[derive(Clone, Debug)] +pub(crate) struct AuthenticatedUser { + pub principal: UserPrincipal, + pub profile: UserProfile, +} + +#[derive(Debug, Deserialize)] +struct IssuerOnlyClaims { + iss: String, +} + +impl RequestAuthContext { + #[must_use] + pub(crate) fn initial() -> Self { + Self { + principal: Principal::anonymous(), + auth_status: AuthStatus::Missing, + auth_error_code: None, + user_profile: None, + } + } +} + +impl AuthStatus { + #[must_use] + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Invalid => "invalid", + Self::Expired => "expired", + Self::Authenticated => "authenticated", + } + } +} + +impl AuthContextSlot { + #[must_use] + pub(crate) fn initial() -> Self { + Self(Arc::new(Mutex::new(RequestAuthContext::initial()))) + } + + pub(crate) fn replace(&self, context: RequestAuthContext) { + *self.0.lock().expect("auth context lock poisoned") = context; + } + + #[must_use] + pub(crate) fn snapshot(&self) -> RequestAuthContext { + self.0.lock().expect("auth context lock poisoned").clone() + } +} + +impl FromRequestParts for RequestPrincipal { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let slot = parts + .extensions + .get::() + .cloned() + .unwrap_or_else(AuthContextSlot::initial); + Ok(Self(slot.snapshot().principal)) + } +} + +impl FromRequestParts for RequestAuth { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let slot = parts + .extensions + .get::() + .cloned() + .unwrap_or_else(AuthContextSlot::initial); + Ok(Self(slot)) + } +} + +impl FromRequestParts for RequiredUser { + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let slot = parts + .extensions + .get::() + .cloned() + .unwrap_or_else(AuthContextSlot::initial); + require_user(&slot).map(Self) + } +} + +impl FromRequestParts> for RequireRunScoped { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let Path(id): Path = Path::from_request_parts(parts, state) + .await + .map_err(IntoResponse::into_response)?; + let run_id = parse_run_id_path(&id)?; + require_worker_or_user_for_run(&principal_from_parts(parts), &run_id) + .map_err(IntoResponse::into_response)?; + Ok(Self(run_id)) + } +} + +impl FromRequestParts> for RequireRunBlob { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state) + .await + .map_err(IntoResponse::into_response)?; + let run_id = parse_run_id_path(&id)?; + let blob_id = parse_blob_id_path(&blob_id)?; + require_worker_or_user_for_run(&principal_from_parts(parts), &run_id) + .map_err(IntoResponse::into_response)?; + Ok(Self(run_id, blob_id)) + } +} + +impl FromRequestParts> for RequireStageArtifact { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let Path((id, stage_id)): Path<(String, String)> = Path::from_request_parts(parts, state) + .await + .map_err(IntoResponse::into_response)?; + let run_id = parse_run_id_path(&id)?; + let stage_id = parse_stage_id_path(&stage_id)?; + require_worker_or_user_for_run(&principal_from_parts(parts), &run_id) + .map_err(IntoResponse::into_response)?; + Ok(Self(run_id, stage_id)) + } +} + +impl FromRequestParts> for RequireCommandLog { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let Path((id, stage_id, stream)): Path<(String, String, String)> = + Path::from_request_parts(parts, state) + .await + .map_err(IntoResponse::into_response)?; + let run_id = parse_run_id_path(&id)?; + let stage_id = parse_stage_id_path(&stage_id)?; + let stream = stream + .parse::() + .map_err(|_| ApiError::bad_request("Invalid command log stream.").into_response())?; + require_worker_or_user_for_run(&principal_from_parts(parts), &run_id) + .map_err(IntoResponse::into_response)?; + Ok(Self(run_id, stage_id, stream)) + } +} + +pub(crate) async fn principal_middleware( + State(state): State>, + mut req: Request, + next: Next, +) -> Response { + let slot = req + .extensions() + .get::() + .cloned() + .unwrap_or_else(|| { + let slot = AuthContextSlot::initial(); + req.extensions_mut().insert(slot.clone()); + slot + }); + + let context = classify_request(&req, state.as_ref()); + slot.replace(context); + next.run(req).await +} + +fn principal_from_parts(parts: &Parts) -> Principal { + parts + .extensions + .get::() + .map_or_else(Principal::anonymous, |slot| slot.snapshot().principal) +} + +pub(crate) fn require_user(slot: &AuthContextSlot) -> Result { + let context = slot.snapshot(); + match context.principal { + Principal::User(user) => Ok(user), + _ => Err(auth_rejection(context.auth_status, context.auth_error_code)), + } +} + +pub(crate) fn require_authenticated_user( + slot: &AuthContextSlot, +) -> Result { + let context = slot.snapshot(); + match context.principal { + Principal::User(principal) => { + let Some(profile) = context.user_profile else { + return Err(ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Authenticated user profile missing.", + )); + }; + Ok(AuthenticatedUser { principal, profile }) + } + _ => Err(auth_rejection(context.auth_status, context.auth_error_code)), + } +} + +#[allow( + dead_code, + reason = "Worker-only route migration is staged behind the shared context." +)] +pub(crate) fn require_worker_for_run( + principal: &Principal, + route_run_id: &RunId, +) -> Result<(), ApiError> { + match principal { + Principal::Worker { run_id } if run_id == route_run_id => Ok(()), + Principal::Worker { .. } => Err(ApiError::forbidden()), + _ => Err(ApiError::unauthorized()), + } +} + +#[allow( + dead_code, + reason = "Run-scoped route migration is staged behind the shared context." +)] +pub(crate) fn require_worker_or_user_for_run( + principal: &Principal, + route_run_id: &RunId, +) -> Result<(), ApiError> { + match principal { + Principal::User(_) => Ok(()), + Principal::Worker { run_id } if run_id == route_run_id => Ok(()), + Principal::Worker { .. } => Err(ApiError::forbidden()), + _ => Err(ApiError::unauthorized()), + } +} + +#[allow( + dead_code, + reason = "Webhook route stamps the slot inline; guard is for future webhook consumers." +)] +pub(crate) fn require_webhook(principal: &Principal) -> Result { + match principal { + Principal::Webhook { delivery_id } => Ok(delivery_id.clone()), + _ => Err(ApiError::unauthorized()), + } +} + +fn classify_request(req: &Request, state: &AppState) -> RequestAuthContext { + let AuthMode::Enabled(config) = req + .extensions() + .get::() + .expect("AuthMode extension must be added to the router"); + + let token = match bearer_from_headers(req.headers()) { + BearerCredential::Missing => return RequestAuthContext::initial(), + BearerCredential::Invalid => { + return rejected(AuthStatus::Invalid, Some("unauthorized")); + } + BearerCredential::Present(token) => token, + }; + + if token.starts_with("fabro_refresh_") { + return rejected(AuthStatus::Invalid, Some("unauthorized")); + } + if !jwt_auth::looks_like_jwt(token) { + return rejected(AuthStatus::Invalid, Some("access_token_invalid")); + } + + let issuer = match insecure_decode::(token) { + Ok(data) => data.claims.iss, + Err(_) => return rejected(AuthStatus::Invalid, Some("access_token_invalid")), + }; + + if issuer == WORKER_TOKEN_ISSUER { + return match worker_token::decode_worker_token(token, state.worker_token_keys()) { + Ok(run_id) => authenticated(Principal::worker(run_id), None), + Err(JwtError::AccessTokenExpired) => { + rejected(AuthStatus::Expired, Some("access_token_expired")) + } + Err(JwtError::AccessTokenInvalid) => { + rejected(AuthStatus::Invalid, Some("access_token_invalid")) + } + }; + } + + classify_user_token(token, config) +} + +enum BearerCredential<'a> { + Missing, + Invalid, + Present(&'a str), +} + +fn bearer_from_headers(headers: &HeaderMap) -> BearerCredential<'_> { + let Some(value) = headers.get(header::AUTHORIZATION) else { + return BearerCredential::Missing; + }; + let Ok(value) = value.to_str() else { + return BearerCredential::Invalid; + }; + match value.strip_prefix("Bearer ") { + Some(token) => BearerCredential::Present(token), + None => BearerCredential::Invalid, + } +} + +fn classify_user_token(token: &str, config: &ConfiguredAuth) -> RequestAuthContext { + let auth = match jwt_auth::authenticate_jwt_bearer(token, config) { + Ok(auth) => auth, + Err(err) if err.code() == Some("access_token_expired") => { + return rejected(AuthStatus::Expired, Some("access_token_expired")); + } + Err(err) if err.code() == Some("access_token_invalid") => { + return rejected(AuthStatus::Invalid, Some("access_token_invalid")); + } + Err(_) => return rejected(AuthStatus::Invalid, Some("unauthorized")), + }; + let Some(identity) = auth.identity else { + return rejected(AuthStatus::Invalid, Some("access_token_invalid")); + }; + let principal = Principal::user(identity, auth.login, auth.auth_method); + let profile = UserProfile { + name: auth.name, + email: auth.email, + avatar_url: auth.avatar_url, + user_url: auth.user_url, + }; + authenticated(principal, Some(profile)) +} + +fn authenticated(principal: Principal, user_profile: Option) -> RequestAuthContext { + RequestAuthContext { + principal, + auth_status: AuthStatus::Authenticated, + auth_error_code: None, + user_profile, + } +} + +fn rejected(status: AuthStatus, code: Option<&'static str>) -> RequestAuthContext { + RequestAuthContext { + principal: Principal::anonymous(), + auth_status: status, + auth_error_code: code, + user_profile: None, + } +} + +fn auth_rejection(status: AuthStatus, code: Option<&'static str>) -> ApiError { + match (status, code) { + (AuthStatus::Expired | AuthStatus::Invalid, Some(code)) => { + ApiError::unauthorized_with_code("Authentication required.", code) + } + _ => ApiError::unauthorized(), + } +} diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs index 599ace2b0..22eed6cd5 100644 --- a/lib/crates/fabro-server/src/run_files.rs +++ b/lib/crates/fabro-server/src/run_files.rs @@ -42,7 +42,7 @@ use serde::Deserialize; use tokio::sync::{Mutex, watch}; use crate::error::ApiError; -use crate::jwt_auth::AuthenticatedService; +use crate::principal_middleware::RequiredUser; use crate::run_files_security::{RunFilesMetrics, is_sensitive}; use crate::server::{AppState, parse_run_id_path}; @@ -185,7 +185,7 @@ where /// set enforced by [`RunFilesMetrics::emit`] — no paths, contents, or raw /// git stderr. pub async fn list_run_files( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Query(params): Query, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index c39f5c200..41d449c60 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -43,7 +43,9 @@ use fabro_auth::{ }; use fabro_config::daemon::ServerDaemon; use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, Storage, envfile}; -use fabro_interview::{Answer, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope}; +use fabro_interview::{ + Answer, AnswerSubmission, ControlInterviewer, Interviewer, Question, WorkerControlEnvelope, +}; use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate_object}; use fabro_llm::model_test::run_model_test; @@ -73,9 +75,9 @@ use fabro_types::settings::server::{ }; use fabro_types::settings::{InterpString, RunNamespace}; use fabro_types::{ - ActorRef, CommandOutputStream, EventBody, InterviewQuestionRecord, PullRequestRecord, + CommandOutputStream, EventBody, InterviewQuestionRecord, Principal, PullRequestRecord, QuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, - RunServerProvenance, RunSubjectProvenance, ServerSettings, parse_blob_ref, + RunServerProvenance, ServerSettings, UserPrincipal, parse_blob_ref, }; use fabro_util::error::{SharedError, collect_causes, render_with_causes}; use fabro_util::version::FABRO_VERSION; @@ -117,16 +119,18 @@ use crate::github_webhooks::{ WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature, }; use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; -use crate::jwt_auth::{self, AuthMode, AuthenticatedService, AuthenticatedSubject}; +use crate::jwt_auth::{self, AuthMode}; +use crate::principal_middleware::{ + AuthContextSlot, AuthStatus, RequestAuth, RequestAuthContext, RequireCommandLog, + RequireRunBlob, RequireRunScoped, RequireStageArtifact, RequiredUser, principal_middleware, + require_user, +}; use crate::request_id::{self, RequestId}; use crate::run_files::{FilesInFlight, list_run_files, new_files_in_flight}; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; use crate::server_secrets::{LlmClientResult, ServerSecrets}; use crate::spawn_env::{apply_render_graph_env, apply_worker_env}; -use crate::worker_token::{ - AuthorizeCommandLog, AuthorizeRunBlob, AuthorizeRunScoped, AuthorizeStageArtifact, - WorkerTokenKeys, issue_worker_token, -}; +use crate::worker_token::{WorkerTokenKeys, issue_worker_token}; use crate::{ canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth, }; @@ -361,17 +365,21 @@ enum AnswerTransportError { } impl RunAnswerTransport { - async fn submit(&self, qid: &str, answer: Answer) -> Result<(), AnswerTransportError> { + async fn submit( + &self, + qid: &str, + submission: AnswerSubmission, + ) -> Result<(), AnswerTransportError> { match self { Self::Subprocess { control_tx } => { - let message = WorkerControlEnvelope::interview_answer(qid.to_string(), answer); + let message = WorkerControlEnvelope::interview_answer(qid.to_string(), submission); timeout(WORKER_CONTROL_ENQUEUE_TIMEOUT, control_tx.send(message)) .await .map_err(|_| AnswerTransportError::Timeout)? .map_err(|_| AnswerTransportError::Closed) } Self::InProcess { interviewer } => interviewer - .submit(qid, answer) + .submit(qid, submission) .await .map_err(|_| AnswerTransportError::Closed), } @@ -536,7 +544,8 @@ impl SlackService { else { return; }; - let _ = submit_pending_interview_answer(state.as_ref(), &pending, submission.answer).await; + let answer_submission = AnswerSubmission::new(submission.answer, submission.actor); + let _ = submit_pending_interview_answer(state.as_ref(), &pending, answer_submission).await; } } @@ -958,6 +967,10 @@ pub fn build_router_with_options( .clone() .unwrap_or_else(|| Arc::new(GithubEndpoints::production_defaults())); let webhook_secret = state.server_secret(WEBHOOK_SECRET_ENV); + let demo_principal_layer = + middleware::from_fn_with_state(Arc::clone(&state), principal_middleware); + let real_principal_layer = + middleware::from_fn_with_state(Arc::clone(&state), principal_middleware); let api_common = if web_enabled { Router::new() .route("/openapi.json", get(openapi_spec)) @@ -967,12 +980,21 @@ pub fn build_router_with_options( }; let demo_router = Router::new() - .nest("/api/v1", api_common.clone().merge(demo_routes())) + .nest( + "/api/v1", + api_common + .clone() + .merge(demo_routes()) + .layer(demo_principal_layer), + ) .layer(axum::Extension(auth_mode.clone())) .layer(axum::Extension(Arc::clone(&github_endpoints))) .with_state(state.clone()); - let mut real_router = Router::new().nest("/api/v1", api_common.merge(real_routes())); + let mut real_router = Router::new().nest( + "/api/v1", + api_common.merge(real_routes()).layer(real_principal_layer), + ); if web_enabled { real_router = real_router.nest("/auth", web_auth::routes().merge(auth::web_routes())); } @@ -1056,7 +1078,7 @@ pub fn build_router_with_options( .layer(middleware::from_fn(request_id::layer)) } -async fn http_log_middleware(req: axum_extract::Request, next: Next) -> Response { +async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Response { let path = req.uri().path(); if path.starts_with("/assets/") || path.starts_with("/images/") { return next.run(req).await; @@ -1069,14 +1091,64 @@ async fn http_log_middleware(req: axum_extract::Request, next: Next) -> Response .copied() .map(RequestId::render) .unwrap_or_default(); + let auth_slot = AuthContextSlot::initial(); + req.extensions_mut().insert(auth_slot.clone()); let start = std::time::Instant::now(); let response = next.run(req).await; let status = response.status().as_u16(); let latency_ms = start.elapsed().as_millis(); + let auth_context = auth_slot.snapshot(); + let principal_fields = auth_context.principal.log_fields(); + let user_auth_method = principal_fields.user_auth_method.unwrap_or(""); + let idp_issuer = principal_fields.idp_issuer.as_deref().unwrap_or(""); + let idp_subject = principal_fields.idp_subject.as_deref().unwrap_or(""); + let login = principal_fields.login.as_deref().unwrap_or(""); + let run_id = principal_fields.run_id.as_deref().unwrap_or(""); + let delivery_id = principal_fields.delivery_id.as_deref().unwrap_or(""); + let team_id = principal_fields.team_id.as_deref().unwrap_or(""); + let user_id = principal_fields.user_id.as_deref().unwrap_or(""); + let auth_status = auth_context.auth_status.as_str(); + let auth_error_code = auth_context.auth_error_code.unwrap_or(""); if status >= 500 { - error!(%method, %path, status, latency_ms, request_id = %request_id, "HTTP response"); + error!( + %method, + %path, + status, + latency_ms, + request_id = %request_id, + principal_kind = principal_fields.principal_kind, + user_auth_method, + idp_issuer, + idp_subject, + login, + run_id, + delivery_id, + team_id, + user_id, + auth_status, + auth_error_code, + "HTTP response" + ); } else { - info!(%method, %path, status, latency_ms, request_id = %request_id, "HTTP response"); + info!( + %method, + %path, + status, + latency_ms, + request_id = %request_id, + principal_kind = principal_fields.principal_kind, + user_auth_method, + idp_issuer, + idp_subject, + login, + run_id, + delivery_id, + team_id, + user_id, + auth_status, + auth_error_code, + "HTTP response" + ); } response } @@ -1292,6 +1364,7 @@ async fn not_implemented() -> Response { async fn github_webhook( State(secret): State>, + RequestAuth(auth_slot): RequestAuth, headers: HeaderMap, body: Bytes, ) -> StatusCode { @@ -1304,15 +1377,34 @@ async fn github_webhook( .get("x-hub-signature-256") .and_then(|value| value.to_str().ok()) else { + auth_slot.replace(RequestAuthContext { + principal: Principal::anonymous(), + auth_status: AuthStatus::Invalid, + auth_error_code: Some("unauthorized"), + user_profile: None, + }); warn!(delivery = %delivery_id, "Webhook missing X-Hub-Signature-256 header"); return StatusCode::UNAUTHORIZED; }; if !verify_signature(&secret, &body, signature) { + auth_slot.replace(RequestAuthContext { + principal: Principal::anonymous(), + auth_status: AuthStatus::Invalid, + auth_error_code: Some("unauthorized"), + user_profile: None, + }); warn!(delivery = %delivery_id, "Webhook HMAC signature mismatch"); return StatusCode::UNAUTHORIZED; } + auth_slot.replace(RequestAuthContext { + principal: Principal::webhook(delivery_id.to_string()), + auth_status: AuthStatus::Authenticated, + auth_error_code: None, + user_profile: None, + }); + let event_type = headers .get("x-github-event") .and_then(|value| value.to_str().ok()) @@ -1345,10 +1437,7 @@ async fn health() -> Response { .into_response() } -async fn get_server_settings( - _auth: AuthenticatedService, - State(state): State>, -) -> Response { +async fn get_server_settings(_auth: RequiredUser, State(state): State>) -> Response { ( StatusCode::OK, Json(state.server_settings().as_ref().clone()), @@ -1356,10 +1445,7 @@ async fn get_server_settings( .into_response() } -async fn get_system_info( - _auth: AuthenticatedService, - State(state): State>, -) -> Response { +async fn get_system_info(_auth: RequiredUser, State(state): State>) -> Response { let manifest_run_settings = state.manifest_run_settings(); let server_settings = state.server_settings(); let (total_runs, active_runs) = { @@ -1419,7 +1505,7 @@ fn system_features( } async fn get_system_df( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Query(params): Query, ) -> Response { @@ -1456,7 +1542,7 @@ async fn get_system_df( } async fn prune_runs( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(body): Json, ) -> Response { @@ -1525,7 +1611,7 @@ async fn prune_runs( } async fn attach_events( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Query(params): Query, ) -> Response { @@ -1816,13 +1902,13 @@ where i64::try_from(value).unwrap_or(i64::MAX) } -async fn list_secrets(_auth: AuthenticatedService, State(state): State>) -> Response { +async fn list_secrets(_auth: RequiredUser, State(state): State>) -> Response { let data = state.vault.read().await.list(); (StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response() } async fn create_secret( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(body): Json, ) -> Response { @@ -1867,7 +1953,7 @@ async fn create_secret( } async fn delete_secret_by_name( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(body): Json, ) -> Response { @@ -1930,7 +2016,7 @@ fn validate_github_slug(kind: &str, value: &str, max_len: usize) -> Result<(), R } async fn get_github_repo( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path((owner, name)): Path<(String, String)>, ) -> Response { @@ -2152,10 +2238,7 @@ async fn get_github_repo( .into_response() } -async fn run_diagnostics( - _auth: AuthenticatedService, - State(state): State>, -) -> Response { +async fn run_diagnostics(_auth: RequiredUser, State(state): State>) -> Response { ( StatusCode::OK, Json(diagnostics::run_all(state.as_ref()).await), @@ -2187,7 +2270,7 @@ fn active_stage_state_from_events(events: &[EventEnvelope], node_id: &str) -> St } async fn get_aggregate_billing( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, ) -> Response { let agg = state @@ -2237,7 +2320,7 @@ async fn get_aggregate_billing( } async fn list_run_stages( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Query(_pagination): Query, @@ -2327,7 +2410,7 @@ async fn list_run_stages( } async fn get_run_billing( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -2963,7 +3046,7 @@ fn paginate_items(items: Vec, pagination: &PaginationParams) -> (Vec, b } async fn list_board_runs( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Query(pagination): Query, ) -> Response { @@ -3010,7 +3093,7 @@ async fn list_board_runs( } async fn list_runs( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Query(params): Query, ) -> Response { @@ -3079,7 +3162,7 @@ struct CommandLogResponseBody { } async fn resolve_run( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Query(query): Query, ) -> Response { @@ -3116,7 +3199,7 @@ async fn resolve_run( } async fn delete_run( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Query(query): Query, Path(id): Path, @@ -4132,10 +4215,10 @@ fn validate_answer_for_question( async fn submit_pending_interview_answer( state: &AppState, pending: &LoadedPendingInterview, - answer: Answer, + submission: AnswerSubmission, ) -> Result<(), Response> { - validate_answer_for_question(&pending.question, &answer)?; - deliver_answer_to_run(state, pending.run_id, &pending.qid, answer).await + validate_answer_for_question(&pending.question, &submission.answer)?; + deliver_answer_to_run(state, pending.run_id, &pending.qid, submission).await } #[allow( @@ -4146,7 +4229,7 @@ async fn deliver_answer_to_run( state: &AppState, run_id: RunId, qid: &str, - answer: Answer, + submission: AnswerSubmission, ) -> Result<(), Response> { let transport = match claim_run_answer_transport(state, run_id, qid) { Ok(transport) => transport, @@ -4167,7 +4250,7 @@ async fn deliver_answer_to_run( } }; - if let Ok(()) = transport.submit(qid, answer).await { + if let Ok(()) = transport.submit(qid, submission).await { Ok(()) } else { release_run_answer_claim(state, run_id, qid); @@ -4216,11 +4299,15 @@ fn answer_from_request( } async fn create_run( - subject: AuthenticatedSubject, + RequestAuth(auth_slot): RequestAuth, State(state): State>, headers: HeaderMap, body: Bytes, ) -> Response { + let subject = match require_user(&auth_slot) { + Ok(subject) => subject, + Err(err) => return err.into_response(), + }; let req = match serde_json::from_slice::(&body) { Ok(req) => req, Err(err) => return ApiError::bad_request(err.to_string()).into_response(), @@ -4298,16 +4385,13 @@ async fn create_run( .into_response() } -fn run_provenance(headers: &HeaderMap, subject: &AuthenticatedSubject) -> RunProvenance { +fn run_provenance(headers: &HeaderMap, subject: &UserPrincipal) -> RunProvenance { RunProvenance { server: Some(RunServerProvenance { version: FABRO_VERSION.to_string(), }), client: run_client_provenance(headers), - subject: Some(RunSubjectProvenance { - login: subject.login.clone(), - auth_method: subject.auth_method, - }), + subject: Some(Principal::User(subject.clone())), } } @@ -4340,7 +4424,7 @@ fn parse_known_fabro_user_agent(user_agent: &str) -> Option<(&str, &str)> { } async fn run_preflight( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(req): Json, ) -> Response { @@ -4367,7 +4451,7 @@ async fn run_preflight( } async fn validate_run_manifest( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(req): Json, ) -> Response { @@ -4391,7 +4475,7 @@ async fn validate_run_manifest( } async fn render_graph_from_manifest( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(req): Json, ) -> Response { @@ -4418,7 +4502,7 @@ async fn render_graph_from_manifest( } async fn start_run( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, body: Option>, @@ -5193,7 +5277,7 @@ pub fn spawn_scheduler(state: Arc) { } async fn get_run_status( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -5217,7 +5301,7 @@ async fn get_run_status( } async fn get_run_settings( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -5249,7 +5333,7 @@ async fn get_run_settings( } async fn get_questions( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -5281,7 +5365,7 @@ async fn get_questions( } async fn submit_answer( - _auth: AuthenticatedService, + auth: RequiredUser, State(state): State>, Path((id, qid)): Path<(String, String)>, Json(req): Json, @@ -5301,14 +5385,15 @@ async fn submit_answer( Ok(answer) => answer, Err(response) => return response, }; - match submit_pending_interview_answer(state.as_ref(), &pending, answer).await { + let submission = AnswerSubmission::new(answer, Principal::User(auth.0)); + match submit_pending_interview_answer(state.as_ref(), &pending, submission).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(response) => response, } } async fn get_run_state( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, ) -> Response { match state.store.open_run_reader(&id).await { @@ -5323,7 +5408,7 @@ async fn get_run_state( } async fn get_run_logs( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, ) -> Response { if state.store.open_run_reader(&id).await.is_err() { @@ -5346,7 +5431,7 @@ async fn get_run_logs( } async fn get_run_stage_command_log( - AuthorizeCommandLog(id, stage_id, stream): AuthorizeCommandLog, + RequireCommandLog(id, stage_id, stream): RequireCommandLog, State(state): State>, Query(query): Query, ) -> Response { @@ -5691,7 +5776,7 @@ impl<'a> RunPrInputs<'a> { } async fn create_run_pull_request( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, Json(body): Json, ) -> Response { @@ -5765,7 +5850,7 @@ async fn create_run_pull_request( } async fn get_run_pull_request( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, ) -> Response { let ctx = match load_pull_request_github_context(&state, &id).await { @@ -5798,7 +5883,7 @@ async fn get_run_pull_request( } async fn merge_run_pull_request( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, Json(body): Json, ) -> Response { @@ -5835,7 +5920,7 @@ async fn merge_run_pull_request( } async fn close_run_pull_request( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, ) -> Response { let ctx = match load_pull_request_github_context(&state, &id).await { @@ -5869,7 +5954,7 @@ async fn close_run_pull_request( } async fn append_run_event( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, Json(value): Json, ) -> Response { @@ -5914,7 +5999,7 @@ async fn append_run_event( } async fn list_run_events( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, Query(params): Query, ) -> Response { @@ -5943,7 +6028,7 @@ async fn list_run_events( } async fn attach_run_events( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Query(params): Query, @@ -6069,7 +6154,7 @@ async fn attach_run_events( } async fn get_checkpoint( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -6107,7 +6192,7 @@ async fn get_checkpoint( } async fn write_run_blob( - AuthorizeRunScoped(id): AuthorizeRunScoped, + RequireRunScoped(id): RequireRunScoped, State(state): State>, body: Bytes, ) -> Response { @@ -6129,7 +6214,7 @@ async fn write_run_blob( } async fn read_run_blob( - AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob, + RequireRunBlob(id, blob_id): RequireRunBlob, State(state): State>, ) -> Response { match state.store.open_run_reader(&id).await { @@ -6163,7 +6248,7 @@ async fn load_run_spec(state: &AppState, run_id: &RunId) -> Result>, Path(id): Path, ) -> Response { @@ -6196,7 +6281,7 @@ async fn list_run_artifacts( } async fn list_stage_artifacts( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path((id, stage_id)): Path<(String, String)>, ) -> Response { @@ -6580,7 +6665,7 @@ async fn upload_stage_artifact_multipart( async fn put_stage_artifact( State(state): State>, - AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact, + RequireStageArtifact(id, stage_id): RequireStageArtifact, Query(params): Query, request: axum_extract::Request, ) -> Response { @@ -6625,7 +6710,7 @@ async fn put_stage_artifact( } async fn get_stage_artifact( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path((id, stage_id)): Path<(String, String)>, Query(params): Query, @@ -6664,7 +6749,7 @@ async fn get_stage_artifact( } async fn generate_preview_url( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Json(request): Json, @@ -6716,7 +6801,7 @@ async fn generate_preview_url( } async fn create_ssh_access( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Json(request): Json, @@ -6736,7 +6821,7 @@ async fn create_ssh_access( } async fn list_sandbox_files( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Query(params): Query, @@ -6766,7 +6851,7 @@ async fn list_sandbox_files( } async fn get_sandbox_file( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Query(params): Query, @@ -6801,7 +6886,7 @@ async fn get_sandbox_file( } async fn put_sandbox_file( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Query(params): Query, @@ -6909,7 +6994,7 @@ async fn append_control_request( state: &AppState, run_id: RunId, action: RunControlAction, - actor: Option, + actor: Option, ) -> anyhow::Result<()> { let run_store = state.store.open_run(&run_id).await?; let event = match action { @@ -6920,8 +7005,8 @@ async fn append_control_request( workflow_event::append_event(&run_store, &run_id, &event).await } -fn actor_from_subject(subject: &AuthenticatedSubject) -> Option { - subject.login.clone().map(ActorRef::user) +fn actor_from_subject(subject: &RequiredUser) -> Principal { + Principal::User(subject.0.clone()) } /// Returns the wire event name if the given body has a dedicated operation @@ -6972,7 +7057,7 @@ fn schedule_worker_kill(state: Arc, run_id: RunId, worker_pid: u32) { } async fn cancel_run( - subject: AuthenticatedSubject, + subject: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -7049,7 +7134,7 @@ async fn cancel_run( state.as_ref(), id, RunControlAction::Cancel, - actor_from_subject(&subject), + Some(actor_from_subject(&subject)), ) .await { @@ -7128,7 +7213,7 @@ enum UnpauseMode { } async fn pause_run( - subject: AuthenticatedSubject, + subject: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -7177,7 +7262,7 @@ async fn pause_run( state.as_ref(), id, RunControlAction::Pause, - actor_from_subject(&subject), + Some(actor_from_subject(&subject)), ) .await { @@ -7230,7 +7315,7 @@ async fn pause_run( } async fn unpause_run( - subject: AuthenticatedSubject, + subject: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -7282,7 +7367,7 @@ async fn unpause_run( state.as_ref(), id, RunControlAction::Unpause, - actor_from_subject(&subject), + Some(actor_from_subject(&subject)), ) .await { @@ -7335,7 +7420,7 @@ async fn unpause_run( } async fn archive_run( - subject: AuthenticatedSubject, + subject: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -7343,7 +7428,7 @@ async fn archive_run( } async fn unarchive_run( - subject: AuthenticatedSubject, + subject: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -7351,7 +7436,7 @@ async fn unarchive_run( } async fn rewind_run( - subject: AuthenticatedSubject, + subject: RequiredUser, State(state): State>, Path(id): Path, body: Option>, @@ -7372,7 +7457,7 @@ async fn rewind_run( match Box::pin(operations::rewind( &state.store, &input, - actor_from_subject(&subject), + Some(actor_from_subject(&subject)), )) .await { @@ -7412,7 +7497,7 @@ async fn rewind_run( } async fn fork_run( - _subject: AuthenticatedSubject, + _subject: RequiredUser, State(state): State>, Path(id): Path, body: Option>, @@ -7448,7 +7533,7 @@ async fn fork_run( } async fn run_timeline( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -7514,7 +7599,7 @@ enum ArchiveAction { async fn run_archive_action( state: Arc, - subject: AuthenticatedSubject, + subject: RequiredUser, id: String, action: ArchiveAction, ) -> Response { @@ -7522,7 +7607,7 @@ async fn run_archive_action( Ok(id) => id, Err(response) => return response, }; - let actor = actor_from_subject(&subject); + let actor = Some(actor_from_subject(&subject)); let result = match action { ArchiveAction::Archive => operations::archive(&state.store, &id, actor) .await @@ -7609,7 +7694,7 @@ async fn synchronous_transition( } async fn list_models( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Query(params): Query, ) -> Response { @@ -7672,7 +7757,7 @@ async fn list_models( } async fn test_model( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, Query(params): Query, @@ -7790,7 +7875,7 @@ fn convert_llm_message(msg: &LlmMessage) -> CompletionMessage { } async fn create_completion( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(req): Json, ) -> Response { @@ -8178,7 +8263,7 @@ async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result>, Path(id): Path, Query(params): Query, @@ -8205,7 +8290,7 @@ async fn get_graph( } async fn get_graph_source( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Path(id): Path, ) -> Response { @@ -8244,9 +8329,9 @@ mod tests { use fabro_model::Provider; use fabro_types::settings::ServerAuthMethod; use fabro_types::{ - AttrValue, CommandTermination, FailureCategory, FailureDetail, Graph, - InterviewQuestionRecord, Outcome, QuestionType, RunAuthMethod, RunBlobId, RunId, RunSpec, - StageOutcome, fixtures, + AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph, + InterviewQuestionRecord, Outcome, QuestionType, RunBlobId, RunId, RunSpec, StageOutcome, + SystemActorKind, fixtures, }; use httpmock::Method::POST; use httpmock::MockServer; @@ -8295,9 +8380,8 @@ mod tests { fn test_app_with() -> Router { let state = create_app_state(); - build_router_with_options( + crate::test_support::build_test_router_with_options( state, - &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { static_asset_root: Some(spa_fixture_root()), @@ -8312,7 +8396,7 @@ mod tests { fn test_app_with_scheduler(state: Arc) -> Router { spawn_scheduler(Arc::clone(&state)); - build_router(state, AuthMode::Disabled) + crate::test_support::build_test_router(state) } fn create_app_state_with_isolated_storage() -> Arc { @@ -8489,7 +8573,7 @@ methods = ["dev-token"] email: "octocat@example.com".to_string(), avatar_url: "https://example.com/octocat.png".to_string(), user_url: "https://github.com/octocat".to_string(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, } } @@ -8557,9 +8641,8 @@ url = "{url}" RunLayer::default(), 5, ); - build_router_with_options( + crate::test_support::build_test_router_with_options( state, - &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions::default(), ) @@ -8870,7 +8953,7 @@ provider = "invalid-provider" #[tokio::test] async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") .uri(api("/secrets")) @@ -8902,7 +8985,7 @@ provider = "invalid-provider" #[tokio::test] async fn github_webhook_rejects_missing_signature() { - let app = webhook_test_app(AuthMode::Disabled); + let app = webhook_test_app(crate::test_support::test_auth_mode()); let body = br#"{"action":"opened"}"#; let response = app @@ -8914,7 +8997,7 @@ provider = "invalid-provider" #[tokio::test] async fn github_webhook_rejects_signature_signed_with_wrong_secret() { - let app = webhook_test_app(AuthMode::Disabled); + let app = webhook_test_app(crate::test_support::test_auth_mode()); let body = br#"{"action":"opened"}"#; let bad_signature = compute_signature(b"wrong-secret", body); @@ -8929,7 +9012,7 @@ provider = "invalid-provider" async fn github_webhook_accepts_valid_signature_when_auth_disabled() { let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; let signature = compute_signature(TEST_WEBHOOK_SECRET.as_bytes(), body); - let app = webhook_test_app(AuthMode::Disabled); + let app = webhook_test_app(crate::test_support::test_auth_mode()); let response = app .oneshot(webhook_request(Some(&signature), None, body)) @@ -8971,7 +9054,7 @@ provider = "invalid-provider" #[tokio::test] async fn create_secret_stores_valid_credential_entries() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let credential = fabro_auth::AuthCredential { provider: Provider::OpenAi, details: fabro_auth::AuthDetails::CodexOAuth { @@ -9145,7 +9228,7 @@ provider = "invalid-provider" ) .unwrap(); } - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let response = app .oneshot( @@ -9173,7 +9256,7 @@ provider = "invalid-provider" #[tokio::test] async fn create_secret_rejects_invalid_credential_json() { let state = create_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("POST") @@ -9196,7 +9279,7 @@ provider = "invalid-provider" #[tokio::test] async fn create_secret_rejects_wrong_credential_name() { let state = create_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("POST") @@ -9236,7 +9319,7 @@ provider = "invalid-provider" #[tokio::test] async fn delete_secret_by_name_removes_file_secret() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let create_req = Request::builder() .method("POST") @@ -9706,7 +9789,7 @@ allowed_usernames = ["octocat"] transport.cancel_run().await.unwrap(); - let answer = answer_task.await.unwrap(); + let answer = answer_task.await.unwrap().answer; assert_eq!(answer.value, AnswerValue::Cancelled); } @@ -9867,7 +9950,7 @@ allowed_usernames = ["octocat"] #[tokio::test] async fn list_run_stages_projects_retrying_until_completion() { let state = create_app_state_with_isolated_storage(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); create_durable_run_with_events(&state, run_id, &[ @@ -10102,7 +10185,7 @@ strategy = "token" github_api_base_url: Option, ) -> (Arc, Router, RunId) { let state = create_github_token_app_state(token, github_api_base_url); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); (state, app, fixtures::RUN_1) } @@ -10115,7 +10198,7 @@ strategy = "token" github_api_base_url: Option, ) -> (Arc, Router, String) { let state = create_github_token_app_state(token, github_api_base_url); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT).await; (state, app, run_id) } @@ -10288,7 +10371,7 @@ strategy = "token" 5, |_| None, ); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("POST") @@ -10311,7 +10394,7 @@ strategy = "token" 5, |_| None, ); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("POST") @@ -10378,7 +10461,7 @@ strategy = "token" 5, |name| (name == EnvVars::ANTHROPIC_API_KEY).then(|| "test-key".to_string()), ); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("GET") @@ -10414,7 +10497,7 @@ strategy = "token" 5, |_| None, ); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("GET") @@ -10774,7 +10857,7 @@ slug = "fabro" #[tokio::test] async fn get_questions_returns_empty_list() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); // Start a run let req = Request::builder() @@ -10843,7 +10926,10 @@ slug = "fabro" let response = submit_pending_interview_answer( state.as_ref(), &pending, - Answer::text("not a valid multiple choice answer"), + AnswerSubmission::system( + Answer::text("not a valid multiple choice answer"), + SystemActorKind::Engine, + ), ) .await .unwrap_err(); @@ -10869,7 +10955,7 @@ slug = "fabro" #[tokio::test] async fn get_run_state_returns_projection() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -10896,7 +10982,7 @@ slug = "fabro" #[tokio::test] async fn get_run_logs_returns_per_run_log_file() { let state = create_app_state_with_isolated_storage(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); create_durable_run_with_events(&state, run_id, &[workflow_event::Event::RunSubmitted { definition_blob: None, @@ -10934,7 +11020,7 @@ slug = "fabro" #[tokio::test] async fn get_run_logs_returns_not_found_for_missing_run() { let state = create_app_state_with_isolated_storage(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let missing_run_id = RunId::new(); let req = Request::builder() @@ -10950,7 +11036,7 @@ slug = "fabro" #[tokio::test] async fn get_run_logs_returns_not_found_when_log_file_is_missing() { let state = create_app_state_with_isolated_storage(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); create_durable_run_with_events(&state, run_id, &[workflow_event::Event::RunSubmitted { definition_blob: None, @@ -10970,7 +11056,7 @@ slug = "fabro" #[tokio::test] async fn get_run_stage_command_log_returns_scratch_slice() { let state = create_app_state_with_isolated_storage(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); let stage_id = StageId::new("script_node", 1); create_durable_run_with_events(&state, run_id, &[ @@ -11031,7 +11117,7 @@ slug = "fabro" #[tokio::test] async fn get_run_stage_command_log_returns_cas_slice() { let state = create_app_state_with_isolated_storage(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); let run_store = state.store.create_run(&run_id).await.unwrap(); let stdout_blob = run_store @@ -11101,7 +11187,7 @@ slug = "fabro" #[tokio::test] async fn get_run_stage_command_log_prefers_scratch_when_cas_ref_exists() { let state = create_app_state_with_isolated_storage(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); let stage_id = StageId::new("script_node", 1); let run_store = state.store.create_run(&run_id).await.unwrap(); @@ -11182,7 +11268,7 @@ slug = "fabro" #[tokio::test] async fn get_run_stage_command_log_returns_not_found_for_missing_stage() { let state = create_app_state_with_isolated_storage(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); create_durable_run_with_events(&state, run_id, &[workflow_event::Event::RunSubmitted { definition_blob: None, @@ -11266,7 +11352,7 @@ slug = "fabro" #[tokio::test] async fn get_run_pull_request_returns_not_found_when_record_missing() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT).await; let response = app @@ -11427,7 +11513,7 @@ slug = "fabro" None, ) .unwrap(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = fixtures::RUN_1; create_completed_run_ready_for_pull_request( &state, @@ -11638,7 +11724,7 @@ slug = "fabro" let state = create_github_token_app_state(Some("ghu_test"), Some(github.base_url())); assert_eq!(state.github_api_base_url, github.base_url()); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = fixtures::RUN_1; create_run_with_pull_request_record( &state, @@ -11890,7 +11976,7 @@ slug = "fabro" #[tokio::test] async fn get_run_state_exposes_pending_interviews() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = fixtures::RUN_1; create_durable_run_with_events(&state, run_id, &[ @@ -11942,7 +12028,7 @@ slug = "fabro" #[tokio::test] async fn get_run_state_includes_provenance_from_user_agent() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -11974,11 +12060,16 @@ slug = "fabro" ); assert_eq!(body["spec"]["provenance"]["client"]["name"], "fabro-cli"); assert_eq!(body["spec"]["provenance"]["client"]["version"], "1.2.3"); + assert_eq!(body["spec"]["provenance"]["subject"]["kind"], "user"); assert_eq!( body["spec"]["provenance"]["subject"]["auth_method"], - "disabled" + "dev_token" + ); + assert_eq!(body["spec"]["provenance"]["subject"]["login"], "dev"); + assert_eq!( + body["spec"]["provenance"]["subject"]["identity"]["issuer"], + "fabro:dev" ); - assert!(body["spec"]["provenance"]["subject"]["login"].is_null()); } #[tokio::test] @@ -12063,7 +12154,7 @@ slug = "fabro" #[tokio::test] async fn create_run_persists_manifest_and_definition_blobs_without_bundle_file() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let raw_manifest = serde_json::to_string_pretty(&minimal_manifest_json(MINIMAL_DOT)).unwrap(); @@ -12122,7 +12213,7 @@ slug = "fabro" #[tokio::test] async fn list_run_events_returns_paginated_json() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -12150,7 +12241,7 @@ slug = "fabro" #[tokio::test] async fn append_run_event_rejects_run_id_mismatch() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -12186,7 +12277,7 @@ slug = "fabro" #[tokio::test] async fn append_run_event_rejects_reserved_archive_event() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT).await; let req = Request::builder() @@ -12220,7 +12311,7 @@ slug = "fabro" #[tokio::test] async fn get_checkpoint_returns_null_initially() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); // Start a run let req = Request::builder() @@ -12248,7 +12339,7 @@ slug = "fabro" #[tokio::test] async fn write_and_read_run_blob_round_trip() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -12284,7 +12375,7 @@ slug = "fabro" #[tokio::test] async fn stage_artifacts_round_trip() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT).await; let stage_id = "code@2"; @@ -12324,7 +12415,7 @@ slug = "fabro" #[tokio::test] async fn create_run_persists_run_spec() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT) .await @@ -12345,7 +12436,7 @@ slug = "fabro" #[tokio::test] async fn stage_artifact_upload_rejects_invalid_filename() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT).await; @@ -12716,7 +12807,7 @@ slug = "fabro" #[tokio::test] async fn stage_artifacts_multipart_round_trip() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT).await; let stage_id = "code@2"; @@ -12782,7 +12873,7 @@ slug = "fabro" #[tokio::test] async fn stage_artifacts_multipart_requires_manifest_first() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_run(&app, MINIMAL_DOT).await; let boundary = "fabro-test-boundary"; @@ -12806,7 +12897,7 @@ slug = "fabro" #[tokio::test] async fn create_run_returns_submitted() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -12823,7 +12914,7 @@ slug = "fabro" #[tokio::test] async fn start_run_transitions_to_queued() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); // Create a run let req = Request::builder() @@ -12862,7 +12953,7 @@ slug = "fabro" #[tokio::test] async fn start_run_conflict_when_not_submitted() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); // Create a run let req = Request::builder() @@ -12896,7 +12987,7 @@ slug = "fabro" #[tokio::test] async fn cancel_run_succeeds() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_and_start_run(&app, MINIMAL_DOT) .await @@ -12937,7 +13028,7 @@ slug = "fabro" #[tokio::test] async fn get_graph_returns_svg() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); // Start a run let req = Request::builder() @@ -12998,7 +13089,7 @@ slug = "fabro" #[tokio::test] async fn get_graph_source_returns_dot() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -13184,7 +13275,7 @@ slug = "fabro" #[tokio::test] async fn list_runs_returns_started_run() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); // List should be empty initially let req = Request::builder() @@ -13235,7 +13326,7 @@ slug = "fabro" #[tokio::test] async fn archive_and_unarchive_updates_listing_visibility() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = fixtures::RUN_1; create_durable_run_with_events(&state, run_id, &[ @@ -13374,7 +13465,7 @@ slug = "fabro" #[tokio::test] async fn delete_run_removes_durable_run() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -13407,7 +13498,7 @@ slug = "fabro" #[tokio::test] async fn delete_active_run_requires_force() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -13448,7 +13539,7 @@ slug = "fabro" #[tokio::test] async fn delete_active_run_force_succeeds() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -13481,7 +13572,7 @@ slug = "fabro" #[tokio::test] async fn get_aggregate_billing_returns_zeros_initially() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("GET") @@ -13502,7 +13593,7 @@ slug = "fabro" #[tokio::test] async fn post_runs_returns_submitted_status() { let state = create_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("POST") @@ -13574,7 +13665,7 @@ level = "debug" manifest_run_defaults_from_toml(source), 5, ); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") @@ -13640,7 +13731,7 @@ level = "debug" #[tokio::test] async fn cancel_queued_run_succeeds() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_and_start_run(&app, MINIMAL_DOT) .await @@ -13704,7 +13795,7 @@ level = "debug" #[tokio::test] async fn cancel_run_overwrites_pending_pause_request() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -13734,7 +13825,7 @@ level = "debug" #[tokio::test] async fn pause_run_rejects_when_control_is_already_pending() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -13763,7 +13854,7 @@ level = "debug" #[tokio::test] async fn pause_run_sets_pending_control_on_board_response() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -13817,7 +13908,7 @@ level = "debug" #[tokio::test] async fn pause_run_immediately_pauses_blocked_run() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -13882,7 +13973,7 @@ level = "debug" #[tokio::test] async fn unpause_run_sets_pending_control() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -13911,7 +14002,7 @@ level = "debug" #[tokio::test] async fn unpause_run_returns_blocked_when_human_gate_is_still_unresolved() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -14146,7 +14237,7 @@ provider = "local" manifest_run_defaults_from_toml(source), |interviewer| fabro_workflow::handler::default_registry(interviewer, || None), ); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -14245,7 +14336,7 @@ provider = "local" std::thread::sleep(std::time::Duration::from_millis(200)); fabro_workflow::handler::default_registry(interviewer, || None) }); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); @@ -14279,7 +14370,7 @@ provider = "local" #[tokio::test] async fn queue_position_reported_for_queued_runs() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); // Create and start two runs (no scheduler, both stay queued) let first_run_id = create_and_start_run(&app, MINIMAL_DOT).await; @@ -14332,7 +14423,7 @@ provider = "local" #[tokio::test] async fn submit_answer_to_queued_run_returns_conflict() { let state = create_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("POST") @@ -14377,7 +14468,7 @@ provider = "local" #[tokio::test] async fn demo_boards_runs_returns_run_list_items() { let state = create_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("GET") .uri(api("/boards/runs")) @@ -14403,7 +14494,7 @@ provider = "local" #[tokio::test] async fn demo_get_run_returns_run_summary_shape() { let state = create_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let run_id = RunId::with_timestamp( "2026-03-06T14:30:00Z" .parse() @@ -14435,7 +14526,7 @@ provider = "local" #[tokio::test] async fn demo_get_run_returns_404_for_unknown_run() { let state = create_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = crate::test_support::build_test_router(state); let req = Request::builder() .method("GET") .uri(api("/runs/nonexistent-run-id")) @@ -14449,7 +14540,7 @@ provider = "local" #[tokio::test] async fn boards_runs_returns_run_list_items_with_board_columns() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_and_start_run(&app, MINIMAL_DOT).await; // Set run to running so it appears on the board @@ -14489,7 +14580,7 @@ provider = "local" #[tokio::test] async fn boards_runs_excludes_removing_status() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = fixtures::RUN_1; // A run in Removing status should not appear on the board @@ -14520,7 +14611,7 @@ provider = "local" #[tokio::test] async fn get_run_exposes_canonical_operator_statuses() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let succeeded_id = fixtures::RUN_1; let removing_id = fixtures::RUN_2; @@ -14592,7 +14683,7 @@ provider = "local" #[tokio::test] async fn boards_runs_maps_statuses_to_columns() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let paused_id = fixtures::RUN_1; let succeeded_id = fixtures::RUN_2; @@ -14743,7 +14834,7 @@ provider = "local" #[tokio::test] async fn boards_runs_includes_live_board_metadata_from_run_state() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = create_and_start_run(&app, MINIMAL_DOT) .await .parse::() @@ -14811,7 +14902,7 @@ provider = "local" #[tokio::test] async fn boards_runs_page_limit_preserves_metadata_for_paged_items() { let state = create_app_state(); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = crate::test_support::build_test_router(Arc::clone(&state)); let first_run_id = create_and_start_run(&app, MINIMAL_DOT) .await diff --git a/lib/crates/fabro-server/src/test_support.rs b/lib/crates/fabro-server/src/test_support.rs new file mode 100644 index 000000000..f4c4395fd --- /dev/null +++ b/lib/crates/fabro-server/src/test_support.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use axum::extract::Request; +use axum::http::{HeaderValue, header}; +use axum::middleware::Next; +use axum::response::Response; +use axum::{Router, middleware}; +use fabro_types::settings::ServerAuthMethod; + +use crate::auth; +use crate::ip_allowlist::IpAllowlistConfig; +use crate::jwt_auth::{AuthMode, ConfiguredAuth}; +use crate::server::{self, AppState, RouterOptions}; + +pub const TEST_DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; +pub const TEST_SESSION_SECRET: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +#[doc(hidden)] +#[must_use] +pub fn test_auth_mode() -> AuthMode { + AuthMode::Enabled(ConfiguredAuth { + methods: vec![ServerAuthMethod::DevToken, ServerAuthMethod::Github], + dev_token: Some(TEST_DEV_TOKEN.to_string()), + jwt_key: Some( + auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes()) + .expect("test jwt signing key should derive"), + ), + jwt_issuer: Some("https://fabro.test".to_string()), + }) +} + +#[doc(hidden)] +pub fn build_test_router(state: Arc) -> Router { + with_test_user(server::build_router(state, test_auth_mode())) +} + +#[doc(hidden)] +pub fn build_test_router_with_options( + state: Arc, + ip_allowlist_config: Arc, + options: RouterOptions, +) -> Router { + with_test_user(server::build_router_with_options( + state, + &test_auth_mode(), + ip_allowlist_config, + options, + )) +} + +#[doc(hidden)] +pub fn with_test_user(router: Router) -> Router { + router.layer(middleware::from_fn(inject_test_user_bearer)) +} + +async fn inject_test_user_bearer(mut req: Request, next: Next) -> Response { + if req.uri().path().starts_with("/api/") && !req.headers().contains_key(header::AUTHORIZATION) { + req.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_static( + "Bearer fabro_dev_abababababababababababababababababababababababababababababababab", + ), + ); + } + next.run(req).await +} diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 0fc1e5287..9ca33454d 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -10,7 +10,7 @@ use cookie::{Cookie, CookieJar, Key, SameSite}; use fabro_redact::DisplaySafeUrl; use fabro_static::EnvVars; use fabro_types::settings::ServerAuthMethod; -use fabro_types::{IdpIdentity, RunAuthMethod}; +use fabro_types::{AuthMethod, IdpIdentity}; use fabro_util::dev_token::validate_dev_token_format; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; use serde::{Deserialize, Serialize}; @@ -18,9 +18,8 @@ use serde_json::json; use tracing::{debug, error, info, warn}; use crate::auth::{GithubEndpoints, browser_shell}; -use crate::jwt_auth::{ - AuthMode, AuthenticatedService, AuthenticatedSubject, auth_method_name, dev_token_matches, -}; +use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches}; +use crate::principal_middleware::{RequestAuth, RequiredUser, require_authenticated_user}; use crate::server::AppState; pub const SESSION_COOKIE_NAME: &str = "__fabro_session"; @@ -31,7 +30,7 @@ const OAUTH_STATE_TTL_MINUTES: i64 = 30; pub struct SessionCookie { pub v: u8, pub login: String, - pub auth_method: RunAuthMethod, + pub auth_method: AuthMethod, pub identity: Option, pub name: String, pub email: String, @@ -255,32 +254,28 @@ fn callback_error_redirect( } fn auth_methods_from_mode(auth_mode: &AuthMode) -> Vec { - match auth_mode { - AuthMode::Enabled(config) => config - .methods - .iter() - .map(|method| auth_method_name(*method).to_string()) - .collect(), - AuthMode::Disabled => Vec::new(), - } + let AuthMode::Enabled(config) = auth_mode; + config + .methods + .iter() + .map(|method| auth_method_name(*method).to_string()) + .collect() } fn auth_method_enabled(auth_mode: &AuthMode, method: ServerAuthMethod) -> bool { - matches!(auth_mode, AuthMode::Enabled(config) if config.methods.contains(&method)) + let AuthMode::Enabled(config) = auth_mode; + config.methods.contains(&method) } fn dev_token_from_mode(auth_mode: &AuthMode) -> Option { - match auth_mode { - AuthMode::Enabled(config) => config.dev_token.clone(), - AuthMode::Disabled => None, - } + let AuthMode::Enabled(config) = auth_mode; + config.dev_token.clone() } -fn session_provider(auth_method: RunAuthMethod) -> &'static str { +fn session_provider(auth_method: AuthMethod) -> &'static str { match auth_method { - RunAuthMethod::Disabled => "disabled", - RunAuthMethod::DevToken => "dev-token", - RunAuthMethod::Github => "github", + AuthMethod::DevToken => "dev-token", + AuthMethod::Github => "github", } } @@ -332,7 +327,7 @@ async fn login_dev_token( let session = SessionCookie { v: 2, login: "dev".to_string(), - auth_method: RunAuthMethod::DevToken, + auth_method: AuthMethod::DevToken, identity: Some(IdpIdentity::new("fabro:dev", "dev").expect("non-empty dev identity")), name: "Development User".to_string(), email: "dev@localhost".to_string(), @@ -724,7 +719,7 @@ async fn callback_github( let session = SessionCookie { v: 2, login: profile.login.clone(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, identity: Some( IdpIdentity::new("https://github.com", profile.id.to_string()) .expect("GitHub profile id should produce a valid identity"), @@ -784,42 +779,38 @@ async fn logout(State(state): State>) -> Response { response } -async fn auth_me(subject: AuthenticatedSubject, headers: HeaderMap) -> Response { - if subject.login.is_none() { - warn!( - has_cookie = headers.get(header::COOKIE).is_some(), - "Auth check failed: authenticated subject missing" - ); - return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); - } - +async fn auth_me(RequestAuth(auth_slot): RequestAuth, headers: HeaderMap) -> Response { + let authenticated = match require_authenticated_user(&auth_slot) { + Ok(authenticated) => authenticated, + Err(err) => { + warn!( + has_cookie = headers.get(header::COOKIE).is_some(), + "Auth check failed: authenticated subject missing" + ); + return err.into_response(); + } + }; let demo_mode = parse_cookie_header(&headers) .get("fabro-demo") .is_some_and(|cookie| cookie.value() == "1"); Json(AuthMeResponse { user: SessionUser { - login: subject.login.expect("checked above"), - name: subject.name, - email: subject.email, - idp_issuer: subject - .identity - .as_ref() - .map(|identity| identity.issuer().to_string()), - idp_subject: subject - .identity - .as_ref() - .map(|identity| identity.subject().to_string()), - avatar_url: subject.avatar_url, - user_url: subject.user_url, + login: authenticated.principal.login.clone(), + name: authenticated.profile.name, + email: authenticated.profile.email, + idp_issuer: Some(authenticated.principal.identity.issuer().to_string()), + idp_subject: Some(authenticated.principal.identity.subject().to_string()), + avatar_url: authenticated.profile.avatar_url, + user_url: authenticated.profile.user_url, }, - provider: session_provider(subject.auth_method).to_string(), + provider: session_provider(authenticated.principal.auth_method).to_string(), demo_mode, }) .into_response() } async fn toggle_demo( - _auth: AuthenticatedService, + _auth: RequiredUser, State(state): State>, Json(payload): Json, ) -> Response { @@ -848,7 +839,7 @@ mod tests { use axum_extra::extract::cookie::Key; use fabro_config::{RunLayer, ServerSettingsBuilder}; use fabro_types::settings::server::ServerAuthMethod; - use fabro_types::{IdpIdentity, RunAuthMethod}; + use fabro_types::{AuthMethod, IdpIdentity}; use serde_json::json; use tower::ServiceExt; @@ -943,12 +934,19 @@ client_id = "github-client-id" RunLayer::default(), Some("web-auth-test-key-material-0123456789"), ); - let middleware_state = state.clone(); + let translation_state = state.clone(); + let principal_state = state.clone(); axum::Router::new() .nest("/auth", routes()) - .nest("/api/v1", api_routes()) + .nest( + "/api/v1", + api_routes().layer(axum::middleware::from_fn_with_state( + principal_state, + crate::principal_middleware::principal_middleware, + )), + ) .layer(axum::middleware::from_fn_with_state( - middleware_state, + translation_state, crate::auth::auth_translation_middleware, )) .layer(Extension(Arc::new(GithubEndpoints::production_defaults()))) @@ -1011,7 +1009,7 @@ client_id = "github-client-id" axum::http::HeaderValue::from_str(&session_cookie).unwrap(), ); let session = read_private_session(&cookie_headers, &key).expect("session should decode"); - assert_eq!(session.auth_method, RunAuthMethod::DevToken); + assert_eq!(session.auth_method, AuthMethod::DevToken); assert_eq!(session.v, 2); assert_eq!( session.identity, @@ -1051,7 +1049,7 @@ client_id = "github-client-id" email: "octocat@example.com".to_string(), avatar_url: String::new(), user_url: String::new(), - auth_method: RunAuthMethod::Github, + auth_method: AuthMethod::Github, }, chrono::Duration::minutes(10), ); diff --git a/lib/crates/fabro-server/src/worker_token.rs b/lib/crates/fabro-server/src/worker_token.rs index a5c94820e..ccf3fc048 100644 --- a/lib/crates/fabro-server/src/worker_token.rs +++ b/lib/crates/fabro-server/src/worker_token.rs @@ -1,19 +1,15 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use axum::extract::{FromRequestParts, Path}; use axum::http::StatusCode; -use axum::http::request::Parts; -use axum::response::{IntoResponse, Response}; -use fabro_types::{CommandOutputStream, RunBlobId, RunId, StageId}; +use fabro_types::RunId; +use jsonwebtoken::errors::ErrorKind; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use tracing::{debug, warn}; +use tracing::warn; use uuid::Uuid; use crate::ApiError; -use crate::auth::{self, KeyDeriveError}; -use crate::jwt_auth::{authenticate_service_parts, bearer_token}; -use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path}; +use crate::auth::{self, JwtError, KeyDeriveError}; pub(crate) const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; pub(crate) const WORKER_TOKEN_SCOPE: &str = "run:worker"; @@ -85,168 +81,43 @@ pub(crate) fn issue_worker_token( }) } -pub(crate) fn authorize_worker_token( - parts: &Parts, - run_id: &RunId, - keys: &WorkerTokenKeys, -) -> Result { - let Some(Ok(token)) = bearer_token(parts) else { - return Ok(false); - }; - - let claims = - match jsonwebtoken::decode::(token, &keys.decoding, &keys.validation) { - Ok(token_data) => token_data.claims, - Err(_) => return Ok(false), - }; +pub(crate) fn decode_worker_token(token: &str, keys: &WorkerTokenKeys) -> Result { + let claims = jsonwebtoken::decode::(token, &keys.decoding, &keys.validation) + .map_err(|err| match err.kind() { + ErrorKind::ExpiredSignature => JwtError::AccessTokenExpired, + _ => JwtError::AccessTokenInvalid, + })? + .claims; if claims.scope != WORKER_TOKEN_SCOPE { warn!( target: "worker_auth", - run_id = %run_id, jti = %claims.jti, reason = "wrong_scope", "worker token rejected" ); - return Err(ApiError::forbidden()); - } - if claims.run_id != run_id.to_string() { - warn!( - target: "worker_auth", - run_id = %run_id, - token_run_id = %claims.run_id, - jti = %claims.jti, - reason = "run_id_mismatch", - "worker token rejected" - ); - return Err(ApiError::forbidden()); + return Err(JwtError::AccessTokenInvalid); } - debug!( - target: "worker_auth", - run_id = %run_id, - jti = %claims.jti, - "worker token accepted" - ); - Ok(true) -} - -fn authorize_run_scoped(parts: &Parts, state: &AppState, run_id: &RunId) -> Result<(), ApiError> { - if authorize_worker_token(parts, run_id, state.worker_token_keys())? { - return Ok(()); - } - authenticate_service_parts(parts) -} - -pub(crate) struct AuthorizeRunScoped(pub(crate) RunId); - -impl FromRequestParts> for AuthorizeRunScoped { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let Path(id): Path = Path::from_request_parts(parts, state) - .await - .map_err(IntoResponse::into_response)?; - let run_id = parse_run_id_path(&id)?; - authorize_run_scoped(parts, state.as_ref(), &run_id) - .map_err(IntoResponse::into_response)?; - Ok(Self(run_id)) - } -} - -pub(crate) struct AuthorizeRunBlob(pub(crate) RunId, pub(crate) RunBlobId); - -impl FromRequestParts> for AuthorizeRunBlob { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state) - .await - .map_err(IntoResponse::into_response)?; - let run_id = parse_run_id_path(&id)?; - let blob_id = parse_blob_id_path(&blob_id)?; - authorize_run_scoped(parts, state.as_ref(), &run_id) - .map_err(IntoResponse::into_response)?; - Ok(Self(run_id, blob_id)) - } -} - -pub(crate) struct AuthorizeStageArtifact(pub(crate) RunId, pub(crate) StageId); - -impl FromRequestParts> for AuthorizeStageArtifact { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let Path((id, stage_id)): Path<(String, String)> = Path::from_request_parts(parts, state) - .await - .map_err(IntoResponse::into_response)?; - let run_id = parse_run_id_path(&id)?; - let stage_id = parse_stage_id_path(&stage_id)?; - authorize_run_scoped(parts, state.as_ref(), &run_id) - .map_err(IntoResponse::into_response)?; - Ok(Self(run_id, stage_id)) - } -} - -pub(crate) struct AuthorizeCommandLog( - pub(crate) RunId, - pub(crate) StageId, - pub(crate) CommandOutputStream, -); - -impl FromRequestParts> for AuthorizeCommandLog { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let Path((id, stage_id, stream)): Path<(String, String, String)> = - Path::from_request_parts(parts, state) - .await - .map_err(IntoResponse::into_response)?; - let run_id = parse_run_id_path(&id)?; - let stage_id = parse_stage_id_path(&stage_id)?; - let stream = stream - .parse::() - .map_err(|_| ApiError::bad_request("Invalid command log stream.").into_response())?; - authorize_run_scoped(parts, state.as_ref(), &run_id) - .map_err(IntoResponse::into_response)?; - Ok(Self(run_id, stage_id, stream)) - } + claims + .run_id + .parse() + .map_err(|_| JwtError::AccessTokenInvalid) } #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex as StdMutex}; - - use axum::http::header; - use axum::http::request::Parts; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use chrono::Duration as ChronoDuration; use jsonwebtoken::{Algorithm, Header, decode}; use serde_json::json; - use tracing::field::{Field, Visit}; - use tracing::{Event, Subscriber, subscriber}; - use tracing_subscriber::layer::{Context, SubscriberExt}; - use tracing_subscriber::{Layer, Registry}; use uuid::Uuid; use super::{ WORKER_TOKEN_ISSUER, WORKER_TOKEN_SCOPE, WorkerTokenClaims, WorkerTokenKeys, - authorize_worker_token, issue_worker_token, + decode_worker_token, issue_worker_token, }; - use crate::auth; + use crate::auth::{self, JwtError}; const TEST_SECRET: &[u8] = b"0123456789abcdef0123456789abcdef"; const OTHER_SECRET: &[u8] = b"fedcba9876543210fedcba9876543210"; @@ -259,23 +130,6 @@ mod tests { "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap() } - fn other_run_id() -> fabro_types::RunId { - "01ARZ3NDEKTSV4RRFFQ69G5FAW".parse().unwrap() - } - - fn request_parts(authorization: Option<&str>) -> Parts { - let mut builder = axum::http::Request::builder(); - if let Some(authorization) = authorization { - builder = builder.header(header::AUTHORIZATION, authorization); - } - let (parts, ()) = builder.body(()).unwrap().into_parts(); - parts - } - - fn bearer_parts(token: &str) -> Parts { - request_parts(Some(&format!("Bearer {token}"))) - } - fn wrong_scope_token(keys: &WorkerTokenKeys, run_id: &fabro_types::RunId) -> String { let claims = WorkerTokenClaims { iss: WORKER_TOKEN_ISSUER.to_string(), @@ -324,72 +178,6 @@ mod tests { format!("{header}.{payload}.") } - fn issue_user_jwt() -> String { - let subject = auth::JwtSubject { - identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(), - login: "octocat".to_string(), - name: "The Octocat".to_string(), - email: "octocat@example.com".to_string(), - avatar_url: "https://example.com/octocat.png".to_string(), - user_url: "https://github.com/octocat".to_string(), - auth_method: fabro_types::RunAuthMethod::Github, - }; - let key = auth::derive_jwt_key(TEST_SECRET).expect("user jwt key should derive"); - auth::issue( - &key, - "https://fabro.example", - &subject, - ChronoDuration::minutes(10), - ) - } - - #[derive(Debug)] - struct LogCapture { - target: String, - fields: Vec<(String, String)>, - } - - #[derive(Default)] - struct LogCaptureVisitor { - fields: Vec<(String, String)>, - } - - impl Visit for LogCaptureVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - self.fields - .push((field.name().to_string(), format!("{value:?}"))); - } - } - - struct LogCaptureLayer { - events: Arc>>, - } - - impl Layer for LogCaptureLayer { - fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { - if event.metadata().target() != "worker_auth" { - return; - } - - let mut visitor = LogCaptureVisitor::default(); - event.record(&mut visitor); - self.events.lock().unwrap().push(LogCapture { - target: event.metadata().target().to_string(), - fields: visitor.fields, - }); - } - } - - fn capture_logs(f: impl FnOnce() -> T) -> (T, Arc>>) { - let events = Arc::new(StdMutex::new(Vec::::new())); - let layer = LogCaptureLayer { - events: Arc::clone(&events), - }; - let subscriber = Registry::default().with(layer); - let result = subscriber::with_default(subscriber, f); - (result, events) - } - #[test] fn issue_worker_token_round_trips_claims() { let run_id = run_id(); @@ -449,179 +237,60 @@ mod tests { } #[test] - fn authorize_worker_token_accepts_matching_run_id() { + fn decode_worker_token_returns_run_id() { let run_id = run_id(); let keys = keys(TEST_SECRET); let token = issue_worker_token(&keys, &run_id).expect("worker token should issue"); - let parts = bearer_parts(&token); - assert!(authorize_worker_token(&parts, &run_id, &keys).unwrap()); + assert_eq!(decode_worker_token(&token, &keys).unwrap(), run_id); } #[test] - fn authorize_worker_token_rejects_cross_run_reuse() { - let run_id = run_id(); - let other_run_id = other_run_id(); - let keys = keys(TEST_SECRET); - let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue"); - let parts = bearer_parts(&token); - - let err = authorize_worker_token(&parts, &run_id, &keys) - .expect_err("mismatched run should reject"); - assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); - } - - #[test] - fn authorize_worker_token_rejects_wrong_scope() { + fn decode_worker_token_rejects_wrong_scope() { let run_id = run_id(); let keys = keys(TEST_SECRET); let token = wrong_scope_token(&keys, &run_id); - let parts = bearer_parts(&token); - let err = - authorize_worker_token(&parts, &run_id, &keys).expect_err("wrong scope should reject"); - assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); + assert_eq!( + decode_worker_token(&token, &keys).expect_err("wrong scope should reject"), + JwtError::AccessTokenInvalid, + ); } #[test] - fn authorize_worker_token_falls_through_without_header() { - let run_id = run_id(); - let keys = keys(TEST_SECRET); - let parts = request_parts(None); - - let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); - - assert!(!result.unwrap()); - assert!(captured.lock().unwrap().is_empty()); - } - - #[test] - fn authorize_worker_token_falls_through_for_user_jwt_without_worker_logs() { - let run_id = run_id(); - let keys = keys(TEST_SECRET); - let token = issue_user_jwt(); - let parts = bearer_parts(&token); - - let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); - - assert!(!result.unwrap()); - assert!(captured.lock().unwrap().is_empty()); - } - - #[test] - fn authorize_worker_token_falls_through_for_expired_token_without_worker_logs() { + fn decode_worker_token_rejects_expired_tokens() { let run_id = run_id(); let keys = keys(TEST_SECRET); let token = expired_worker_token(&keys, &run_id); - let parts = bearer_parts(&token); - let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); - - assert!(!result.unwrap()); - assert!(captured.lock().unwrap().is_empty()); + assert_eq!( + decode_worker_token(&token, &keys).expect_err("expired token should reject"), + JwtError::AccessTokenExpired, + ); } #[test] - fn authorize_worker_token_falls_through_for_bad_signature_without_worker_logs() { + fn decode_worker_token_rejects_bad_signature() { let run_id = run_id(); let signer = keys(OTHER_SECRET); let verifier = keys(TEST_SECRET); let token = issue_worker_token(&signer, &run_id).expect("worker token should issue"); - let parts = bearer_parts(&token); - let (result, captured) = - capture_logs(|| authorize_worker_token(&parts, &run_id, &verifier)); - - assert!(!result.unwrap()); - assert!(captured.lock().unwrap().is_empty()); + assert_eq!( + decode_worker_token(&token, &verifier).expect_err("bad signature should reject"), + JwtError::AccessTokenInvalid, + ); } #[test] - fn authorize_worker_token_falls_through_for_alg_none_without_worker_logs() { + fn decode_worker_token_rejects_alg_none() { let run_id = run_id(); let keys = keys(TEST_SECRET); let token = alg_none_token(&run_id); - let parts = bearer_parts(&token); - let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); - - assert!(!result.unwrap()); - assert!(captured.lock().unwrap().is_empty()); - } - - #[test] - fn authorize_worker_token_logs_acceptance() { - let run_id = run_id(); - let keys = keys(TEST_SECRET); - let token = issue_worker_token(&keys, &run_id).expect("worker token should issue"); - let parts = bearer_parts(&token); - - let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); - - assert!(result.unwrap()); - let events = captured.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].target, "worker_auth"); - assert!(events[0] - .fields - .iter() - .any(|(field, value)| field == "message" && value.contains("worker token accepted"))); - assert!( - events[0] - .fields - .iter() - .any(|(field, value)| field == "run_id" && value.contains(&run_id.to_string())) - ); - assert!( - events[0] - .fields - .iter() - .any(|(field, value)| field == "jti" && !value.is_empty()) - ); - } - - #[test] - fn authorize_worker_token_logs_run_id_mismatch() { - let run_id = run_id(); - let other_run_id = other_run_id(); - let keys = keys(TEST_SECRET); - let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue"); - let parts = bearer_parts(&token); - - let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); - - let err = result.expect_err("mismatched run should reject"); - assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); - let events = captured.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].target, "worker_auth"); - assert!( - events[0] - .fields - .iter() - .any(|(field, value)| field == "reason" && value.contains("run_id_mismatch")) - ); - } - - #[test] - fn authorize_worker_token_logs_wrong_scope() { - let run_id = run_id(); - let keys = keys(TEST_SECRET); - let token = wrong_scope_token(&keys, &run_id); - let parts = bearer_parts(&token); - - let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); - - let err = result.expect_err("wrong scope should reject"); - assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); - let events = captured.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].target, "worker_auth"); - assert!( - events[0] - .fields - .iter() - .any(|(field, value)| field == "reason" && value.contains("wrong_scope")) + assert_eq!( + decode_worker_token(&token, &keys).expect_err("alg none should reject"), + JwtError::AccessTokenInvalid, ); } } diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index d837bc493..0b938e754 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -9,7 +9,7 @@ use fabro_config::ServerSettingsBuilder; use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; use fabro_server::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; use fabro_server::server::{ - RouterOptions, build_router, build_router_with_options, create_app_state, + RouterOptions, build_router, create_app_state, create_app_state_with_runtime_settings_and_options, }; use tower::ServiceExt; @@ -47,7 +47,7 @@ fn spa_fixture_root() -> PathBuf { #[tokio::test] async fn old_unversioned_routes_return_404() { - let app = build_router(create_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(create_app_state()); let cases = [(Method::POST, "/completions")]; @@ -64,9 +64,8 @@ async fn old_unversioned_routes_return_404() { #[tokio::test] async fn root_and_health_stay_at_root() { - let app = build_router_with_options( + let app = fabro_server::test_support::build_test_router_with_options( create_app_state(), - &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { static_asset_root: Some(spa_fixture_root()), @@ -99,7 +98,7 @@ async fn root_and_health_stay_at_root() { #[tokio::test] async fn install_routes_are_absent_in_normal_mode() { - let app = build_router(create_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(create_app_state()); let response = app .oneshot( @@ -117,7 +116,7 @@ async fn install_routes_are_absent_in_normal_mode() { #[tokio::test] async fn moved_routes_not_at_root_of_api_prefix() { - let app = build_router(create_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(create_app_state()); for path in ["/api/v1/health", "/api/v1/"] { let req = Request::builder() @@ -132,7 +131,7 @@ async fn moved_routes_not_at_root_of_api_prefix() { #[tokio::test] async fn source_maps_are_not_served() { - let app = build_router(create_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(create_app_state()); let request = Request::builder() .method("GET") @@ -151,9 +150,10 @@ async fn source_maps_are_not_served() { #[tokio::test] async fn web_enabled_serves_web_only_routes() { - let app = build_router_with_options( + let auth_mode = dev_token_enabled_auth_mode(); + let app = fabro_server::server::build_router_with_options( create_app_state(), - &AuthMode::Disabled, + &auth_mode, Arc::new(IpAllowlistConfig::default()), RouterOptions { static_asset_root: Some(spa_fixture_root()), @@ -228,6 +228,7 @@ async fn web_enabled_serves_web_only_routes() { let demo_toggle_request = Request::builder() .method("POST") .uri("/api/v1/demo/toggle") + .header("authorization", format!("Bearer {DEV_TOKEN}")) .header("content-type", "application/json") .body(Body::from(r#"{"enabled":true}"#)) .unwrap(); @@ -315,9 +316,8 @@ async fn toggle_demo_allows_authenticated_requests() { #[tokio::test] async fn security_headers_are_applied_to_all_responses() { - let app = build_router_with_options( + let app = fabro_server::test_support::build_test_router_with_options( create_app_state(), - &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { static_asset_root: Some(spa_fixture_root()), @@ -450,13 +450,12 @@ _version = 1 enabled = false ", ); - let app = build_router_with_options( + let app = fabro_server::test_support::build_test_router_with_options( create_app_state_with_runtime_settings_and_options( settings.server_settings, settings.manifest_run_defaults, 5, ), - &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { web_enabled: false, @@ -515,13 +514,12 @@ _version = 1 enabled = false ", ); - let app = build_router_with_options( + let app = fabro_server::test_support::build_test_router_with_options( create_app_state_with_runtime_settings_and_options( settings.server_settings, settings.manifest_run_defaults, 5, ), - &AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), RouterOptions { web_enabled: false, @@ -543,9 +541,8 @@ enabled = false #[tokio::test] async fn allowlist_blocks_non_allowlisted_api_requests() { - let app = build_router_with_options( + let app = fabro_server::test_support::build_test_router_with_options( create_app_state(), - &AuthMode::Disabled, Arc::new(IpAllowlistConfig { allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), trusted_proxy_count: 0, @@ -566,9 +563,8 @@ async fn allowlist_blocks_non_allowlisted_api_requests() { #[tokio::test] async fn allowlist_exempts_health_checks() { - let app = build_router_with_options( + let app = fabro_server::test_support::build_test_router_with_options( create_app_state(), - &AuthMode::Disabled, Arc::new(IpAllowlistConfig { allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), trusted_proxy_count: 0, diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index 2be3dea21..4fce42837 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -12,8 +12,7 @@ use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::{build_router, create_app_state_with_store}; +use fabro_server::server::create_app_state_with_store; use fabro_store::{ArtifactStore, Database}; use fabro_types::RunId; use fabro_workflow::event as workflow_event; @@ -83,7 +82,7 @@ async fn append_completed_run_with_final_patch( #[tokio::test] async fn invalid_run_id_returns_400() { - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); let req = Request::builder() .method("GET") .uri(files_url("not-a-ulid")) @@ -100,7 +99,7 @@ async fn invalid_run_id_returns_400() { #[tokio::test] async fn unknown_run_returns_404() { - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); // Valid ULID format but not a run we've created. let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; let req = Request::builder() @@ -119,7 +118,7 @@ async fn unknown_run_returns_404() { #[tokio::test] async fn malformed_from_sha_query_returns_400() { - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; let req = Request::builder() .method("GET") @@ -143,7 +142,7 @@ async fn malformed_from_sha_query_returns_400() { #[tokio::test] async fn non_default_from_sha_returns_400_even_when_hex() { - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; // Well-formed hex SHA but v1 reserves the parameter for a future // version; any non-default value must be rejected. @@ -166,7 +165,7 @@ async fn non_default_from_sha_returns_400_even_when_hex() { #[tokio::test] async fn malformed_to_sha_returns_400() { - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; let req = Request::builder() .method("GET") @@ -187,7 +186,7 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() { // A run that has been created but not started has no base_sha or // sandbox record, so the handler returns an empty envelope. The UI // maps that to R4(a). - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); let manifest = minimal_manifest_json(MINIMAL_DOT); let create_req = Request::builder() .method("POST") @@ -232,7 +231,7 @@ async fn degraded_run_returns_file_diff_shape_without_meta_patch() { Arc::clone(&store), artifact_store, ); - let app = build_router(state, AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(state); let run_id = RunId::new(); let patch = "\ diff --git a/src/lib.rs b/src/lib.rs @@ -284,7 +283,7 @@ diff --git a/.env.production b/.env.production async fn demo_mode_returns_fixture_without_touching_store() { // R34: demo handler must return the illustrative fixture with no // cross-contamination with real run state. - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); let arbitrary = "not-even-a-valid-ulid-for-run"; let req = Request::builder() @@ -314,7 +313,7 @@ async fn response_envelope_matches_openapi_paginated_run_file_list_shape() { // Sanity check that the happy-path envelope shape matches what the // OpenAPI spec + regenerated TS client expect. Uses demo mode so the // test stays deterministic without running a sandbox. - let app = build_router(test_app_state(), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(test_app_state()); let req = Request::builder() .method("GET") .uri(files_url("whatever")) diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index f77a233d2..08909fe46 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -1,7 +1,5 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::build_router; use tower::ServiceExt; use crate::helpers::{ @@ -40,7 +38,8 @@ slug = "fabro-app" storage_dir.path().display() )); - let app = build_router(test_app_state_with_options(settings, 5), AuthMode::Disabled); + let app = + fabro_server::test_support::build_test_router(test_app_state_with_options(settings, 5)); let mut manifest = minimal_manifest_json(MINIMAL_DOT); manifest["configs"] = serde_json::json!([{ "type": "user", diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index 3afea1b87..d68b81b2b 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -1,7 +1,6 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::{build_router, create_app_state_with_runtime_settings_and_options}; +use fabro_server::server::create_app_state_with_runtime_settings_and_options; use tower::ServiceExt; use crate::helpers::{response_json, settings_from_toml}; @@ -32,13 +31,12 @@ allowed_usernames = ["alice"] client_id = "Iv1.abcdef" "#, ); - let app = build_router( + let app = fabro_server::test_support::build_test_router( create_app_state_with_runtime_settings_and_options( settings.server_settings, settings.manifest_run_defaults, 5, ), - AuthMode::Disabled, ); let request = Request::builder() diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index 079e0d83d..d8035983b 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -117,10 +117,8 @@ async fn load_questions(app: &axum::Router, run_id: &str) -> serde_json::Value { async fn get_system_info_returns_runtime_fields() { let (_temp, settings, expected_storage_dir) = temp_storage_settings(); let configured_server_url = settings.server_settings.server.web.url.as_source(); - let app = fabro_server::server::build_router( - test_app_state_with_options(settings, 5), - fabro_server::jwt_auth::AuthMode::Disabled, - ); + let app = + fabro_server::test_support::build_test_router(test_app_state_with_options(settings, 5)); let request = Request::builder() .method("GET") diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index 733211846..88ff179b1 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -236,7 +236,7 @@ methods = ["dev-token"] #[tokio::test] async fn tcp_ip_allowlist_uses_connect_info() { let addr = start_tcp_server( - AuthMode::Disabled, + fabro_server::test_support::test_auth_mode(), Arc::new(IpAllowlistConfig { allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), trusted_proxy_count: 0, diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 6ee369d94..bbeeea030 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -5,12 +5,11 @@ use std::time::Duration; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use fabro_config::{LocalSandboxLayer, RunLayer, RunSandboxLayer, ServerSettingsBuilder}; -use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{ - AppState, build_router, create_app_state, - create_app_state_with_runtime_settings_and_env_lookup, + AppState, create_app_state, create_app_state_with_runtime_settings_and_env_lookup, create_app_state_with_runtime_settings_and_options_and_registry_factory, spawn_scheduler, }; +use fabro_server::test_support::build_test_router; use fabro_test::{ assert_axum_status, assert_reqwest_status, expect_axum_json, expect_axum_status, expect_axum_status_in, expect_axum_text, @@ -110,7 +109,7 @@ pub(crate) fn test_settings() -> TestAppSettings { pub(crate) fn test_app_with_scheduler(state: Arc) -> axum::Router { spawn_scheduler(Arc::clone(&state)); - build_router(state, AuthMode::Disabled) + build_test_router(state) } pub(crate) fn test_app_with_no_providers() -> axum::Router { @@ -121,7 +120,7 @@ pub(crate) fn test_app_with_no_providers() -> axum::Router { 5, |_| None, ); - build_router(state, AuthMode::Disabled) + build_test_router(state) } pub(crate) fn test_app_with_mock_anthropic(mock_base_url: &str) -> axum::Router { @@ -137,7 +136,7 @@ pub(crate) fn test_app_with_mock_anthropic(mock_base_url: &str) -> axum::Router _ => None, }, ); - build_router(state, AuthMode::Disabled) + build_test_router(state) } pub(crate) fn api(path: &str) -> String { diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index db4fc3ddd..74781e914 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -11,10 +11,7 @@ use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use fabro_server::install::{InstallAppState, build_install_router}; -use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::{ - build_router, create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env, -}; +use fabro_server::server::create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env; use serde_yaml::Value; use tower::ServiceExt; @@ -79,7 +76,7 @@ fn request_for(method: &Method, uri: &str) -> Request { #[tokio::test] async fn all_spec_routes_are_routable() { let spec = load_spec(); - let normal_app = build_router(test_app_state(), AuthMode::Disabled); + let normal_app = fabro_server::test_support::build_test_router(test_app_state()); let install_app = build_install_router(InstallAppState::for_test("test-install-token")); let paths = spec @@ -148,7 +145,7 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() { async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() { let secret = "test-webhook-secret".to_string(); let settings = test_settings(); - let app = build_router( + let app = fabro_server::test_support::build_test_router( create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env( settings.server_settings, settings.manifest_run_defaults, @@ -156,7 +153,6 @@ async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() |_| None, &std::collections::HashMap::from([("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret)]), ), - AuthMode::Disabled, ); let response = app @@ -174,7 +170,7 @@ async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() #[tokio::test] async fn install_and_normal_routes_stay_isolated() { let spec = load_spec(); - let normal_app = build_router(test_app_state(), AuthMode::Disabled); + let normal_app = fabro_server::test_support::build_test_router(test_app_state()); let install_app = build_install_router(InstallAppState::for_test("test-install-token")); let paths = spec diff --git a/lib/crates/fabro-server/tests/it/pagination.rs b/lib/crates/fabro-server/tests/it/pagination.rs index 9ef9192ae..64174fd78 100644 --- a/lib/crates/fabro-server/tests/it/pagination.rs +++ b/lib/crates/fabro-server/tests/it/pagination.rs @@ -7,8 +7,6 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; -use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::build_router; use tower::ServiceExt; use super::helpers::{response_json, test_app_state}; @@ -75,7 +73,7 @@ const ENDPOINTS: &[PaginatedEndpoint] = &[ #[tokio::test] async fn paginated_endpoints_return_correct_shape() { let state = test_app_state(); - let app = build_router(state, AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(state); for ep in ENDPOINTS { // Large limit: paginated shape, has_more = false (all fixture items fit). diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index 3ec3c3776..6031c122e 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_interview::Interviewer; -use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{ - build_router, create_app_state_with_runtime_settings_and_registry_factory, spawn_scheduler, + create_app_state_with_runtime_settings_and_registry_factory, spawn_scheduler, }; use fabro_workflow::handler::HandlerRegistry; use fabro_workflow::handler::agent::AgentHandler; @@ -126,7 +125,7 @@ async fn full_http_lifecycle_approve_and_complete() { gate_registry, ); spawn_scheduler(Arc::clone(&state)); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(Arc::clone(&state)); // 1. Create run let req = Request::builder() @@ -214,7 +213,7 @@ async fn full_http_lifecycle_cancel() { gate_registry, ); spawn_scheduler(Arc::clone(&state)); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(Arc::clone(&state)); // Create and start a run that will block at the human gate let req = Request::builder() @@ -285,7 +284,7 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() { gate_registry, ); spawn_scheduler(Arc::clone(&state)); - let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let app = fabro_server::test_support::build_test_router(Arc::clone(&state)); let req = Request::builder() .method("POST") diff --git a/lib/crates/fabro-slack/src/connection.rs b/lib/crates/fabro-slack/src/connection.rs index 1491b7ae0..9a863f2c6 100644 --- a/lib/crates/fabro-slack/src/connection.rs +++ b/lib/crates/fabro-slack/src/connection.rs @@ -131,9 +131,10 @@ pub async fn run_event_loop( match action { DispatchAction::SubmitAnswer(submission) => { + let submission = *submission; debug!( - run_id = submission.run_id, - qid = submission.qid, + run_id = submission.run_id.as_str(), + qid = submission.qid.as_str(), "Submitting answer from Slack" ); on_submit(submission); @@ -221,6 +222,8 @@ mod tests { "envelope_id": "env-1", "payload": { "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.answer", "type": "button", @@ -234,6 +237,7 @@ mod tests { assert_eq!(outcome, ProcessOutcome::Continue); match action { DispatchAction::SubmitAnswer(submission) => { + let submission = *submission; assert_eq!(submission.run_id, "run-1"); assert_eq!(submission.qid, "q-1"); assert_eq!(submission.answer.value, AnswerValue::Yes); @@ -284,11 +288,13 @@ mod tests { "type": "events_api", "envelope_id": "env-50", "payload": { + "team_id": "T123", "event": { "type": "message", "text": "my answer", "thread_ts": "1234.5678", - "user": "U123" + "user": "U123", + "user_name": "ada" } } }) @@ -298,6 +304,7 @@ mod tests { assert_eq!(outcome, ProcessOutcome::Continue); match action { DispatchAction::SubmitAnswer(submission) => { + let submission = *submission; assert_eq!(submission.run_id, "run-10"); assert_eq!(submission.qid, "q-10"); assert_eq!( @@ -338,6 +345,8 @@ mod tests { "envelope_id": "env-1", "payload": { "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.answer", "type": "button", diff --git a/lib/crates/fabro-slack/src/dispatch.rs b/lib/crates/fabro-slack/src/dispatch.rs index b0c0f9f8b..26d57b1b8 100644 --- a/lib/crates/fabro-slack/src/dispatch.rs +++ b/lib/crates/fabro-slack/src/dispatch.rs @@ -6,7 +6,7 @@ use crate::threads::{self, ThreadRegistry}; #[derive(Debug)] pub enum DispatchAction { Connected, - SubmitAnswer(SlackAnswerSubmission), + SubmitAnswer(Box), Reconnect, Ignored, } @@ -19,7 +19,7 @@ pub fn dispatch(envelope: &SocketEnvelope, thread_registry: &ThreadRegistry) -> return DispatchAction::Ignored; }; match interaction::parse_interaction(payload) { - Some(submission) => DispatchAction::SubmitAnswer(submission), + Some(submission) => DispatchAction::SubmitAnswer(Box::new(submission)), None => DispatchAction::Ignored, } } @@ -33,17 +33,32 @@ pub fn dispatch(envelope: &SocketEnvelope, thread_registry: &ThreadRegistry) -> let Some(question_ref) = thread_registry.resolve(&thread_ts) else { return DispatchAction::Ignored; }; - DispatchAction::SubmitAnswer(SlackAnswerSubmission { + let Some(actor) = event_actor(payload) else { + return DispatchAction::Ignored; + }; + DispatchAction::SubmitAnswer(Box::new(SlackAnswerSubmission { run_id: question_ref.run_id, - qid: question_ref.qid, + qid: question_ref.qid, answer: fabro_interview::Answer::text(text), - }) + actor, + })) } SocketEventKind::Disconnect => DispatchAction::Reconnect, SocketEventKind::Unknown => DispatchAction::Ignored, } } +fn event_actor(payload: &serde_json::Value) -> Option { + let event = &payload["event"]; + let team_id = payload["team_id"] + .as_str() + .or_else(|| event["team"].as_str())? + .to_string(); + let user_id = event["user"].as_str()?.to_string(); + let user_name = event["user_name"].as_str().map(str::to_string); + Some(fabro_types::Principal::slack(team_id, user_id, user_name)) +} + #[cfg(test)] mod tests { use fabro_interview::AnswerValue; @@ -70,6 +85,8 @@ mod tests { envelope_id: Some("env-1".to_string()), payload: Some(serde_json::json!({ "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.answer", "type": "button", @@ -80,6 +97,7 @@ mod tests { let action = dispatch(&envelope, ®istry); match action { DispatchAction::SubmitAnswer(submission) => { + let submission = *submission; assert_eq!(submission.run_id, "run-1"); assert_eq!(submission.qid, "q-1"); assert_eq!(submission.answer.value, AnswerValue::Yes); @@ -148,17 +166,20 @@ mod tests { envelope_type: "events_api".to_string(), envelope_id: Some("env-5".to_string()), payload: Some(serde_json::json!({ + "team_id": "T123", "event": { "type": "message", "text": "https://github.com/org/repo", "thread_ts": "1234.5678", - "user": "U123" + "user": "U123", + "user_name": "ada" } })), }; let action = dispatch(&envelope, ®istry); match action { DispatchAction::SubmitAnswer(submission) => { + let submission = *submission; assert_eq!(submission.run_id, "run-10"); assert_eq!(submission.qid, "q-10"); assert_eq!( diff --git a/lib/crates/fabro-slack/src/interaction.rs b/lib/crates/fabro-slack/src/interaction.rs index 6a75c3c01..448fa6fbe 100644 --- a/lib/crates/fabro-slack/src/interaction.rs +++ b/lib/crates/fabro-slack/src/interaction.rs @@ -1,4 +1,5 @@ use fabro_interview::Answer; +use fabro_types::Principal; use serde_json::Value; use crate::payload::{SlackActionPayload, SlackAnswerSubmission}; @@ -20,6 +21,7 @@ pub fn parse_interaction(payload: &Value) -> Option { let value = action["value"].as_str()?; let routed: SlackActionPayload = serde_json::from_str(value).ok()?; let question_ref = routed.question_ref(); + let actor = interaction_actor(payload)?; let action_type = action["type"].as_str().unwrap_or("button"); @@ -48,9 +50,21 @@ pub fn parse_interaction(payload: &Value) -> Option { run_id: question_ref.run_id, qid: question_ref.qid, answer, + actor, }) } +fn interaction_actor(payload: &Value) -> Option { + let team_id = payload["team"]["id"].as_str()?.to_string(); + let user = &payload["user"]; + let user_id = user["id"].as_str()?.to_string(); + let user_name = user["name"] + .as_str() + .or_else(|| user["username"].as_str()) + .map(str::to_string); + Some(Principal::slack(team_id, user_id, user_name)) +} + /// Extract selected checkbox values from `payload.state.values`. fn extract_checkbox_selections(payload: &Value) -> Answer { let selected = @@ -79,6 +93,8 @@ mod tests { fn parse_yes_button_click() { let payload = serde_json::json!({ "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.answer", "type": "button", @@ -89,12 +105,22 @@ mod tests { assert_eq!(result.run_id, "run-1"); assert_eq!(result.qid, "q-1"); assert_eq!(result.answer.value, AnswerValue::Yes); + assert_eq!( + result.actor, + fabro_types::Principal::slack( + "T123".to_string(), + "U123".to_string(), + Some("ada".to_string()) + ) + ); } #[test] fn parse_no_button_click() { let payload = serde_json::json!({ "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.answer", "type": "button", @@ -111,6 +137,8 @@ mod tests { fn parse_multiple_choice_button() { let payload = serde_json::json!({ "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.answer", "type": "button", @@ -126,6 +154,8 @@ mod tests { fn checkbox_toggle_is_ignored() { let payload = serde_json::json!({ "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.select", "type": "checkboxes", @@ -142,6 +172,8 @@ mod tests { fn submit_button_reads_checkbox_state() { let payload = serde_json::json!({ "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.submit", "type": "button", @@ -173,6 +205,8 @@ mod tests { fn submit_button_with_no_checkboxes_selected() { let payload = serde_json::json!({ "type": "block_actions", + "team": { "id": "T123" }, + "user": { "id": "U123", "name": "ada" }, "actions": [{ "action_id": "interview.submit", "type": "button", diff --git a/lib/crates/fabro-slack/src/payload.rs b/lib/crates/fabro-slack/src/payload.rs index 12846658e..2bfe690f2 100644 --- a/lib/crates/fabro-slack/src/payload.rs +++ b/lib/crates/fabro-slack/src/payload.rs @@ -1,4 +1,5 @@ use fabro_interview::Answer; +use fabro_types::Principal; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -12,6 +13,7 @@ pub struct SlackAnswerSubmission { pub run_id: String, pub qid: String, pub answer: Answer, + pub actor: Principal, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index d1f295bd6..77b36796e 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -1280,7 +1280,7 @@ mod tests { state .apply_event(&test_event( 2, - EventBody::RunArchived(RunArchivedProps { actor: None }), + EventBody::RunArchived(RunArchivedProps::default()), None, )) .unwrap(); @@ -1343,14 +1343,14 @@ mod tests { state .apply_event(&test_event( 2, - EventBody::RunArchived(RunArchivedProps { actor: None }), + EventBody::RunArchived(RunArchivedProps::default()), None, )) .unwrap(); state .apply_event(&test_event( 3, - EventBody::RunUnarchived(RunUnarchivedProps { actor: None }), + EventBody::RunUnarchived(RunUnarchivedProps::default()), None, )) .unwrap(); @@ -1440,7 +1440,7 @@ mod tests { let err = state .apply_event(&test_event( 2, - EventBody::RunArchived(RunArchivedProps { actor: None }), + EventBody::RunArchived(RunArchivedProps::default()), None, )) .unwrap_err(); @@ -1475,7 +1475,7 @@ mod tests { state .apply_event(&test_event( 2, - EventBody::RunUnarchived(RunUnarchivedProps { actor: None }), + EventBody::RunUnarchived(RunUnarchivedProps::default()), None, )) .unwrap(); diff --git a/lib/crates/fabro-types/src/event_envelope.rs b/lib/crates/fabro-types/src/event_envelope.rs index 6407880cf..8ee812c48 100644 --- a/lib/crates/fabro-types/src/event_envelope.rs +++ b/lib/crates/fabro-types/src/event_envelope.rs @@ -16,7 +16,7 @@ mod tests { use super::EventEnvelope; use crate::run_event::RunCompletedProps; use crate::{ - ActorRef, EventBody, ParallelBranchId, RunEvent, StageId, SuccessReason, fixtures, + EventBody, ParallelBranchId, Principal, RunEvent, StageId, SuccessReason, fixtures, }; #[test] @@ -72,8 +72,9 @@ mod tests { session_id: Some("ses_42".to_string()), parent_session_id: Some("ses_root".to_string()), tool_call_id: Some("tool_call_xyz".to_string()), - actor: Some(ActorRef::agent( + actor: Some(Principal::agent( Some("ses_42".to_string()), + Some("ses_root".to_string()), Some("claude-sonnet".to_string()), )), body: EventBody::RunCompleted(RunCompletedProps { @@ -99,8 +100,9 @@ mod tests { assert_eq!(wire["parent_session_id"], "ses_root"); assert_eq!(wire["tool_call_id"], "tool_call_xyz"); assert_eq!(wire["actor"]["kind"], "agent"); - assert_eq!(wire["actor"]["id"], "ses_42"); - assert_eq!(wire["actor"]["display"], "claude-sonnet"); + assert_eq!(wire["actor"]["session_id"], "ses_42"); + assert_eq!(wire["actor"]["parent_session_id"], "ses_root"); + assert_eq!(wire["actor"]["model"], "claude-sonnet"); assert_eq!(wire["event"], "run.completed"); let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 01f8e08f9..d04249069 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -14,6 +14,7 @@ pub mod failure_signature; pub mod graph; pub mod interview; pub mod outcome; +pub mod principal; pub mod pull_request; pub mod repository; pub mod retro; @@ -52,6 +53,7 @@ pub use interview::{InterviewQuestionRecord, QuestionType}; pub use outcome::{ FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState, }; +pub use principal::{AuthMethod, Principal, PrincipalLogFields, SystemActorKind, UserPrincipal}; pub use pull_request::{ PullRequestDetail, PullRequestGithubDetail, PullRequestRecord, PullRequestRef, PullRequestUser, }; @@ -61,13 +63,13 @@ pub use retro::{ OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro, }; pub use run::{ - DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunAuthMethod, RunClientProvenance, - RunProvenance, RunServerProvenance, RunSpec, RunSubjectProvenance, + DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunClientProvenance, RunProvenance, + RunServerProvenance, RunSpec, }; pub use run_blob_id::RunBlobId; pub use run_event::{ - ActorKind, ActorRef, EventBody, ExecOutputTail, InterviewOption, MetadataSnapshotFailureKind, - MetadataSnapshotPhase, RunEvent, RunNoticeLevel, + EventBody, ExecOutputTail, InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase, + RunEvent, RunNoticeLevel, }; pub use run_id::{RunId, fixtures}; pub use run_projection::{PendingInterviewRecord, RunProjection, StageProjection, first_event_seq}; diff --git a/lib/crates/fabro-types/src/principal.rs b/lib/crates/fabro-types/src/principal.rs new file mode 100644 index 000000000..a983f7b87 --- /dev/null +++ b/lib/crates/fabro-types/src/principal.rs @@ -0,0 +1,335 @@ +use serde::{Deserialize, Serialize}; + +use crate::{IdpIdentity, RunId}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserPrincipal { + pub identity: IdpIdentity, + pub login: String, + pub auth_method: AuthMethod, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Principal { + User(UserPrincipal), + Worker { + run_id: RunId, + }, + Webhook { + delivery_id: String, + }, + Slack { + team_id: String, + user_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_name: Option, + }, + Agent { + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + }, + System { + system_kind: SystemActorKind, + }, + Anonymous, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMethod { + Github, + DevToken, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SystemActorKind { + Engine, + Watchdog, + Timeout, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrincipalLogFields { + pub principal_kind: &'static str, + pub user_auth_method: Option<&'static str>, + pub idp_issuer: Option, + pub idp_subject: Option, + pub login: Option, + pub run_id: Option, + pub delivery_id: Option, + pub team_id: Option, + pub user_id: Option, +} + +impl Principal { + #[must_use] + pub fn user(identity: IdpIdentity, login: String, auth_method: AuthMethod) -> Self { + Self::User(UserPrincipal { + identity, + login, + auth_method, + }) + } + + #[must_use] + pub fn worker(run_id: RunId) -> Self { + Self::Worker { run_id } + } + + #[must_use] + pub fn webhook(delivery_id: String) -> Self { + Self::Webhook { delivery_id } + } + + #[must_use] + pub fn slack(team_id: String, user_id: String, user_name: Option) -> Self { + Self::Slack { + team_id, + user_id, + user_name, + } + } + + #[must_use] + pub fn agent( + session_id: Option, + parent_session_id: Option, + model: Option, + ) -> Self { + Self::Agent { + session_id, + parent_session_id, + model, + } + } + + #[must_use] + pub fn system(system_kind: SystemActorKind) -> Self { + Self::System { system_kind } + } + + #[must_use] + pub fn anonymous() -> Self { + Self::Anonymous + } + + #[must_use] + pub fn user_identity(&self) -> Option<&IdpIdentity> { + match self { + Self::User(user) => Some(&user.identity), + _ => None, + } + } + + #[must_use] + pub fn display(&self) -> String { + match self { + Self::User(user) => user.login.clone(), + Self::Worker { run_id } => run_id.to_string(), + Self::Webhook { delivery_id } => delivery_id.clone(), + Self::Slack { + user_name: Some(user_name), + .. + } => user_name.clone(), + Self::Slack { + team_id, user_id, .. + } => format!("{team_id}:{user_id}"), + Self::Agent { + model: Some(model), .. + } => model.clone(), + Self::Agent { + session_id: Some(session_id), + .. + } => session_id.clone(), + Self::Agent { .. } => "agent".to_string(), + Self::System { system_kind } => format!("system:{system_kind:?}").to_lowercase(), + Self::Anonymous => "anonymous".to_string(), + } + } + + #[must_use] + pub fn log_fields(&self) -> PrincipalLogFields { + match self { + Self::User(user) => PrincipalLogFields { + principal_kind: "user", + user_auth_method: Some(user.auth_method.as_str()), + idp_issuer: Some(user.identity.issuer().to_string()), + idp_subject: Some(user.identity.subject().to_string()), + login: Some(user.login.clone()), + run_id: None, + delivery_id: None, + team_id: None, + user_id: None, + }, + Self::Worker { run_id } => PrincipalLogFields { + principal_kind: "worker", + user_auth_method: None, + idp_issuer: None, + idp_subject: None, + login: None, + run_id: Some(run_id.to_string()), + delivery_id: None, + team_id: None, + user_id: None, + }, + Self::Webhook { delivery_id } => PrincipalLogFields { + principal_kind: "webhook", + user_auth_method: None, + idp_issuer: None, + idp_subject: None, + login: None, + run_id: None, + delivery_id: Some(delivery_id.clone()), + team_id: None, + user_id: None, + }, + Self::Slack { + team_id, user_id, .. + } => PrincipalLogFields { + principal_kind: "slack", + user_auth_method: None, + idp_issuer: None, + idp_subject: None, + login: None, + run_id: None, + delivery_id: None, + team_id: Some(team_id.clone()), + user_id: Some(user_id.clone()), + }, + Self::Agent { .. } => PrincipalLogFields { + principal_kind: "agent", + user_auth_method: None, + idp_issuer: None, + idp_subject: None, + login: None, + run_id: None, + delivery_id: None, + team_id: None, + user_id: None, + }, + Self::System { .. } => PrincipalLogFields { + principal_kind: "system", + user_auth_method: None, + idp_issuer: None, + idp_subject: None, + login: None, + run_id: None, + delivery_id: None, + team_id: None, + user_id: None, + }, + Self::Anonymous => PrincipalLogFields { + principal_kind: "anonymous", + user_auth_method: None, + idp_issuer: None, + idp_subject: None, + login: None, + run_id: None, + delivery_id: None, + team_id: None, + user_id: None, + }, + } + } +} + +impl AuthMethod { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Github => "github", + Self::DevToken => "dev_token", + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{AuthMethod, Principal, SystemActorKind}; + use crate::{IdpIdentity, fixtures}; + + fn identity() -> IdpIdentity { + IdpIdentity::new("https://github.com", "12345").unwrap() + } + + #[test] + fn user_principal_serializes_flat_with_identity() { + let principal = Principal::user(identity(), "octocat".to_string(), AuthMethod::Github); + + assert_eq!( + serde_json::to_value(&principal).unwrap(), + json!({ + "kind": "user", + "identity": { + "issuer": "https://github.com", + "subject": "12345" + }, + "login": "octocat", + "auth_method": "github" + }) + ); + } + + #[test] + fn system_principal_uses_system_kind_field() { + let principal = Principal::system(SystemActorKind::Watchdog); + + assert_eq!( + serde_json::to_value(&principal).unwrap(), + json!({ + "kind": "system", + "system_kind": "watchdog" + }) + ); + } + + #[test] + fn round_trips_all_variants() { + let variants = [ + Principal::user(identity(), "octocat".to_string(), AuthMethod::Github), + Principal::worker(fixtures::RUN_1), + Principal::webhook("delivery-1".to_string()), + Principal::slack("T1".to_string(), "U1".to_string(), Some("ada".to_string())), + Principal::agent( + Some("session".to_string()), + Some("parent".to_string()), + Some("gpt".to_string()), + ), + Principal::system(SystemActorKind::Engine), + Principal::anonymous(), + ]; + + for principal in variants { + let value = serde_json::to_value(&principal).unwrap(); + let parsed: Principal = serde_json::from_value(value).unwrap(); + assert_eq!(parsed, principal); + } + } + + #[test] + fn projects_log_fields() { + let user = Principal::user(identity(), "octocat".to_string(), AuthMethod::DevToken); + let fields = user.log_fields(); + + assert_eq!(fields.principal_kind, "user"); + assert_eq!(fields.user_auth_method, Some("dev_token")); + assert_eq!(fields.idp_issuer.as_deref(), Some("https://github.com")); + assert_eq!(fields.idp_subject.as_deref(), Some("12345")); + assert_eq!(fields.login.as_deref(), Some("octocat")); + + let worker = Principal::worker(fixtures::RUN_1); + assert_eq!(worker.log_fields().principal_kind, "worker"); + assert_eq!( + worker.log_fields().run_id, + Some(fixtures::RUN_1.to_string()) + ); + } +} diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index e53cfbb2e..79dd74923 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -4,17 +4,10 @@ use serde::{Deserialize, Serialize}; use crate::WorkflowSettings; use crate::graph::Graph; +use crate::principal::Principal; use crate::run_blob_id::RunBlobId; use crate::run_id::RunId; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RunAuthMethod { - Disabled, - DevToken, - Github, -} - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct RunServerProvenance { pub version: String, @@ -30,13 +23,6 @@ pub struct RunClientProvenance { pub version: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RunSubjectProvenance { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub login: Option, - pub auth_method: RunAuthMethod, -} - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct RunProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -44,7 +30,7 @@ pub struct RunProvenance { #[serde(default, skip_serializing_if = "Option::is_none")] pub client: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject: Option, + pub subject: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 8cd8412c4..e8bdfb27c 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value, json}; pub use stage::*; -use crate::{ParallelBranchId, RunId, StageId}; +use crate::{ParallelBranchId, Principal, RunId, StageId}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -26,52 +26,6 @@ pub enum RunNoticeLevel { Error, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ActorKind { - User, - Agent, - System, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ActorRef { - pub kind: ActorKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display: Option, -} - -impl ActorRef { - #[must_use] - pub fn user(login: String) -> Self { - Self { - kind: ActorKind::User, - id: Some(login.clone()), - display: Some(login), - } - } - - #[must_use] - pub fn agent(session_id: Option, display: Option) -> Self { - Self { - kind: ActorKind::Agent, - id: session_id, - display, - } - } - - #[must_use] - pub fn system_worker() -> Self { - Self { - kind: ActorKind::System, - id: Some("worker".to_string()), - display: Some("system:worker".to_string()), - } - } -} - #[derive(Debug, Clone, PartialEq)] pub struct RunEvent { pub id: String, @@ -85,7 +39,7 @@ pub struct RunEvent { pub session_id: Option, pub parent_session_id: Option, pub tool_call_id: Option, - pub actor: Option, + pub actor: Option, pub body: EventBody, } @@ -352,7 +306,7 @@ struct RunEventRaw { #[serde(default)] tool_call_id: Option, #[serde(default)] - actor: Option, + actor: Option, event: String, #[serde(default = "default_properties")] properties: Value, @@ -374,7 +328,7 @@ struct RunEventParts<'a> { session_id: Option, parent_session_id: Option, tool_call_id: Option, - actor: Option, + actor: Option, event: &'a str, properties: &'a Value, } @@ -821,7 +775,17 @@ mod tests { use serde_json::json; use super::*; - use crate::{Edge, Graph, Node, RunBlobId, WorkflowSettings, fixtures}; + use crate::{ + AuthMethod, Edge, Graph, IdpIdentity, Node, RunBlobId, WorkflowSettings, fixtures, + }; + + fn user_principal(login: &str) -> Principal { + Principal::user( + IdpIdentity::new("https://github.com", "12345").unwrap(), + login.to_string(), + AuthMethod::Github, + ) + } #[test] fn run_event_round_trips_json() { @@ -1029,8 +993,9 @@ mod tests { "tool_call_id": "call_1", "actor": { "kind": "agent", - "id": "ses_child", - "display": "claude-sonnet" + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "model": "claude-sonnet" }, "properties": { "tool_name": "read_file", @@ -1050,9 +1015,14 @@ mod tests { ); assert_eq!(parsed.tool_call_id.as_deref(), Some("call_1")); let actor = parsed.actor.as_ref().expect("actor present"); - assert_eq!(actor.kind, ActorKind::Agent); - assert_eq!(actor.id.as_deref(), Some("ses_child")); - assert_eq!(actor.display.as_deref(), Some("claude-sonnet")); + assert_eq!( + actor, + &Principal::agent( + Some("ses_child".to_string()), + Some("ses_parent".to_string()), + Some("claude-sonnet".to_string()), + ) + ); let serialized = parsed.to_value().unwrap(); assert_eq!(serialized["stage_id"], value["stage_id"]); @@ -1138,19 +1108,16 @@ mod tests { } #[test] - fn run_archived_serializes_with_dotted_event_name_and_actor_property() { - let body = EventBody::RunArchived(RunArchivedProps { - actor: Some(ActorRef::user("alice".to_string())), - }); + fn run_archived_serializes_with_dotted_event_name_without_actor_property() { + let body = EventBody::RunArchived(RunArchivedProps::default()); let value = serde_json::to_value(&body).unwrap(); assert_eq!(value["event"], "run.archived"); - assert_eq!(value["properties"]["actor"]["kind"], "user"); - assert_eq!(value["properties"]["actor"]["id"], "alice"); + assert_eq!(value["properties"], json!({})); } #[test] - fn run_unarchived_serializes_with_actor_only() { - let body = EventBody::RunUnarchived(RunUnarchivedProps { actor: None }); + fn run_unarchived_serializes_without_actor_property() { + let body = EventBody::RunUnarchived(RunUnarchivedProps::default()); let value = serde_json::to_value(&body).unwrap(); assert_eq!(value["event"], "run.unarchived"); assert_eq!(value["properties"], json!({})); @@ -1163,23 +1130,25 @@ mod tests { "ts": "2026-04-19T12:00:00.000Z", "run_id": fixtures::RUN_1, "event": "run.archived", - "properties": { - "actor": { - "kind": "user", - "id": "alice", - "display": "alice" - } - } + "actor": { + "kind": "user", + "identity": { + "issuer": "https://github.com", + "subject": "12345" + }, + "login": "alice", + "auth_method": "github" + }, + "properties": {} }); let parsed = RunEvent::from_value(value.clone()).unwrap(); assert!(matches!(parsed.body, EventBody::RunArchived(_))); + assert_eq!(parsed.actor, Some(user_principal("alice"))); let serialized = parsed.to_value().unwrap(); assert_eq!(serialized["event"], "run.archived"); - assert_eq!( - serialized["properties"]["actor"], - value["properties"]["actor"] - ); + assert_eq!(serialized["actor"], value["actor"]); + assert_eq!(serialized["properties"], json!({})); } #[test] @@ -1194,9 +1163,7 @@ mod tests { let parsed = RunEvent::from_value(value.clone()).unwrap(); match &parsed.body { - EventBody::RunUnarchived(props) => { - assert!(props.actor.is_none()); - } + EventBody::RunUnarchived(_) => {} other => panic!("expected RunUnarchived body, got {other:?}"), } } diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index 92f12842d..a35f406a8 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use super::{ActorRef, BilledTokenCounts, RunNoticeLevel}; +use super::{BilledTokenCounts, RunNoticeLevel}; use crate::status::{BlockedReason, FailureReason, SuccessReason}; use crate::{ ForkSourceRef, GitContext, Graph, RunBlobId, RunControlAction, RunId, RunProvenance, @@ -98,17 +98,19 @@ pub struct RunSupersededByProps { pub target_visit: usize, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunArchivedProps { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub actor: Option, -} +#[allow( + clippy::empty_structs_with_brackets, + reason = "This type must serialize as {} rather than null." +)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct RunArchivedProps {} -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunUnarchivedProps { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub actor: Option, -} +#[allow( + clippy::empty_structs_with_brackets, + reason = "This type must serialize as {} rather than null." +)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct RunUnarchivedProps {} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunCompletedProps { diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 167f08cbb..72464a1c0 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; use ::fabro_types::{ - ActorRef, BilledTokenCounts, BlockedReason, CommandTermination, FailureReason, ForkSourceRef, - GitContext, ParallelBranchId, PullRequestRecord, RunBlobId, RunControlAction, RunEvent, RunId, + BilledTokenCounts, BlockedReason, CommandTermination, FailureReason, ForkSourceRef, GitContext, + ParallelBranchId, Principal, PullRequestRecord, RunBlobId, RunControlAction, RunEvent, RunId, RunProvenance, StageId, StageOutcome, SuccessReason, run_event as fabro_types, }; use anyhow::{Context, Result}; @@ -91,15 +91,15 @@ pub enum Event { RunRemoving, RunCancelRequested { #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, + actor: Option, }, RunPauseRequested { #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, + actor: Option, }, RunUnpauseRequested { #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, + actor: Option, }, RunPaused, RunUnpaused, @@ -111,11 +111,11 @@ pub enum Event { }, RunArchived { #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, + actor: Option, }, RunUnarchived { #[serde(default, skip_serializing_if = "Option::is_none")] - actor: Option, + actor: Option, }, WorkflowRunCompleted { duration_ms: u64, @@ -275,18 +275,24 @@ pub enum Event { context_display: Option, }, InterviewCompleted { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, question_id: String, question: String, answer: String, duration_ms: u64, }, InterviewTimeout { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, question_id: String, question: String, stage: String, duration_ms: u64, }, InterviewInterrupted { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, question_id: String, question: String, stage: String, @@ -1434,7 +1440,7 @@ struct StoredEventFields { parallel_group_id: Option, parallel_branch_id: Option, tool_call_id: Option, - actor: Option, + actor: Option, } fn default_node_label(node_id: Option<&String>, node_label: Option) -> Option { @@ -1508,7 +1514,8 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { | Event::RunPauseRequested { actor } | Event::RunUnpauseRequested { actor } | Event::RunArchived { actor } - | Event::RunUnarchived { actor, .. } => StoredEventFields { + | Event::RunUnarchived { actor, .. } + | Event::InterviewCompleted { actor, .. } => StoredEventFields { actor: actor.clone(), ..StoredEventFields::default() }, @@ -1557,7 +1564,11 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { let node_label = default_node_label(node_id.as_ref(), None); let stage_id = Some(StageId::new(stage.clone(), *visit)); let tool_call_id = agent_tool_call_id(agent_event).map(str::to_string); - let actor = agent_actor_for_event(agent_event, session_id.as_deref()); + let actor = agent_actor_for_event( + agent_event, + session_id.as_deref(), + parent_session_id.as_deref(), + ); StoredEventFields { session_id: session_id.clone(), parent_session_id: parent_session_id.clone(), @@ -1594,21 +1605,20 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { } Event::Prompt { stage, .. } | Event::InterviewStarted { stage, .. } - | Event::InterviewTimeout { stage, .. } - | Event::InterviewInterrupted { stage, .. } | Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())), + Event::InterviewTimeout { actor, stage, .. } + | Event::InterviewInterrupted { actor, stage, .. } => { + let mut fields = node_stored_fields(Some(stage.clone())); + fields.actor.clone_from(actor); + fields + } Event::StallWatchdogTimeout { node, .. } => node_stored_fields(Some(node.clone())), _ => StoredEventFields::default(), } } -fn actor_from_provenance(provenance: &RunProvenance) -> Option { - provenance - .subject - .as_ref()? - .login - .clone() - .map(ActorRef::user) +fn actor_from_provenance(provenance: &RunProvenance) -> Option { + provenance.subject.clone() } fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { @@ -1619,10 +1629,15 @@ fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { } } -fn agent_actor_for_event(event: &AgentEvent, session_id: Option<&str>) -> Option { +fn agent_actor_for_event( + event: &AgentEvent, + session_id: Option<&str>, + parent_session_id: Option<&str>, +) -> Option { match event { - AgentEvent::AssistantMessage { model, .. } => Some(ActorRef::agent( + AgentEvent::AssistantMessage { model, .. } => Some(Principal::agent( session_id.map(str::to_string), + parent_session_id.map(str::to_string), Some(model.clone()), )), _ => None, @@ -1731,13 +1746,11 @@ fn event_body_from_event(event: &Event) -> EventBody { target_node_id: target_node_id.clone(), target_visit: *target_visit, }), - Event::RunArchived { actor } => EventBody::RunArchived(fabro_types::RunArchivedProps { - actor: actor.clone(), - }), - Event::RunUnarchived { actor } => { - EventBody::RunUnarchived(fabro_types::RunUnarchivedProps { - actor: actor.clone(), - }) + Event::RunArchived { .. } => { + EventBody::RunArchived(fabro_types::RunArchivedProps::default()) + } + Event::RunUnarchived { .. } => { + EventBody::RunUnarchived(fabro_types::RunUnarchivedProps::default()) } Event::WorkflowRunCompleted { duration_ms, @@ -1962,6 +1975,7 @@ fn event_body_from_event(event: &Event) -> EventBody { context_display: context_display.clone(), }), Event::InterviewCompleted { + actor: _, question_id, question, answer, @@ -1973,6 +1987,7 @@ fn event_body_from_event(event: &Event) -> EventBody { duration_ms: *duration_ms, }), Event::InterviewTimeout { + actor: _, question_id, question, stage, @@ -1984,6 +1999,7 @@ fn event_body_from_event(event: &Event) -> EventBody { duration_ms: *duration_ms, }), Event::InterviewInterrupted { + actor: _, question_id, question, stage, @@ -3172,10 +3188,18 @@ impl Emitter { mod tests { use std::sync::{Arc, Mutex}; - use ::fabro_types::{ActorKind, fixtures}; + use ::fabro_types::{AuthMethod, IdpIdentity, fixtures}; use super::*; + fn user_principal(login: &str) -> Principal { + Principal::user( + IdpIdentity::new("https://github.com", "12345").unwrap(), + login.to_string(), + AuthMethod::Github, + ) + } + #[test] fn event_emitter_new_has_no_listeners() { let emitter = Emitter::new(fixtures::RUN_1); @@ -3483,7 +3507,7 @@ mod tests { let second_events = Arc::clone(&second); let sink = RunEventSink::map( |mut event| { - event.actor = Some(ActorRef::user("alice".to_string())); + event.actor = Some(user_principal("alice")); event }, RunEventSink::fanout(vec![ @@ -3511,8 +3535,8 @@ mod tests { let second = second.lock().await; assert_eq!(first.len(), 1); assert_eq!(second.len(), 1); - assert_eq!(first[0].actor, Some(ActorRef::user("alice".to_string()))); - assert_eq!(second[0].actor, Some(ActorRef::user("alice".to_string()))); + assert_eq!(first[0].actor, Some(user_principal("alice"))); + assert_eq!(second[0].actor, Some(user_principal("alice"))); } #[tokio::test] @@ -3767,11 +3791,7 @@ mod tests { #[test] fn control_action_events_carry_actor_in_envelope() { - let actor = ActorRef { - kind: ActorKind::User, - id: Some("alice".to_string()), - display: Some("alice".to_string()), - }; + let actor = user_principal("alice"); let cancel = to_run_event(&fixtures::RUN_1, &Event::RunCancelRequested { actor: Some(actor.clone()), @@ -3804,11 +3824,7 @@ mod tests { #[test] fn run_archived_round_trips_actor_in_envelope() { - let actor = ActorRef { - kind: ActorKind::User, - id: Some("alice".to_string()), - display: Some("alice".to_string()), - }; + let actor = user_principal("alice"); let archived = to_run_event(&fixtures::RUN_1, &Event::RunArchived { actor: Some(actor.clone()), @@ -3820,11 +3836,7 @@ mod tests { #[test] fn run_unarchived_round_trips_actor_in_envelope() { - let actor = ActorRef { - kind: ActorKind::User, - id: Some("bob".to_string()), - display: Some("bob".to_string()), - }; + let actor = user_principal("bob"); let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived { actor: Some(actor.clone()), @@ -3832,9 +3844,7 @@ mod tests { assert_eq!(unarchived.event_name(), "run.unarchived"); assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor); match &unarchived.body { - EventBody::RunUnarchived(props) => { - assert_eq!(props.actor.as_ref().expect("actor set"), &actor); - } + EventBody::RunUnarchived(_) => {} other => panic!("expected RunUnarchived body, got {other:?}"), } } @@ -3956,24 +3966,24 @@ mod tests { parent_session_id: None, }); let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor.kind, ActorKind::Agent); - assert_eq!(actor.id.as_deref(), Some("ses_agent")); - assert_eq!(actor.display.as_deref(), Some("claude-sonnet")); + assert_eq!( + actor, + &Principal::agent( + Some("ses_agent".to_string()), + None, + Some("claude-sonnet".to_string()), + ) + ); } #[test] fn run_created_populates_user_actor_from_provenance() { - use ::fabro_types::{ - Graph, RunAuthMethod, RunSubjectProvenance, WorkflowSettings, fixtures, - }; + use ::fabro_types::{Graph, WorkflowSettings, fixtures}; let provenance = RunProvenance { server: None, client: None, - subject: Some(RunSubjectProvenance { - login: Some("alice".to_string()), - auth_method: RunAuthMethod::Github, - }), + subject: Some(user_principal("alice")), }; let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated { @@ -3994,8 +4004,6 @@ mod tests { in_place: false, }); let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor.kind, ActorKind::User); - assert_eq!(actor.id.as_deref(), Some("alice")); - assert_eq!(actor.display.as_deref(), Some("alice")); + assert_eq!(actor, &user_principal("alice")); } } diff --git a/lib/crates/fabro-workflow/src/handler/human.rs b/lib/crates/fabro-workflow/src/handler/human.rs index a603a2f8b..924f098eb 100644 --- a/lib/crates/fabro-workflow/src/handler/human.rs +++ b/lib/crates/fabro-workflow/src/handler/human.rs @@ -6,7 +6,7 @@ use std::time::Instant; use async_trait::async_trait; use fabro_graphviz::graph::{Graph, Node}; use fabro_interview::{Answer, AnswerValue, Interviewer, Question}; -use fabro_types::{BlockedReason, InterviewOption, QuestionType}; +use fabro_types::{BlockedReason, InterviewOption, Principal, QuestionType, SystemActorKind}; use ulid::Ulid; use super::{EngineServices, Handler}; @@ -284,13 +284,16 @@ impl Handler for HumanHandler { self.tracker .interview_started(services.run.emitter.as_ref()); let interview_start = Instant::now(); - let answer = self.interviewer.ask(question).await; + let answer_submission = self.interviewer.ask(question).await; + let answer_actor = answer_submission.actor.clone(); + let answer = answer_submission.answer; // 4. Handle timeout if answer.value == AnswerValue::Timeout { self.emit( &services.run.emitter, &Event::InterviewTimeout { + actor: Some(Principal::system(SystemActorKind::Timeout)), question_id: question_id.clone(), question: question_text, stage: node.id.clone(), @@ -331,6 +334,7 @@ impl Handler for HumanHandler { self.emit( &services.run.emitter, &Event::InterviewInterrupted { + actor: Some(Principal::system(SystemActorKind::Engine)), question_id: question_id.clone(), question: question_text, stage: node.id.clone(), @@ -349,6 +353,7 @@ impl Handler for HumanHandler { self.emit( &services.run.emitter, &Event::InterviewCompleted { + actor: Some(answer_actor), question_id, question: question_text, answer: answer_text(&answer), @@ -365,6 +370,7 @@ impl Handler for HumanHandler { self.emit( &services.run.emitter, &Event::InterviewCompleted { + actor: Some(answer_actor), question_id, question: question_text, answer: answer_text(&answer), @@ -548,7 +554,7 @@ mod tests { #[tokio::test] async fn wait_human_auto_approve_selects_first() { - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let handler = HumanHandler::new(interviewer); let graph = build_graph_with_human_gate(); let node = graph.nodes.get("gate").unwrap(); @@ -570,7 +576,7 @@ mod tests { #[tokio::test] async fn wait_human_no_edges_returns_fail() { - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let handler = HumanHandler::new(interviewer); let mut graph = Graph::new("test"); let gate = Node::new("gate"); @@ -838,7 +844,7 @@ mod tests { #[tokio::test] async fn simulate_selects_first_choice() { - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let handler = HumanHandler::new(interviewer); let graph = build_graph_with_human_gate(); let node = graph.nodes.get("gate").unwrap(); diff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs index b32f5ceb0..87f2e5b50 100644 --- a/lib/crates/fabro-workflow/src/operations/archive.rs +++ b/lib/crates/fabro-workflow/src/operations/archive.rs @@ -1,5 +1,5 @@ use fabro_store::Database; -use fabro_types::{ActorRef, RunId, RunStatus, TerminalStatus}; +use fabro_types::{Principal, RunId, RunStatus, TerminalStatus}; use super::run_store::map_open_run_error; use crate::error::Error; @@ -47,7 +47,7 @@ pub enum UnarchiveOutcome { pub async fn archive( store: &Database, run_id: &RunId, - actor: Option, + actor: Option, ) -> Result { let run_store = store .open_run(run_id) @@ -94,7 +94,7 @@ pub async fn archive( pub async fn unarchive( store: &Database, run_id: &RunId, - actor: Option, + actor: Option, ) -> Result { let run_store = store .open_run(run_id) diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index dac3d3a64..d3158b9b3 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -1087,10 +1087,11 @@ mod tests { name: Some("fabro-cli".to_string()), version: Some("0.9.0".to_string()), }), - subject: Some(fabro_types::RunSubjectProvenance { - login: None, - auth_method: fabro_types::RunAuthMethod::Disabled, - }), + subject: Some(fabro_types::Principal::user( + fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(), + "octocat".to_string(), + fabro_types::AuthMethod::Github, + )), }), configured_providers: Vec::new(), }, @@ -1110,8 +1111,12 @@ mod tests { Some("fabro-cli") ); assert_eq!( - provenance.subject.unwrap().auth_method, - fabro_types::RunAuthMethod::Disabled + provenance.subject.unwrap(), + fabro_types::Principal::user( + fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(), + "octocat".to_string(), + fabro_types::AuthMethod::Github, + ) ); } } diff --git a/lib/crates/fabro-workflow/src/operations/rewind.rs b/lib/crates/fabro-workflow/src/operations/rewind.rs index 79abf676b..f9cc8f71d 100644 --- a/lib/crates/fabro-workflow/src/operations/rewind.rs +++ b/lib/crates/fabro-workflow/src/operations/rewind.rs @@ -1,5 +1,5 @@ use fabro_store::Database; -use fabro_types::{ActorRef, RunId}; +use fabro_types::{Principal, RunId}; use tracing::error; use super::archive; @@ -32,7 +32,7 @@ pub enum RewindOutcome { pub async fn rewind( store: &Database, input: &RewindInput, - actor: Option, + actor: Option, ) -> Result { let projection = store .open_run(&input.run_id) diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 883d0230c..bf46dcbe2 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -405,7 +405,7 @@ impl RunSession { let interviewer: Arc = if resolved.execution.approval == ApprovalMode::Auto { - Arc::new(AutoApproveInterviewer) + Arc::new(AutoApproveInterviewer::engine()) } else { services.interviewer }; @@ -1107,7 +1107,7 @@ mod tests { run_id: fixtures::RUN_1, cancel_token: None, emitter, - interviewer: Arc::new(fabro_interview::AutoApproveInterviewer), + interviewer: Arc::new(fabro_interview::AutoApproveInterviewer::engine()), run_store: store.open_run(&fixtures::RUN_1).await.unwrap().into(), event_sink: RunEventSink::store(store.open_run(&fixtures::RUN_1).await.unwrap()), artifact_sink: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 07dc8d31d..942727558 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -212,7 +212,7 @@ async fn execute_test_run_with_options( mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: LifecycleOptions { setup_commands: vec![], setup_command_timeout_ms: 1_000, @@ -270,7 +270,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: LifecycleOptions { setup_commands: vec![], setup_command_timeout_ms: 1_000, @@ -342,7 +342,7 @@ async fn run_with_lifecycle( mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle, run_options, workflow_path: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 9552d23c0..d7d6f22ae 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -921,7 +921,7 @@ mod tests { mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: crate::run_options::LifecycleOptions { setup_commands: vec![command.to_string()], setup_command_timeout_ms: 1_000, @@ -976,7 +976,7 @@ mod tests { mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: crate::run_options::LifecycleOptions { setup_commands: vec![], setup_command_timeout_ms: 1_000, @@ -1038,7 +1038,7 @@ mod tests { mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: crate::run_options::LifecycleOptions { setup_commands: vec![], setup_command_timeout_ms: 1_000, @@ -1122,7 +1122,7 @@ mod tests { mcp_servers: Vec::new(), dry_run: false, }, - Arc::new(AutoApproveInterviewer), + Arc::new(AutoApproveInterviewer::engine()), &HashMap::new(), &graph, Arc::new(VaultCredentialSource::new(Arc::clone(&vault))), @@ -1167,7 +1167,7 @@ mod tests { mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: crate::run_options::LifecycleOptions { setup_commands: vec!["true".to_string()], setup_command_timeout_ms: 1_000, @@ -1279,7 +1279,7 @@ mod tests { mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: crate::run_options::LifecycleOptions { setup_commands: vec!["sleep 5".to_string()], setup_command_timeout_ms: 5_000, @@ -1340,7 +1340,7 @@ mod tests { mcp_servers: Vec::new(), dry_run: true, }, - interviewer: Arc::new(AutoApproveInterviewer), + interviewer: Arc::new(AutoApproveInterviewer::engine()), lifecycle: crate::run_options::LifecycleOptions { setup_commands: vec![], setup_command_timeout_ms: 5_000, diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 8a9c891c7..9b8916cd8 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -2375,7 +2375,7 @@ async fn tool_handler_e2e() { graph.edges.push(Edge::new("echo_task", "exit")); let dir = tempfile::tempdir().unwrap(); - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(Emitter::default()), @@ -2446,7 +2446,7 @@ async fn auto_approve_interviewer_e2e() { graph.edges.push(Edge::new("reject", "exit")); let dir = tempfile::tempdir().unwrap(); - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(Emitter::default()), @@ -2739,7 +2739,7 @@ async fn scenario_ship_a_feature() { "Plan to achieve: Ship the widget" ); - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let dir = tempfile::tempdir().unwrap(); let emitter = Emitter::default(); let events = collect_events(&emitter); @@ -2813,7 +2813,9 @@ async fn scenario_parallel_expert_review() { let graph = parse(input).expect("parse"); validate_or_raise(&graph, &[]).expect("validate"); - let recorder = Arc::new(RecordingInterviewer::new(Box::new(AutoApproveInterviewer))); + let recorder = Arc::new(RecordingInterviewer::new(Box::new( + AutoApproveInterviewer::engine(), + ))); let dir = tempfile::tempdir().unwrap(); let interviewer: Arc = recorder.clone(); @@ -3832,7 +3834,7 @@ async fn integration_smoke_plan_implement_review_done() { assert_eq!(graph.nodes["plan"].model(), Some("test-model")); // Run pipeline - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let dir = tempfile::tempdir().unwrap(); let emitter = Emitter::default(); let events = collect_events(&emitter); @@ -6605,7 +6607,7 @@ mod real_llm { graph.edges.push(Edge::new("revise", "gate")); let dir = tempfile::tempdir().unwrap(); - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some( make_llm_backend(Arc::clone(&client)), @@ -13278,7 +13280,7 @@ async fn wait_timer_e2e() { graph.edges.push(Edge::new("wait60", "exit")); let dir = tempfile::tempdir().unwrap(); - let interviewer = Arc::new(AutoApproveInterviewer); + let interviewer = Arc::new(AutoApproveInterviewer::engine()); let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(Emitter::default()), diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 5120a1a24..6e4fd758c 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -18,8 +18,6 @@ base.ts common.ts configuration.ts index.ts -models/actor-kind.ts -models/actor-ref.ts models/agent-permissions.ts models/aggregate-billing-totals.ts models/aggregate-billing.ts @@ -33,6 +31,7 @@ models/artifact-entry.ts models/artifact-list-response.ts models/artifacts-settings.ts models/assistant-stage-turn.ts +models/auth-method.ts models/billed-token-counts.ts models/billing-by-model.ts models/billing-stage-ref.ts @@ -99,6 +98,7 @@ models/health-response.ts models/history-entry.ts models/hook-definition.ts models/hook-event.ts +models/idp-identity.ts models/index.ts models/install-finish-response.ts models/install-github-app-manifest-input.ts @@ -185,6 +185,14 @@ models/preflight-response.ts models/preflight-workflow-summary.ts models/preview-url-request.ts models/preview-url-response.ts +models/principal-agent.ts +models/principal-anonymous.ts +models/principal-slack.ts +models/principal-system.ts +models/principal-user.ts +models/principal-webhook.ts +models/principal-worker.ts +models/principal.ts models/project-namespace.ts models/provider.ts models/prune-run-entry.ts @@ -293,6 +301,7 @@ models/stage-turn.ts models/start-run-request.ts models/submit-answer-request.ts models/success-reason.ts +models/system-actor-kind.ts models/system-features.ts models/system-info-response.ts models/system-run-counts.ts diff --git a/lib/packages/fabro-api-client/src/models/actor-ref.ts b/lib/packages/fabro-api-client/src/models/actor-ref.ts deleted file mode 100644 index 491cd29e4..000000000 --- a/lib/packages/fabro-api-client/src/models/actor-ref.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ActorKind } from './actor-kind'; - -/** - * Optional primary actor associated with a run event. Present on control actions and durable agent output where a stable user or agent identity matters; omitted on routine runtime lifecycle events. - */ -export interface ActorRef { - 'kind': ActorKind; - /** - * Stable actor identifier when available. - */ - 'id'?: string; - /** - * Display-friendly label for the actor. - */ - 'display'?: string; -} - - - diff --git a/lib/packages/fabro-api-client/src/models/actor-kind.ts b/lib/packages/fabro-api-client/src/models/auth-method.ts similarity index 65% rename from lib/packages/fabro-api-client/src/models/actor-kind.ts rename to lib/packages/fabro-api-client/src/models/auth-method.ts index abdc2fb3b..71990e692 100644 --- a/lib/packages/fabro-api-client/src/models/actor-kind.ts +++ b/lib/packages/fabro-api-client/src/models/auth-method.ts @@ -15,16 +15,15 @@ /** - * High-level category of an event actor. + * Runtime user authentication method. */ -export const ActorKind = { - USER: 'user', - AGENT: 'agent', - SYSTEM: 'system' +export const AuthMethod = { + GITHUB: 'github', + DEV_TOKEN: 'dev_token' } as const; -export type ActorKind = typeof ActorKind[keyof typeof ActorKind]; +export type AuthMethod = typeof AuthMethod[keyof typeof AuthMethod]; diff --git a/lib/packages/fabro-api-client/src/models/event-envelope.ts b/lib/packages/fabro-api-client/src/models/event-envelope.ts index 7947bcff7..357531818 100644 --- a/lib/packages/fabro-api-client/src/models/event-envelope.ts +++ b/lib/packages/fabro-api-client/src/models/event-envelope.ts @@ -15,10 +15,10 @@ // May contain unused imports in some cases // @ts-ignore -import type { ActorRef } from './actor-ref'; +import type { EventSeq } from './event-seq'; // May contain unused imports in some cases // @ts-ignore -import type { EventSeq } from './event-seq'; +import type { Principal } from './principal'; // May contain unused imports in some cases // @ts-ignore import type { RunEvent } from './run-event'; diff --git a/lib/packages/fabro-api-client/src/models/idp-identity.ts b/lib/packages/fabro-api-client/src/models/idp-identity.ts new file mode 100644 index 000000000..6eb4dd167 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/idp-identity.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface IdpIdentity { + 'issuer': string; + 'subject': string; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 986f961a9..7503047de 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -1,5 +1,3 @@ -export * from './actor-kind'; -export * from './actor-ref'; export * from './agent-permissions'; export * from './aggregate-billing'; export * from './aggregate-billing-totals'; @@ -13,6 +11,7 @@ export * from './artifact-entry'; export * from './artifact-list-response'; export * from './artifacts-settings'; export * from './assistant-stage-turn'; +export * from './auth-method'; export * from './billed-token-counts'; export * from './billing-by-model'; export * from './billing-stage-ref'; @@ -79,6 +78,7 @@ export * from './health-response'; export * from './history-entry'; export * from './hook-definition'; export * from './hook-event'; +export * from './idp-identity'; export * from './install-finish-response'; export * from './install-github-app-manifest-input'; export * from './install-github-app-manifest-response'; @@ -164,6 +164,14 @@ export * from './preflight-response'; export * from './preflight-workflow-summary'; export * from './preview-url-request'; export * from './preview-url-response'; +export * from './principal'; +export * from './principal-agent'; +export * from './principal-anonymous'; +export * from './principal-slack'; +export * from './principal-system'; +export * from './principal-user'; +export * from './principal-webhook'; +export * from './principal-worker'; export * from './project-namespace'; export * from './provider'; export * from './prune-run-entry'; @@ -272,6 +280,7 @@ export * from './stage-turn'; export * from './start-run-request'; export * from './submit-answer-request'; export * from './success-reason'; +export * from './system-actor-kind'; export * from './system-features'; export * from './system-info-response'; export * from './system-run-counts'; diff --git a/lib/packages/fabro-api-client/src/models/principal-agent.ts b/lib/packages/fabro-api-client/src/models/principal-agent.ts new file mode 100644 index 000000000..61264b88d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal-agent.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface PrincipalAgent { + 'kind': PrincipalAgentKindEnum; + 'session_id'?: string | null; + 'parent_session_id'?: string | null; + 'model'?: string | null; +} + +export const PrincipalAgentKindEnum = { + AGENT: 'agent' +} as const; + +export type PrincipalAgentKindEnum = typeof PrincipalAgentKindEnum[keyof typeof PrincipalAgentKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/principal-anonymous.ts b/lib/packages/fabro-api-client/src/models/principal-anonymous.ts new file mode 100644 index 000000000..daac61df0 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal-anonymous.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface PrincipalAnonymous { + 'kind': PrincipalAnonymousKindEnum; +} + +export const PrincipalAnonymousKindEnum = { + ANONYMOUS: 'anonymous' +} as const; + +export type PrincipalAnonymousKindEnum = typeof PrincipalAnonymousKindEnum[keyof typeof PrincipalAnonymousKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/principal-slack.ts b/lib/packages/fabro-api-client/src/models/principal-slack.ts new file mode 100644 index 000000000..61ab4c2d3 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal-slack.ts @@ -0,0 +1,28 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface PrincipalSlack { + 'kind': PrincipalSlackKindEnum; + 'team_id': string; + 'user_id': string; + 'user_name'?: string | null; +} + +export const PrincipalSlackKindEnum = { + SLACK: 'slack' +} as const; + +export type PrincipalSlackKindEnum = typeof PrincipalSlackKindEnum[keyof typeof PrincipalSlackKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/principal-system.ts b/lib/packages/fabro-api-client/src/models/principal-system.ts new file mode 100644 index 000000000..ec4e5562d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal-system.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SystemActorKind } from './system-actor-kind'; + +export interface PrincipalSystem { + 'kind': PrincipalSystemKindEnum; + 'system_kind': SystemActorKind; +} + +export const PrincipalSystemKindEnum = { + SYSTEM: 'system' +} as const; + +export type PrincipalSystemKindEnum = typeof PrincipalSystemKindEnum[keyof typeof PrincipalSystemKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/principal-user.ts b/lib/packages/fabro-api-client/src/models/principal-user.ts new file mode 100644 index 000000000..3111c4071 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal-user.ts @@ -0,0 +1,34 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AuthMethod } from './auth-method'; +// May contain unused imports in some cases +// @ts-ignore +import type { IdpIdentity } from './idp-identity'; + +export interface PrincipalUser { + 'kind': PrincipalUserKindEnum; + 'identity': IdpIdentity; + 'login': string; + 'auth_method': AuthMethod; +} + +export const PrincipalUserKindEnum = { + USER: 'user' +} as const; + +export type PrincipalUserKindEnum = typeof PrincipalUserKindEnum[keyof typeof PrincipalUserKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/principal-webhook.ts b/lib/packages/fabro-api-client/src/models/principal-webhook.ts new file mode 100644 index 000000000..a2aae6f71 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal-webhook.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface PrincipalWebhook { + 'kind': PrincipalWebhookKindEnum; + 'delivery_id': string; +} + +export const PrincipalWebhookKindEnum = { + WEBHOOK: 'webhook' +} as const; + +export type PrincipalWebhookKindEnum = typeof PrincipalWebhookKindEnum[keyof typeof PrincipalWebhookKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/principal-worker.ts b/lib/packages/fabro-api-client/src/models/principal-worker.ts new file mode 100644 index 000000000..feece9215 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal-worker.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface PrincipalWorker { + 'kind': PrincipalWorkerKindEnum; + 'run_id': string; +} + +export const PrincipalWorkerKindEnum = { + WORKER: 'worker' +} as const; + +export type PrincipalWorkerKindEnum = typeof PrincipalWorkerKindEnum[keyof typeof PrincipalWorkerKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/principal.ts b/lib/packages/fabro-api-client/src/models/principal.ts new file mode 100644 index 000000000..e3597295d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/principal.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { AuthMethod } from './auth-method'; +// May contain unused imports in some cases +// @ts-ignore +import type { IdpIdentity } from './idp-identity'; +// May contain unused imports in some cases +// @ts-ignore +import type { PrincipalAgent } from './principal-agent'; +// May contain unused imports in some cases +// @ts-ignore +import type { PrincipalAnonymous } from './principal-anonymous'; +// May contain unused imports in some cases +// @ts-ignore +import type { PrincipalSlack } from './principal-slack'; +// May contain unused imports in some cases +// @ts-ignore +import type { PrincipalSystem } from './principal-system'; +// May contain unused imports in some cases +// @ts-ignore +import type { PrincipalUser } from './principal-user'; +// May contain unused imports in some cases +// @ts-ignore +import type { PrincipalWebhook } from './principal-webhook'; +// May contain unused imports in some cases +// @ts-ignore +import type { PrincipalWorker } from './principal-worker'; +// May contain unused imports in some cases +// @ts-ignore +import type { SystemActorKind } from './system-actor-kind'; + +/** + * @type Principal + */ +export type Principal = { kind: 'agent' } & PrincipalAgent | { kind: 'anonymous' } & PrincipalAnonymous | { kind: 'slack' } & PrincipalSlack | { kind: 'system' } & PrincipalSystem | { kind: 'user' } & PrincipalUser | { kind: 'webhook' } & PrincipalWebhook | { kind: 'worker' } & PrincipalWorker; diff --git a/lib/packages/fabro-api-client/src/models/run-event.ts b/lib/packages/fabro-api-client/src/models/run-event.ts index 5f47658cf..aad7c0559 100644 --- a/lib/packages/fabro-api-client/src/models/run-event.ts +++ b/lib/packages/fabro-api-client/src/models/run-event.ts @@ -15,7 +15,7 @@ // May contain unused imports in some cases // @ts-ignore -import type { ActorRef } from './actor-ref'; +import type { Principal } from './principal'; /** * Internal RunEvent-compatible JSON payload. The server validates this body by deserializing into the typed RunEvent struct. @@ -46,7 +46,7 @@ export interface RunEvent { * Stable identifier for a tool call, present on agent.tool.* events and other durable events that directly describe the same tool call. */ 'tool_call_id'?: string | null; - 'actor'?: ActorRef | null; + 'actor'?: Principal | null; /** * Event type discriminator. */ diff --git a/lib/packages/fabro-api-client/src/models/system-actor-kind.ts b/lib/packages/fabro-api-client/src/models/system-actor-kind.ts new file mode 100644 index 000000000..9904e8b28 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/system-actor-kind.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + + +export const SystemActorKind = { + ENGINE: 'engine', + WATCHDOG: 'watchdog', + TIMEOUT: 'timeout' +} as const; + +export type SystemActorKind = typeof SystemActorKind[keyof typeof SystemActorKind];