chore: publish clean community baseline
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
use crate::{MembershipRole, UserId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SessionActor {
|
||||
pub user_id: UserId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub role: MembershipRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum PolicyAction {
|
||||
ReadWorkspace,
|
||||
WriteWorkspace,
|
||||
ReadWorkspaceAccess,
|
||||
WriteWorkspaceAccess,
|
||||
ReadOperation,
|
||||
WriteOperation,
|
||||
ReadAgent,
|
||||
WriteAgent,
|
||||
ReadPlatformApiKey,
|
||||
WritePlatformApiKey,
|
||||
ReadSecret,
|
||||
WriteSecret,
|
||||
ReadAuthProfile,
|
||||
WriteAuthProfile,
|
||||
ReadObservability,
|
||||
ReadCapability,
|
||||
}
|
||||
|
||||
impl PolicyAction {
|
||||
pub fn is_read(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::ReadWorkspace
|
||||
| Self::ReadWorkspaceAccess
|
||||
| Self::ReadOperation
|
||||
| Self::ReadAgent
|
||||
| Self::ReadPlatformApiKey
|
||||
| Self::ReadSecret
|
||||
| Self::ReadAuthProfile
|
||||
| Self::ReadObservability
|
||||
| Self::ReadCapability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PolicyScope {
|
||||
Workspace(WorkspaceId),
|
||||
Global,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PolicyDecision {
|
||||
Allow,
|
||||
Deny { reason: &'static str },
|
||||
}
|
||||
|
||||
pub trait PolicyEngine: Send + Sync {
|
||||
fn check(
|
||||
&self,
|
||||
actor: &SessionActor,
|
||||
action: PolicyAction,
|
||||
scope: PolicyScope,
|
||||
) -> PolicyDecision;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct OwnerOnlyPolicyEngine;
|
||||
|
||||
impl PolicyEngine for OwnerOnlyPolicyEngine {
|
||||
fn check(
|
||||
&self,
|
||||
actor: &SessionActor,
|
||||
action: PolicyAction,
|
||||
_scope: PolicyScope,
|
||||
) -> PolicyDecision {
|
||||
if matches!(actor.role, MembershipRole::Owner) || action.is_read() {
|
||||
PolicyDecision::Allow
|
||||
} else {
|
||||
PolicyDecision::Deny {
|
||||
reason: "write actions require owner role in community",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
OwnerOnlyPolicyEngine, PolicyAction, PolicyDecision, PolicyEngine, PolicyScope,
|
||||
SessionActor,
|
||||
};
|
||||
use crate::{MembershipRole, UserId, WorkspaceId};
|
||||
|
||||
fn actor(role: MembershipRole) -> SessionActor {
|
||||
SessionActor {
|
||||
user_id: UserId::new("user_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_is_allowed_for_write_actions() {
|
||||
let decision = OwnerOnlyPolicyEngine.check(
|
||||
&actor(MembershipRole::Owner),
|
||||
PolicyAction::WriteWorkspace,
|
||||
PolicyScope::Workspace(WorkspaceId::new("ws_01")),
|
||||
);
|
||||
|
||||
assert_eq!(decision, PolicyDecision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_owner_is_allowed_for_read_actions() {
|
||||
let decision = OwnerOnlyPolicyEngine.check(
|
||||
&actor(MembershipRole::Viewer),
|
||||
PolicyAction::ReadWorkspace,
|
||||
PolicyScope::Workspace(WorkspaceId::new("ws_01")),
|
||||
);
|
||||
|
||||
assert_eq!(decision, PolicyDecision::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_owner_is_denied_for_write_actions() {
|
||||
let decision = OwnerOnlyPolicyEngine.check(
|
||||
&actor(MembershipRole::Admin),
|
||||
PolicyAction::WriteWorkspace,
|
||||
PolicyScope::Workspace(WorkspaceId::new("ws_01")),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
PolicyDecision::Deny {
|
||||
reason: "write actions require owner role in community",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AuditEventId, UserId, UserSessionId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditActor {
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub session_id: Option<UserSessionId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditTargetKind {
|
||||
Workspace,
|
||||
Membership,
|
||||
Invitation,
|
||||
Operation,
|
||||
Agent,
|
||||
PlatformApiKey,
|
||||
Secret,
|
||||
AuthProfile,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditTarget {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub kind: AuditTargetKind,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub id: AuditEventId,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub occurred_at: OffsetDateTime,
|
||||
pub actor: AuditActor,
|
||||
pub action: String,
|
||||
pub target: AuditTarget,
|
||||
pub payload: Value,
|
||||
pub source_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditQuery {
|
||||
pub workspace_id: Option<WorkspaceId>,
|
||||
pub action: Option<String>,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuditEventPage {
|
||||
pub items: Vec<AuditEvent>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuditError {
|
||||
#[error("audit sink failed: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AuditSink: Send + Sync {
|
||||
async fn record(&self, event: AuditEvent) -> Result<(), AuditError>;
|
||||
|
||||
async fn list(&self, _query: AuditQuery) -> Result<AuditEventPage, AuditError> {
|
||||
Ok(AuditEventPage::default())
|
||||
}
|
||||
|
||||
async fn get(&self, _id: &AuditEventId) -> Result<Option<AuditEvent>, AuditError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedAuditSink = Arc<dyn AuditSink>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoopAuditSink;
|
||||
|
||||
#[async_trait]
|
||||
impl AuditSink for NoopAuditSink {
|
||||
async fn record(&self, _event: AuditEvent) -> Result<(), AuditError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse,
|
||||
MachineAccessMode, Membership, MembershipRole, OperationSecurityLevel, PlatformApiKeyScope,
|
||||
User, UserId, WorkspaceId,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifiedMachineCredential {
|
||||
pub machine_access_mode: MachineAccessMode,
|
||||
pub max_security_level: OperationSecurityLevel,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("machine credential verifier failed")]
|
||||
pub struct MachineCredentialVerifierError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait MachineCredentialVerifier: Send + Sync {
|
||||
async fn verify_bearer_token(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
token: &str,
|
||||
) -> Result<Option<VerifiedMachineCredential>, MachineCredentialVerifierError>;
|
||||
}
|
||||
|
||||
pub type SharedMachineCredentialVerifier = Arc<dyn MachineCredentialVerifier>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TokenIssuerActor {
|
||||
pub user_id: UserId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub role: MembershipRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum TokenIssuerError {
|
||||
#[error("machine tokens are not available in this edition")]
|
||||
NotSupportedInEdition,
|
||||
#[error("invalid grant: {0}")]
|
||||
InvalidGrant(String),
|
||||
#[error("agent key is unknown")]
|
||||
AgentKeyUnknown,
|
||||
#[error("operation is not strict")]
|
||||
OperationNotStrict,
|
||||
#[error("operation is not published for agent")]
|
||||
OperationNotPublishedForAgent,
|
||||
#[error("registry failure: {0}")]
|
||||
RegistryFailure(String),
|
||||
#[error("replay guard failure: {0}")]
|
||||
ReplayGuardFailure(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MachineTokenIssuer: Send + Sync {
|
||||
async fn issue_short_lived(
|
||||
&self,
|
||||
request: IssueAgentTokenRequest,
|
||||
actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError>;
|
||||
|
||||
async fn issue_one_time(
|
||||
&self,
|
||||
request: IssueOneTimeAgentTokenRequest,
|
||||
actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError>;
|
||||
}
|
||||
|
||||
pub type SharedMachineTokenIssuer = Arc<dyn MachineTokenIssuer>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoMachineTokenIssuer;
|
||||
|
||||
#[async_trait]
|
||||
impl MachineTokenIssuer for NoMachineTokenIssuer {
|
||||
async fn issue_short_lived(
|
||||
&self,
|
||||
_request: IssueAgentTokenRequest,
|
||||
_actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError> {
|
||||
Err(TokenIssuerError::NotSupportedInEdition)
|
||||
}
|
||||
|
||||
async fn issue_one_time(
|
||||
&self,
|
||||
_request: IssueOneTimeAgentTokenRequest,
|
||||
_actor: &TokenIssuerActor,
|
||||
) -> Result<IssuedAgentTokenResponse, TokenIssuerError> {
|
||||
Err(TokenIssuerError::NotSupportedInEdition)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IdentityProviderKind {
|
||||
Password,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuthenticatedIdentity {
|
||||
pub user: User,
|
||||
pub memberships: Vec<Membership>,
|
||||
pub current_workspace_id: Option<WorkspaceId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum IdentityError {
|
||||
#[error("not supported for this provider")]
|
||||
NotSupportedForProvider,
|
||||
#[error("bad credentials")]
|
||||
BadCredentials,
|
||||
#[error("account disabled")]
|
||||
AccountDisabled,
|
||||
#[error("internal: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LoginPayload {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SsoAuthorizeRequest {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub provider_id: String,
|
||||
pub base_origin: String,
|
||||
pub return_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SsoAuthorizeRedirect {
|
||||
pub authorization_url: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SsoCallbackRequest {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub provider_id: String,
|
||||
pub code: String,
|
||||
pub base_origin: String,
|
||||
pub return_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorPendingIdentity {
|
||||
pub user_id: UserId,
|
||||
pub workspace_id: Option<WorkspaceId>,
|
||||
pub return_to: Option<String>,
|
||||
pub expires_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorStatus {
|
||||
pub enabled: bool,
|
||||
pub pending_setup: bool,
|
||||
pub recovery_codes_remaining: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorSetup {
|
||||
pub secret: String,
|
||||
pub otpauth_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TwoFactorActivation {
|
||||
pub recovery_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct TwoFactorChallenge {
|
||||
pub code: Option<String>,
|
||||
pub recovery_code: Option<String>,
|
||||
}
|
||||
|
||||
pub enum LoginOutcome {
|
||||
Authenticated(AuthenticatedIdentity),
|
||||
TwoFactorRequired(TwoFactorPendingIdentity),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait IdentityProvider: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
|
||||
fn kind(&self) -> IdentityProviderKind;
|
||||
|
||||
async fn login_password(&self, payload: LoginPayload) -> Result<LoginOutcome, IdentityError>;
|
||||
|
||||
async fn begin_sso(
|
||||
&self,
|
||||
_request: SsoAuthorizeRequest,
|
||||
) -> Result<SsoAuthorizeRedirect, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn complete_sso(
|
||||
&self,
|
||||
_request: SsoCallbackRequest,
|
||||
) -> Result<LoginOutcome, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn get_two_factor_status(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
) -> Result<TwoFactorStatus, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn begin_two_factor_setup(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_email: &str,
|
||||
) -> Result<TwoFactorSetup, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn activate_two_factor(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_code: &str,
|
||||
) -> Result<TwoFactorActivation, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn disable_two_factor(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_challenge: TwoFactorChallenge,
|
||||
) -> Result<(), IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
|
||||
async fn verify_two_factor_login(
|
||||
&self,
|
||||
_pending: &TwoFactorPendingIdentity,
|
||||
_challenge: TwoFactorChallenge,
|
||||
) -> Result<AuthenticatedIdentity, IdentityError> {
|
||||
Err(IdentityError::NotSupportedForProvider)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedIdentityProvider = Arc<dyn IdentityProvider>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MachineTokenIssuer, NoMachineTokenIssuer, TokenIssuerActor, TokenIssuerError};
|
||||
use crate::{
|
||||
AgentTokenGrantType, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MembershipRole,
|
||||
UserId, WorkspaceId,
|
||||
};
|
||||
|
||||
fn actor() -> TokenIssuerActor {
|
||||
TokenIssuerActor {
|
||||
user_id: UserId::new("user_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
role: MembershipRole::Owner,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_machine_token_issuer_rejects_short_lived_issue() {
|
||||
let result = NoMachineTokenIssuer
|
||||
.issue_short_lived(
|
||||
IssueAgentTokenRequest {
|
||||
grant_type: AgentTokenGrantType::AgentKey,
|
||||
agent_key: Some("crk_agent_secret".to_owned()),
|
||||
refresh_token: None,
|
||||
scope: vec![],
|
||||
},
|
||||
&actor(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err(TokenIssuerError::NotSupportedInEdition));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_machine_token_issuer_rejects_one_time_issue() {
|
||||
let result = NoMachineTokenIssuer
|
||||
.issue_one_time(
|
||||
IssueOneTimeAgentTokenRequest {
|
||||
agent_key: "crk_agent_secret".to_owned(),
|
||||
operation_id: "op_01".into(),
|
||||
scope: vec![],
|
||||
},
|
||||
&actor(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err(TokenIssuerError::NotSupportedInEdition));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{TenantId, UsageRollup};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BillingGate {
|
||||
Allow,
|
||||
Deny { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum BillingError {
|
||||
#[error("billing integration is not available in this edition")]
|
||||
NotSupportedInEdition,
|
||||
#[error("billing provider failure: {0}")]
|
||||
Provider(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BillingHook: Send + Sync {
|
||||
async fn on_rollup(&self, rollup: UsageRollup) -> Result<(), BillingError>;
|
||||
|
||||
async fn check_billing_gate(&self, tenant: &TenantId) -> Result<BillingGate, BillingError>;
|
||||
}
|
||||
|
||||
pub type SharedBillingHook = Arc<dyn BillingHook>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoopBillingHook;
|
||||
|
||||
#[async_trait]
|
||||
impl BillingHook for NoopBillingHook {
|
||||
async fn on_rollup(&self, _rollup: UsageRollup) -> Result<(), BillingError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_billing_gate(&self, _tenant: &TenantId) -> Result<BillingGate, BillingError> {
|
||||
Ok(BillingGate::Allow)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BillingGate, BillingHook, NoopBillingHook};
|
||||
use crate::{TenantId, UsagePeriod, UsageRollup, WorkspaceId};
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_billing_hook_allows_default_gate_and_rollups() {
|
||||
let hook = NoopBillingHook;
|
||||
|
||||
hook.on_rollup(UsageRollup {
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: None,
|
||||
period: UsagePeriod::Last24Hours,
|
||||
calls_total: 10,
|
||||
calls_ok: 9,
|
||||
calls_error: 1,
|
||||
p50_ms: 12,
|
||||
p95_ms: 42,
|
||||
p99_ms: 77,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let gate = hook
|
||||
.check_billing_gate(&TenantId::new("tenant_01"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(gate, BillingGate::Allow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::{
|
||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
||||
Protocol,
|
||||
};
|
||||
|
||||
pub trait CapabilityProfile: Send + Sync {
|
||||
fn capabilities(&self) -> EditionCapabilities;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct CommunityCapabilityProfile;
|
||||
|
||||
impl CapabilityProfile for CommunityCapabilityProfile {
|
||||
fn capabilities(&self) -> EditionCapabilities {
|
||||
EditionCapabilities {
|
||||
edition: ProductEdition::Community,
|
||||
supported_protocols: vec![Protocol::Rest],
|
||||
supported_security_levels: vec![OperationSecurityLevel::Standard],
|
||||
machine_access_modes: vec![MachineAccessMode::StaticAgentKey],
|
||||
limits: EditionLimits {
|
||||
max_workspaces: Some(1),
|
||||
max_users_per_workspace: Some(1),
|
||||
max_agents_per_workspace: Some(1),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AgentId, InvocationSource, InvocationStatus, OperationId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MeteringEvent {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: OperationId,
|
||||
pub source: InvocationSource,
|
||||
pub status: InvocationStatus,
|
||||
pub duration_ms: u64,
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MeteringSink: Send + Sync {
|
||||
async fn record(&self, event: MeteringEvent);
|
||||
}
|
||||
|
||||
pub type SharedMeteringSink = Arc<dyn MeteringSink>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct NoopMeteringSink;
|
||||
|
||||
#[async_trait]
|
||||
impl MeteringSink for NoopMeteringSink {
|
||||
async fn record(&self, _event: MeteringEvent) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{MeteringEvent, MeteringSink, NoopMeteringSink};
|
||||
use crate::{InvocationSource, InvocationStatus, OperationId, WorkspaceId};
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_metering_sink_accepts_event() {
|
||||
NoopMeteringSink
|
||||
.record(MeteringEvent {
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
source: InvocationSource::AgentToolCall,
|
||||
status: InvocationStatus::Ok,
|
||||
duration_ms: 42,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod access;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod capability;
|
||||
pub mod metering;
|
||||
pub mod protocol;
|
||||
pub mod tenancy;
|
||||
@@ -0,0 +1,322 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
AgentId, ExecutionMode, InvocationSource, Protocol, StreamSession, Target, WorkspaceId,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct ResponseCacheScope {
|
||||
pub workspace_key: String,
|
||||
pub agent_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeRequestContext {
|
||||
pub request_id: String,
|
||||
pub correlation_id: String,
|
||||
pub response_cache_scope: Option<ResponseCacheScope>,
|
||||
pub metering_context: Option<MeteringContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MeteringContext {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub source: InvocationSource,
|
||||
}
|
||||
|
||||
impl RuntimeRequestContext {
|
||||
pub fn new(request_id: impl Into<String>, correlation_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
request_id: request_id.into(),
|
||||
correlation_id: correlation_id.into(),
|
||||
response_cache_scope: None,
|
||||
metering_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_request_id(request_id: impl Into<String>) -> Self {
|
||||
let request_id = request_id.into();
|
||||
Self::new(request_id.clone(), request_id)
|
||||
}
|
||||
|
||||
pub fn outbound_headers(&self) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("x-request-id".to_owned(), self.request_id.clone()),
|
||||
("x-correlation-id".to_owned(), self.correlation_id.clone()),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn with_response_cache_scope(
|
||||
mut self,
|
||||
workspace_key: impl Into<String>,
|
||||
agent_key: impl Into<String>,
|
||||
) -> Self {
|
||||
self.response_cache_scope = Some(ResponseCacheScope {
|
||||
workspace_key: workspace_key.into(),
|
||||
agent_key: agent_key.into(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn response_cache_scope(&self) -> Option<&ResponseCacheScope> {
|
||||
self.response_cache_scope.as_ref()
|
||||
}
|
||||
|
||||
pub fn with_metering_context(
|
||||
mut self,
|
||||
workspace_id: WorkspaceId,
|
||||
agent_id: Option<AgentId>,
|
||||
source: InvocationSource,
|
||||
) -> Self {
|
||||
self.metering_context = Some(MeteringContext {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
source,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn metering_context(&self) -> Option<&MeteringContext> {
|
||||
self.metering_context.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct PreparedRequest {
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub path_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub query_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grpc: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub variables: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AdapterResponse {
|
||||
pub status_code: u16,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body: Value,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WindowExecutionResult {
|
||||
pub summary: Value,
|
||||
pub items: Vec<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<Value>,
|
||||
pub window_complete: bool,
|
||||
pub truncated: bool,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum ProtocolAdapterError {
|
||||
#[error("protocol {protocol:?} does not support execution mode {mode:?}")]
|
||||
UnsupportedMode {
|
||||
protocol: Protocol,
|
||||
mode: ExecutionMode,
|
||||
},
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProtocolAdapter: Send + Sync {
|
||||
fn protocol(&self) -> Protocol;
|
||||
|
||||
fn supports_mode(&self, mode: ExecutionMode) -> bool;
|
||||
|
||||
async fn invoke_unary(
|
||||
&self,
|
||||
target: &Target,
|
||||
prepared: &PreparedRequest,
|
||||
context: &RuntimeRequestContext,
|
||||
) -> Result<AdapterResponse, ProtocolAdapterError>;
|
||||
|
||||
async fn invoke_window(
|
||||
&self,
|
||||
_target: &Target,
|
||||
_prepared: &PreparedRequest,
|
||||
_window_duration_ms: u64,
|
||||
_max_items: Option<u32>,
|
||||
_context: &RuntimeRequestContext,
|
||||
) -> Result<WindowExecutionResult, ProtocolAdapterError> {
|
||||
Err(ProtocolAdapterError::UnsupportedMode {
|
||||
protocol: self.protocol(),
|
||||
mode: ExecutionMode::Window,
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_session(
|
||||
&self,
|
||||
_target: &Target,
|
||||
_prepared: &PreparedRequest,
|
||||
_context: &RuntimeRequestContext,
|
||||
) -> Result<StreamSession, ProtocolAdapterError> {
|
||||
Err(ProtocolAdapterError::UnsupportedMode {
|
||||
protocol: self.protocol(),
|
||||
mode: ExecutionMode::Session,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedProtocolAdapter = Arc<dyn ProtocolAdapter>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AdapterRegistry {
|
||||
adapters: Vec<SharedProtocolAdapter>,
|
||||
}
|
||||
|
||||
impl AdapterRegistry {
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn register(mut self, adapter: SharedProtocolAdapter) -> Self {
|
||||
self.adapters
|
||||
.retain(|existing| existing.protocol() != adapter.protocol());
|
||||
self.adapters.push(adapter);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get(&self, protocol: Protocol) -> Option<SharedProtocolAdapter> {
|
||||
self.adapters
|
||||
.iter()
|
||||
.find(|adapter| adapter.protocol() == protocol)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn protocols(&self) -> Vec<Protocol> {
|
||||
self.adapters
|
||||
.iter()
|
||||
.map(|adapter| adapter.protocol())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
AdapterRegistry, AdapterResponse, PreparedRequest, ProtocolAdapter, ProtocolAdapterError,
|
||||
RuntimeRequestContext,
|
||||
};
|
||||
use crate::{
|
||||
ExecutionMode, HttpMethod, InvocationSource, Protocol, RestTarget, Target, WorkspaceId,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StubAdapter(Protocol);
|
||||
|
||||
#[async_trait]
|
||||
impl ProtocolAdapter for StubAdapter {
|
||||
fn protocol(&self) -> Protocol {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn supports_mode(&self, mode: ExecutionMode) -> bool {
|
||||
matches!(mode, ExecutionMode::Unary)
|
||||
}
|
||||
|
||||
async fn invoke_unary(
|
||||
&self,
|
||||
_target: &Target,
|
||||
_prepared: &PreparedRequest,
|
||||
_context: &RuntimeRequestContext,
|
||||
) -> Result<AdapterResponse, ProtocolAdapterError> {
|
||||
Ok(AdapterResponse {
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body: json!({"ok": true}),
|
||||
data: json!({"ok": true}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_returns_registered_protocols() {
|
||||
let registry = AdapterRegistry::empty()
|
||||
.register(Arc::new(StubAdapter(Protocol::Rest)))
|
||||
.register(Arc::new(StubAdapter(Protocol::Graphql)));
|
||||
|
||||
assert_eq!(
|
||||
registry.protocols(),
|
||||
vec![Protocol::Rest, Protocol::Graphql]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_replaces_adapter_for_same_protocol() {
|
||||
let registry = AdapterRegistry::empty()
|
||||
.register(Arc::new(StubAdapter(Protocol::Rest)))
|
||||
.register(Arc::new(StubAdapter(Protocol::Rest)));
|
||||
|
||||
assert_eq!(registry.protocols(), vec![Protocol::Rest]);
|
||||
assert!(registry.get(Protocol::Rest).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_context_attaches_response_cache_scope() {
|
||||
let context = RuntimeRequestContext::from_request_id("req_123")
|
||||
.with_response_cache_scope("ws_01", "agent_01");
|
||||
|
||||
let scope = context.response_cache_scope().unwrap();
|
||||
assert_eq!(scope.workspace_key, "ws_01");
|
||||
assert_eq!(scope.agent_key, "agent_01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_context_attaches_metering_context() {
|
||||
let context = RuntimeRequestContext::from_request_id("req_123").with_metering_context(
|
||||
WorkspaceId::new("ws_01"),
|
||||
None,
|
||||
InvocationSource::AgentToolCall,
|
||||
);
|
||||
|
||||
let metering = context.metering_context().unwrap();
|
||||
assert_eq!(metering.workspace_id, WorkspaceId::new("ws_01"));
|
||||
assert_eq!(metering.agent_id, None);
|
||||
assert_eq!(metering.source, InvocationSource::AgentToolCall);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_returns_registered_adapter() {
|
||||
let registry = AdapterRegistry::empty().register(Arc::new(StubAdapter(Protocol::Rest)));
|
||||
let adapter = registry.get(Protocol::Rest).unwrap();
|
||||
|
||||
let response = adapter
|
||||
.invoke_unary(
|
||||
&Target::Rest(RestTarget {
|
||||
base_url: "https://example.test".to_owned(),
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/health".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
}),
|
||||
&PreparedRequest::default(),
|
||||
&RuntimeRequestContext::from_request_id("req_1"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{TenantId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TenantResolutionContext {
|
||||
pub workspace_id: Option<WorkspaceId>,
|
||||
pub workspace_slug: Option<String>,
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum TenancyError {
|
||||
#[error("tenant lookup is not supported in this edition")]
|
||||
UnsupportedLookup,
|
||||
#[error("internal tenancy failure: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TenantController: Send + Sync {
|
||||
fn resolve_tenant(&self, request: &TenantResolutionContext) -> Result<TenantId, TenancyError>;
|
||||
|
||||
async fn list_workspaces_for_tenant(
|
||||
&self,
|
||||
tenant: &TenantId,
|
||||
) -> Result<Vec<WorkspaceId>, TenancyError>;
|
||||
}
|
||||
|
||||
pub type SharedTenantController = Arc<dyn TenantController>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SingleTenantController {
|
||||
tenant_id: TenantId,
|
||||
}
|
||||
|
||||
impl Default for SingleTenantController {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tenant_id: TenantId::new("default"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TenantController for SingleTenantController {
|
||||
fn resolve_tenant(&self, _request: &TenantResolutionContext) -> Result<TenantId, TenancyError> {
|
||||
Ok(self.tenant_id.clone())
|
||||
}
|
||||
|
||||
async fn list_workspaces_for_tenant(
|
||||
&self,
|
||||
_tenant: &TenantId,
|
||||
) -> Result<Vec<WorkspaceId>, TenancyError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SingleTenantController, TenantController, TenantResolutionContext};
|
||||
use crate::TenantId;
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_tenant_controller_returns_default_tenant() {
|
||||
let controller = SingleTenantController::default();
|
||||
|
||||
let tenant = controller
|
||||
.resolve_tenant(&TenantResolutionContext::default())
|
||||
.unwrap();
|
||||
let workspaces = controller
|
||||
.list_workspaces_for_tenant(&tenant)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tenant, TenantId::new("default"));
|
||||
assert!(workspaces.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user