use std::sync::Arc;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use polyc_crypto::session;
use polyc_crypto::signing_role::{RoleTrustSet, SigningRole as _, TurnReadRole};
use polyc_persona::ScopeResolution;
use crate::authority::{
AdminPrincipal, ConversationGrantPrincipal, GRANT_KIND, GrantClaims, PersonaAccess,
PersonaPrincipal, Principal, PrincipalError, SEARCH_SCOPE_CAP, Scoping, SearchScope,
SearchScopeError, canonical_search_scope,
};
use crate::session::QueryScope;
#[async_trait::async_trait]
pub trait SessionVerification: Send + Sync {
async fn verify_bearer(
&self,
token: &str,
now_ms: u64,
) -> Result<
polyc_crypto::session::AuthorizedSessionClaims,
polyc_session_family::authority::SessionAuthorityError,
>;
}
struct FullAuthority(Arc<dyn polyc_session_family::authority::BrowserSessionAuthority>);
#[async_trait::async_trait]
impl SessionVerification for FullAuthority {
async fn verify_bearer(
&self,
token: &str,
now_ms: u64,
) -> Result<
polyc_crypto::session::AuthorizedSessionClaims,
polyc_session_family::authority::SessionAuthorityError,
> {
self.0.verify_bearer(token, now_ms).await
}
}
pub(crate) struct CredentialAuthority {
persona: PersonaAccess,
bearer_authority: Option<Arc<dyn SessionVerification>>,
turn_read_trust: RoleTrustSet<TurnReadRole>,
#[cfg(any(test, feature = "test-util"))]
legacy_revoked: Option<Arc<polyc_crypto::session::RevokedTokens>>,
#[cfg(any(test, feature = "test-util"))]
legacy_session_trust: Option<RoleTrustSet<polyc_crypto::signing_role::SessionRole>>,
#[cfg(test)]
test_search_scope_cap: std::sync::Mutex<Option<usize>>,
}
impl std::fmt::Debug for CredentialAuthority {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CredentialAuthority")
.field("bearer_authority", &self.bearer_authority.is_some())
.finish_non_exhaustive()
}
}
impl CredentialAuthority {
pub(crate) fn current(
persona: Arc<dyn crate::authority::PersonaSource>,
turn_read_trust: RoleTrustSet<TurnReadRole>,
authority: Arc<dyn polyc_session_family::authority::BrowserSessionAuthority>,
) -> Self {
Self::verifying(persona, turn_read_trust, Arc::new(FullAuthority(authority)))
}
pub(crate) fn verifying(
persona: Arc<dyn crate::authority::PersonaSource>,
turn_read_trust: RoleTrustSet<TurnReadRole>,
sessions: Arc<dyn SessionVerification>,
) -> Self {
Self {
persona: PersonaAccess::Current(persona),
bearer_authority: Some(sessions),
turn_read_trust,
#[cfg(any(test, feature = "test-util"))]
legacy_revoked: None,
#[cfg(any(test, feature = "test-util"))]
legacy_session_trust: None,
#[cfg(test)]
test_search_scope_cap: std::sync::Mutex::new(None),
}
}
#[cfg(any(test, feature = "test-util"))]
pub(crate) fn legacy(
persona: crate::authority::PersonaCell,
revoked: Arc<polyc_crypto::session::RevokedTokens>,
turn_read_trust: RoleTrustSet<TurnReadRole>,
legacy_session_trust: RoleTrustSet<polyc_crypto::signing_role::SessionRole>,
) -> Self {
Self {
persona: PersonaAccess::Legacy(persona),
bearer_authority: None,
turn_read_trust,
legacy_revoked: Some(revoked),
legacy_session_trust: Some(legacy_session_trust),
#[cfg(test)]
test_search_scope_cap: std::sync::Mutex::new(None),
}
}
#[cfg(test)]
pub(crate) fn replace_turn_read_trust_for_test(&mut self, trust: RoleTrustSet<TurnReadRole>) {
self.turn_read_trust = trust;
}
#[cfg(test)]
pub(crate) fn set_test_search_scope_cap(&self, cap: usize) {
*self.test_search_scope_cap.lock().expect("poison") = Some(cap);
}
pub(crate) async fn verify_admin_session(
&self,
token: &str,
now_ms: u64,
) -> Result<Principal, PrincipalError> {
let claims = if let Some(authority) = &self.bearer_authority {
authority
.verify_bearer(token, now_ms)
.await
.map_err(|error| match error {
polyc_session_family::authority::SessionAuthorityError::Invalid => {
PrincipalError::InvalidSession
}
_ => PrincipalError::StoreUnavailable,
})?
} else {
#[cfg(not(any(test, feature = "test-util")))]
return Err(PrincipalError::StoreUnavailable);
#[cfg(any(test, feature = "test-util"))]
{
let legacy = session::verify_session_with_trust(
self.legacy_session_trust
.as_ref()
.ok_or(PrincipalError::StoreUnavailable)?,
token,
now_ms,
self.legacy_revoked
.as_ref()
.ok_or(PrincipalError::StoreUnavailable)?,
)
.ok_or(PrincipalError::InvalidSession)?;
polyc_crypto::session::AuthorizedSessionClaims {
issuer: legacy.issuer,
key_id: legacy.key_id,
session_id: "legacy-test-session".to_owned(),
authorization_epoch: 0,
subject: legacy.subject,
scopes: legacy.scopes,
issued_ms: legacy.issued_ms,
expires_ms: legacy.expires_ms,
}
}
};
if !claims.has_scope(session::SessionScope::ExplorerRead) {
return Err(PrincipalError::InvalidSession);
}
let persona_id = claims
.subject
.persona_id()
.ok_or(PrincipalError::InvalidSession)?
.to_owned();
let Some(persona) = self.persona.load_full() else {
return Err(PrincipalError::StoreUnavailable);
};
match persona.active_persona(persona_id).await {
Ok(Some(active)) => {
let persona_id = active.persona_id;
if active.admin {
Ok(Principal::Admin(AdminPrincipal::minted(persona_id)))
} else {
Ok(Principal::Persona(PersonaPrincipal::minted(persona_id)))
}
}
Ok(None) => Err(PrincipalError::NotAuthorizedForFleet),
Err(_store_error) => Err(PrincipalError::StoreUnavailable),
}
}
pub(crate) fn verify_conversation_grant(
&self,
token: &str,
now_unix_ms: u64,
) -> Result<Principal, PrincipalError> {
let (claims_b64, sig_b64) = token.split_once('.').ok_or_else(|| {
tracing::warn!("conversation grant token malformed: no `.` separator");
PrincipalError::InvalidGrant
})?;
let canonical = URL_SAFE_NO_PAD.decode(claims_b64).map_err(|_| {
tracing::warn!("conversation grant token malformed: claims segment not base64");
PrincipalError::InvalidGrant
})?;
let signature = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
tracing::warn!("conversation grant token malformed: signature segment not base64");
PrincipalError::InvalidGrant
})?;
let claims: GrantClaims = serde_json::from_slice(&canonical).map_err(|_| {
tracing::warn!("conversation grant token malformed: claims did not decode as JSON");
PrincipalError::InvalidGrant
})?;
if claims.issuer != TurnReadRole::ISSUER
|| !self.turn_read_trust.verify_turn_read_capability(
&claims.key_id,
&canonical,
&signature,
)
{
tracing::warn!("conversation grant token signature invalid");
return Err(PrincipalError::InvalidGrant);
}
if claims.kind != GRANT_KIND {
tracing::warn!(kind = %claims.kind, "conversation grant token kind tag mismatch");
return Err(PrincipalError::InvalidGrant);
}
if now_unix_ms > claims.expires_at_ms {
tracing::warn!("conversation grant token expired");
return Err(PrincipalError::GrantExpired);
}
Ok(Principal::ConversationGrant(
ConversationGrantPrincipal::minted(claims.conversation_id, claims.subject),
))
}
pub(crate) async fn scoping_for(
&self,
principal: &Principal,
) -> Result<Scoping, PrincipalError> {
let scoping = match principal {
Principal::Admin(admin) => Scoping {
scope: QueryScope::Fleet,
allow_explain: true,
caller_identity: Some(admin.persona_id().to_owned()),
conversation_id: None,
turn_id: None,
web_session_id: None,
},
Principal::ConversationGrant(grant) => {
Scoping::for_conversation(grant.conversation_id(), grant.subject())
}
Principal::Persona(persona) => {
let Some(persona_host) = self.persona.load_full() else {
return Err(PrincipalError::StoreUnavailable);
};
let participations = persona_host
.participations(persona.persona_id().to_owned())
.await
.map_err(|_store_error| PrincipalError::StoreUnavailable)?;
let conversation_ids = participations
.into_iter()
.map(|participation| participation.conversation_id)
.collect();
Scoping {
scope: QueryScope::Conversations(conversation_ids),
allow_explain: false,
caller_identity: Some(persona.persona_id().to_owned()),
conversation_id: None,
turn_id: None,
web_session_id: None,
}
}
};
Ok(scoping)
}
pub(crate) async fn own_rows_scoping(
&self,
persona_id: &str,
) -> Result<Scoping, PrincipalError> {
let Some(persona_host) = self.persona.load_full() else {
return Err(PrincipalError::StoreUnavailable);
};
let participations = persona_host
.participations(persona_id.to_owned())
.await
.map_err(|_store_error| PrincipalError::StoreUnavailable)?;
let conversation_ids = participations
.into_iter()
.map(|participation| participation.conversation_id)
.collect();
let scoping = Scoping {
scope: QueryScope::Conversations(conversation_ids),
allow_explain: false,
caller_identity: Some(persona_id.to_owned()),
conversation_id: None,
turn_id: None,
web_session_id: None,
};
Ok(scoping)
}
pub(crate) async fn resolve_search_scope(
&self,
principal_ref: &str,
conversation_id: &str,
turn_id: &str,
) -> Result<SearchScope, SearchScopeError> {
let Some(persona_host) = self.persona.load_full() else {
return Err(SearchScopeError::StoreUnavailable);
};
match persona_host.active_persona(principal_ref.to_owned()).await {
Ok(Some(_active)) => {}
Ok(None) => {
tracing::info!(
persona_id = %principal_ref,
conversation_id = %conversation_id,
turn_id = %turn_id,
"refusing search-scope resolution: persona is not active"
);
return Err(SearchScopeError::PersonaNotActive);
}
Err(_store_error) => {
return Err(SearchScopeError::StoreUnavailable);
}
}
#[cfg(test)]
let cap = self
.test_search_scope_cap
.lock()
.expect("poison")
.unwrap_or(SEARCH_SCOPE_CAP);
#[cfg(not(test))]
let cap = SEARCH_SCOPE_CAP;
let resolution = persona_host
.participation_scope(principal_ref.to_owned(), cap)
.await
.map_err(|_store_error| SearchScopeError::StoreUnavailable)?;
let mut conversation_ids = match resolution {
ScopeResolution::RefusedOverCap { count } => {
tracing::warn!(
persona_id = %principal_ref,
conversation_id = %conversation_id,
turn_id = %turn_id,
count,
cap,
"refusing search-scope resolution: participation count exceeds the search cap"
);
return Err(SearchScopeError::OverCap { count });
}
ScopeResolution::Resolved { conversation_ids } => conversation_ids,
};
conversation_ids.retain(|id| id != conversation_id);
let (conversation_ids, hash) = canonical_search_scope(conversation_ids);
Ok(SearchScope::minted(conversation_ids, hash))
}
}
pub(crate) trait UnixClock: Send + Sync {
fn now_unix_ms(&self) -> u64;
}
pub(crate) struct SystemUnixClock;
impl UnixClock for SystemUnixClock {
fn now_unix_ms(&self) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| {
u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
})
}
}
pub(crate) enum PresentedCredential {
Bearer(String),
ConversationGrant(String),
}
impl std::fmt::Debug for PresentedCredential {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Bearer(_) => "bearer",
Self::ConversationGrant(_) => "conversation-grant",
})
}
}
pub(crate) struct CredentialWitness {
credential: PresentedCredential,
authority: Arc<CredentialAuthority>,
clock: Arc<dyn UnixClock>,
}
impl std::fmt::Debug for CredentialWitness {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CredentialWitness")
.field("credential", &self.credential)
.finish_non_exhaustive()
}
}
impl CredentialWitness {
pub(crate) async fn admit_bearer(
token: String,
authority: Arc<CredentialAuthority>,
clock: Arc<dyn UnixClock>,
) -> Result<(Self, Scoping), PrincipalError> {
let now = clock.now_unix_ms();
match authority.verify_conversation_grant(&token, now) {
Ok(_grant) => {
Self::admit(
PresentedCredential::ConversationGrant(token),
authority,
clock,
)
.await
}
Err(PrincipalError::GrantExpired) => Err(PrincipalError::GrantExpired),
Err(_not_a_grant) => {
Self::admit(PresentedCredential::Bearer(token), authority, clock).await
}
}
}
pub(crate) async fn admit(
credential: PresentedCredential,
authority: Arc<CredentialAuthority>,
clock: Arc<dyn UnixClock>,
) -> Result<(Self, Scoping), PrincipalError> {
let scoping = Self::verify(&credential, &authority, clock.as_ref()).await?;
Ok((
Self {
credential,
authority,
clock,
},
scoping,
))
}
async fn verify(
credential: &PresentedCredential,
authority: &CredentialAuthority,
clock: &dyn UnixClock,
) -> Result<Scoping, PrincipalError> {
let now = clock.now_unix_ms();
let principal = match credential {
PresentedCredential::Bearer(token) => {
authority.verify_admin_session(token, now).await?
}
PresentedCredential::ConversationGrant(token) => {
authority.verify_conversation_grant(token, now)?
}
};
authority.scoping_for(&principal).await
}
pub(crate) async fn current_scope(&self) -> Result<QueryScope, PrincipalError> {
let scoping = Self::verify(&self.credential, &self.authority, self.clock.as_ref()).await?;
Ok(scoping.scope)
}
}
#[async_trait::async_trait]
impl crate::core_execution::CoreScopeRevalidator for CredentialWitness {
async fn current_scope(
&self,
operation: &crate::core_resolution::CoreOperationContext,
) -> Result<QueryScope, crate::core_resolution::CoreResolutionError> {
operation.check()?;
Self::current_scope(self)
.await
.map_err(|_| crate::core_resolution::CoreResolutionError::InvalidAttribution)
}
}
impl crate::core_execution::sealed::CredentialProven for CredentialWitness {}