diff --git a/Cargo.lock b/Cargo.lock index 4790db55..1252fe56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2303,6 +2303,7 @@ name = "locality-auth-core" version = "0.3.8" dependencies = [ "serde", + "serde_json", ] [[package]] diff --git a/crates/locality-auth-core/Cargo.toml b/crates/locality-auth-core/Cargo.toml index f24c28ab..a0bc3bff 100644 --- a/crates/locality-auth-core/Cargo.toml +++ b/crates/locality-auth-core/Cargo.toml @@ -12,3 +12,6 @@ path = "src/lib.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0" diff --git a/crates/locality-auth-core/src/lib.rs b/crates/locality-auth-core/src/lib.rs index 23df1132..0358a5de 100644 --- a/crates/locality-auth-core/src/lib.rs +++ b/crates/locality-auth-core/src/lib.rs @@ -1,7 +1,7 @@ //! Shared OAuth connector auth contracts for Locality runtimes. //! -//! This crate owns stable connector IDs, OAuth callback paths, and scope -//! profiles. It does not own token storage, tenant authorization, broker route -//! handling, or hosted source finalization. +//! This crate owns stable connector IDs, OAuth callback paths, authority modes, +//! and scope profiles. It does not own token storage, tenant authorization, +//! broker route handling, or hosted source finalization. pub mod oauth; diff --git a/crates/locality-auth-core/src/oauth.rs b/crates/locality-auth-core/src/oauth.rs index 8b829cef..338389d1 100644 --- a/crates/locality-auth-core/src/oauth.rs +++ b/crates/locality-auth-core/src/oauth.rs @@ -58,10 +58,54 @@ impl OAuthConnector { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum OAuthHostMode { + /// Backend-brokered OAuth where the completed credential is handed back to + /// a local desktop/CLI host and source sync remains local authority. LocalBrokered, + /// Administrator-owned OAuth where the backend stores an opaque credential + /// reference and owns hosted source hydration. HostedAdmin, } +impl OAuthHostMode { + pub const fn authority_mode(self) -> ConnectorAuthorityMode { + match self { + Self::LocalBrokered => ConnectorAuthorityMode::LocalDirect, + Self::HostedAdmin => ConnectorAuthorityMode::HostedManaged, + } + } +} + +/// Data-authority mode created by a connector authorization flow. +/// +/// This is intentionally separate from the provider's OAuth mechanics. A +/// backend may broker either flow, but only `hosted_managed` authorizes backend +/// credential retention and hosted source hydration. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectorAuthorityMode { + /// Desktop/CLI owns the connection after brokered credential handoff. + LocalDirect, + /// Hosted backend owns credential storage, refresh, and hydration. + HostedManaged, +} + +impl ConnectorAuthorityMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::LocalDirect => "local_direct", + Self::HostedManaged => "hosted_managed", + } + } + + pub const fn stores_provider_data_in_hosted_backend(self) -> bool { + matches!(self, Self::HostedManaged) + } + + pub const fn is_local_direct(self) -> bool { + matches!(self, Self::LocalDirect) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OAuthProfile { pub connector: OAuthConnector, @@ -72,6 +116,12 @@ pub struct OAuthProfile { pub broker_callback_path: &'static str, } +impl OAuthProfile { + pub const fn authority_mode(self) -> ConnectorAuthorityMode { + self.host.authority_mode() + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum OAuthProfileError { BrokerBaseUrlMustBeHttps, @@ -591,6 +641,15 @@ mod tests { .expect("local broker profile"); assert_eq!(profile.connector, *connector); assert_eq!(profile.host, OAuthHostMode::LocalBrokered); + assert_eq!( + profile.authority_mode(), + ConnectorAuthorityMode::LocalDirect + ); + assert!( + !profile + .authority_mode() + .stores_provider_data_in_hosted_backend() + ); assert!( profile .client_completion_redirect_uri @@ -601,10 +660,60 @@ mod tests { } } + #[test] + fn oauth_host_modes_map_to_explicit_connector_authority_modes() { + assert_eq!( + OAuthHostMode::LocalBrokered.authority_mode(), + ConnectorAuthorityMode::LocalDirect + ); + assert_eq!( + OAuthHostMode::HostedAdmin.authority_mode(), + ConnectorAuthorityMode::HostedManaged + ); + assert!(!ConnectorAuthorityMode::LocalDirect.stores_provider_data_in_hosted_backend()); + assert!(ConnectorAuthorityMode::HostedManaged.stores_provider_data_in_hosted_backend()); + + for connector in OAuthConnector::all() { + let local = oauth_profile(*connector, OAuthHostMode::LocalBrokered) + .expect("local broker profile"); + let hosted = oauth_profile(*connector, OAuthHostMode::HostedAdmin) + .expect("hosted admin profile"); + + assert_eq!(local.authority_mode(), ConnectorAuthorityMode::LocalDirect); + assert_eq!( + hosted.authority_mode(), + ConnectorAuthorityMode::HostedManaged + ); + } + } + + #[test] + fn connector_authority_wire_names_are_stable() { + assert_eq!(ConnectorAuthorityMode::LocalDirect.as_str(), "local_direct"); + assert_eq!( + ConnectorAuthorityMode::HostedManaged.as_str(), + "hosted_managed" + ); + assert_eq!( + serde_json::to_string(&ConnectorAuthorityMode::LocalDirect) + .expect("serialize local authority"), + "\"local_direct\"" + ); + assert_eq!( + serde_json::from_str::("\"hosted_managed\"") + .expect("deserialize hosted authority"), + ConnectorAuthorityMode::HostedManaged + ); + } + #[test] fn hosted_slack_profile_is_reduced_from_local_slack_profile() { let hosted = oauth_profile(OAuthConnector::Slack, OAuthHostMode::HostedAdmin) .expect("hosted Slack profile"); + assert_eq!( + hosted.authority_mode(), + ConnectorAuthorityMode::HostedManaged + ); assert_eq!( hosted.scopes, &[ diff --git a/crates/locality-connector/tests/manifest_contract.rs b/crates/locality-connector/tests/manifest_contract.rs index 1df53024..1907d7bd 100644 --- a/crates/locality-connector/tests/manifest_contract.rs +++ b/crates/locality-connector/tests/manifest_contract.rs @@ -107,6 +107,28 @@ fn strict_parser_rejects_unknown_fields_and_enums() { ); } +#[test] +fn registry_v1_rejects_connector_authority_fields() { + let mut authority_field = registry_value(); + authority_field["connectors"][0]["authority_mode"] = json!("hosted_managed"); + assert!( + ConnectorRegistry::parse(&authority_field.to_string()) + .expect_err("registry v1 must not accept connector authority") + .to_string() + .contains("unknown field") + ); + + let mut profile_authority_field = registry_value(); + profile_authority_field["connectors"][0]["profiles"][0]["authority_mode"] = + json!("local_direct"); + assert!( + ConnectorRegistry::parse(&profile_authority_field.to_string()) + .expect_err("registry v1 profiles must not accept connector authority") + .to_string() + .contains("unknown field") + ); +} + #[test] fn validation_rejects_duplicate_defaults_and_missing_default_profile() { let mut duplicate = registry_value(); diff --git a/docs/connector-development.md b/docs/connector-development.md index 856a7241..6f4826c1 100644 --- a/docs/connector-development.md +++ b/docs/connector-development.md @@ -38,7 +38,11 @@ crate cannot ship by itself. shared ID/scope/callback profile instead of duplicating connector auth constants. Public broker routing and configuration must stay aligned with the shared profile through generated metadata, drift tests, or explicit matching - updates. + updates. Every OAuth profile must also preserve its connector authority mode: + `LocalBrokered` means local direct authority after credential handoff, while + `HostedAdmin` means backend-owned hosted hydration. A backend-brokered OAuth + callback alone must not imply hosted credential retention or hosted source + replication for desktop users. 8. Add the desktop source ID, setup/auth classification, display metadata, and `apps/desktop/src/assets/connectors/.svg` icon. Add OAuth-service routing only when the connector actually uses the public OAuth broker. @@ -118,6 +122,12 @@ public connector revision, but the public crates must not depend on private hosted code. Do not use this manifest branch to enable currently unreachable portable/batch paths or introduce a dynamic ABI/plugin loader. +OAuth brokering and data authority are separate decisions. Locality may use a +backend callback to complete provider OAuth for a desktop connector, but the +credential is handed back to the local host and provider data stays outside the +hosted backend unless an administrator explicitly creates a hosted-managed +connection. + ## Minimal read-only example The connector advertises no push operations and fails closed on apply. Real diff --git a/docs/oauth-architecture.md b/docs/oauth-architecture.md index 01156ba6..aa491e63 100644 --- a/docs/oauth-architecture.md +++ b/docs/oauth-architecture.md @@ -8,8 +8,20 @@ Locality has two OAuth hosts: callbacks, Postgres finalization, and managed secret storage. The shared layer is `locality-auth-core`. It owns connector IDs, callback paths, -and scope profiles. It does not own token storage, tenant authorization, hosted -source finalization, or background job scheduling. +authority modes, and scope profiles. It does not own token storage, tenant +authorization, hosted source finalization, or background job scheduling. + +## Public Authority Contract + +`locality-auth-core` exposes the public connector authority vocabulary: +`local_direct` and `hosted_managed`. `local_direct` means the local Locality +host resolves credentials and calls the provider directly. `hosted_managed` +means a hosted/admin runtime owns managed credential and provider access. + +Desktop-originated OAuth uses the `LocalBrokered` host mode and maps to +`local_direct`, even when the public OAuth broker helps complete the provider +authorization. Hosted/admin OAuth uses the `HostedAdmin` host mode and maps to +`hosted_managed`; it must not be silently treated as local direct authority. ## Public Brokered Desktop Flow