lenso-service 0.1.21

Public contracts for Lenso Providers and Autonomous Services.
Documentation
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use ed25519_dalek::{Signer as _, SigningKey};
use lenso_service::{
    ContextClaimProof, DelegatedActorContext, DelegatedActorCredentialRequest,
    DelegatedContextErrorCode, DelegatedContextIssuer, DelegatedContextVerifier,
    Ed25519DelegatedContextVerifier, ServiceContextPolicy, ServiceTenancyMode,
    SystemSandboxDelegatedContextProvider, TenantContext, TenantCredentialRequest,
    delegated_actor_signing_bytes, tenant_context_signing_bytes,
};

fn provider() -> SystemSandboxDelegatedContextProvider {
    SystemSandboxDelegatedContextProvider::new("local", "delegated-context-secret").unwrap()
}

fn actor(provider: &SystemSandboxDelegatedContextProvider) -> lenso_service::DelegatedActorContext {
    provider
        .issue_actor(DelegatedActorCredentialRequest::new(
            "user_01",
            "service:support",
            "support.ticket.update",
            ["support.tickets.read", "support.tickets.update"],
            1_000,
            30_000,
        ))
        .unwrap()
}

fn tenant(provider: &SystemSandboxDelegatedContextProvider) -> lenso_service::TenantContext {
    provider
        .issue_tenant(TenantCredentialRequest::new(
            "tenant_01",
            "user_01",
            "delegation_1",
            "service:support",
            1_000,
            30_000,
        ))
        .unwrap()
}

fn policy(mode: ServiceTenancyMode) -> ServiceContextPolicy {
    ServiceContextPolicy::new(
        "service:support",
        "support.ticket.update",
        ["support.tickets.update"],
        ["support.tickets.read", "support.tickets.update"],
        mode,
    )
}

#[test]
fn valid_delegated_actor_and_required_tenant_are_verified() {
    let provider = provider();
    let actor = actor(&provider);
    let tenant = tenant(&provider);

    let admitted = policy(ServiceTenancyMode::Required)
        .verify(&provider, Some(&actor), Some(&tenant), 2_000)
        .unwrap();

    assert_eq!(admitted.actor.subject, "user_01");
    assert_eq!(admitted.actor.intent, "support.ticket.update");
    assert_eq!(admitted.tenant.unwrap().tenant_id, "tenant_01");
    assert_eq!(admitted.evidence.outcome, "identity_context_accepted");
    assert_eq!(
        admitted.evidence.delegation_id.as_deref(),
        Some("delegation_1")
    );
    assert_eq!(
        admitted.evidence.tenant_claim_id.as_deref(),
        Some("tenant_claim_1")
    );
}

#[test]
fn delegation_rejects_wrong_audience_overbroad_permission_and_wrong_intent() {
    let provider = provider();
    let tenant = tenant(&provider);

    let wrong_audience = provider
        .issue_actor(DelegatedActorCredentialRequest::new(
            "user_01",
            "service:billing",
            "support.ticket.update",
            ["support.tickets.update"],
            1_000,
            30_000,
        ))
        .unwrap();
    let overbroad = provider
        .issue_actor(DelegatedActorCredentialRequest::new(
            "user_01",
            "service:support",
            "support.ticket.update",
            ["support.tickets.update", "support.admin"],
            1_000,
            30_000,
        ))
        .unwrap();
    let wrong_intent = provider
        .issue_actor(DelegatedActorCredentialRequest::new(
            "user_01",
            "service:support",
            "support.ticket.delete",
            ["support.tickets.update"],
            1_000,
            30_000,
        ))
        .unwrap();

    for (actor, code) in [
        (wrong_audience, DelegatedContextErrorCode::AudienceMismatch),
        (overbroad, DelegatedContextErrorCode::OverbroadPermissions),
        (wrong_intent, DelegatedContextErrorCode::IntentMismatch),
    ] {
        assert_eq!(
            policy(ServiceTenancyMode::Required)
                .verify(&provider, Some(&actor), Some(&tenant), 2_000)
                .unwrap_err()
                .code,
            code
        );
    }
}

#[test]
fn tenancy_modes_require_preserve_or_disallow_explicit_scope() {
    let provider = provider();
    let actor = actor(&provider);
    let tenant = tenant(&provider);

    assert_eq!(
        policy(ServiceTenancyMode::Required)
            .verify(&provider, Some(&actor), None, 2_000)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::TenantRequired
    );
    assert_eq!(
        policy(ServiceTenancyMode::Optional)
            .verify(&provider, Some(&actor), Some(&tenant), 2_000)
            .unwrap()
            .tenant
            .unwrap()
            .tenant_id,
        "tenant_01"
    );
    assert!(
        policy(ServiceTenancyMode::Optional)
            .verify(&provider, Some(&actor), None, 2_000)
            .unwrap()
            .tenant
            .is_none()
    );
    assert_eq!(
        policy(ServiceTenancyMode::None)
            .verify(&provider, Some(&actor), Some(&tenant), 2_000)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::TenantIncompatible
    );
    assert!(
        policy(ServiceTenancyMode::None)
            .verify(&provider, Some(&actor), None, 2_000)
            .unwrap()
            .tenant
            .is_none()
    );
}

#[test]
fn tenant_context_cannot_be_spliced_across_actor_delegations() {
    let provider = provider();
    let actor = actor(&provider);
    let tenant = provider
        .issue_tenant(TenantCredentialRequest::new(
            "tenant_01",
            "user_02",
            "delegation_1",
            "service:support",
            1_000,
            30_000,
        ))
        .unwrap();

    assert_eq!(
        policy(ServiceTenancyMode::Required)
            .verify(&provider, Some(&actor), Some(&tenant), 2_000)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::TenantIncompatible
    );
}

#[test]
fn invalid_proof_expiry_and_missing_delegation_are_rejected_without_secrets_in_evidence() {
    let provider = provider();
    let tenant = tenant(&provider);
    let mut invalid = actor(&provider);
    invalid.proof.signature.push_str("tampered");

    for (actor, now, code) in [
        (None, 2_000, DelegatedContextErrorCode::DelegationRequired),
        (
            Some(&invalid),
            2_000,
            DelegatedContextErrorCode::InvalidProof,
        ),
        (
            Some(&actor(&provider)),
            40_000,
            DelegatedContextErrorCode::CredentialExpired,
        ),
    ] {
        let error = policy(ServiceTenancyMode::Required)
            .verify(&provider, actor, Some(&tenant), now)
            .unwrap_err();
        assert_eq!(error.code, code);
        let evidence = serde_json::to_string(&error.evidence).unwrap();
        assert!(!evidence.contains("eyJ"));
        assert!(!evidence.contains("delegated-context-secret"));
    }
}

fn public_key(key: &SigningKey) -> String {
    URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes())
}

fn ed25519_actor(key: &SigningKey, method: &str, delegation_id: &str) -> DelegatedActorContext {
    let mut context = DelegatedActorContext {
        issuer: "console:acme".to_owned(),
        subject: "operator:alice".to_owned(),
        audiences: vec!["service:support".to_owned()],
        intent: "support.ticket.update".to_owned(),
        permissions: vec!["support.tickets.update".to_owned()],
        expires_at_unix_ms: 31_000,
        delegation_id: delegation_id.to_owned(),
        proof: ContextClaimProof {
            verification_method: method.to_owned(),
            algorithm: "Ed25519".to_owned(),
            signature: String::new(),
        },
    };
    context.proof.signature = URL_SAFE_NO_PAD.encode(
        key.sign(&delegated_actor_signing_bytes(&context).unwrap())
            .to_bytes(),
    );
    context
}

fn ed25519_tenant(key: &SigningKey, method: &str, delegation_id: &str) -> TenantContext {
    let mut context = TenantContext {
        issuer: "console:acme".to_owned(),
        tenant_id: "tenant_01".to_owned(),
        actor_subject: "operator:alice".to_owned(),
        delegation_id: delegation_id.to_owned(),
        audiences: vec!["service:support".to_owned()],
        expires_at_unix_ms: 30_000,
        claim_id: "tenant-claim-1".to_owned(),
        proof: ContextClaimProof {
            verification_method: method.to_owned(),
            algorithm: "Ed25519".to_owned(),
            signature: String::new(),
        },
    };
    context.proof.signature = URL_SAFE_NO_PAD.encode(
        key.sign(&tenant_context_signing_bytes(&context).unwrap())
            .to_bytes(),
    );
    context
}

#[test]
fn production_verifier_accepts_overlapping_keys_then_rejects_the_retired_key() {
    let old = SigningKey::from_bytes(&[7; 32]);
    let active = SigningKey::from_bytes(&[9; 32]);
    let rotating = Ed25519DelegatedContextVerifier::from_base64_public_keys([
        ("console:acme", "console-key-old", public_key(&old)),
        ("console:acme", "console-key-active", public_key(&active)),
    ])
    .unwrap();
    let old_actor = ed25519_actor(&old, "console-key-old", "delegation-old");
    let active_actor = ed25519_actor(&active, "console-key-active", "delegation-active");
    rotating
        .verify_actor(&old_actor, "service:support", 2_000)
        .unwrap();
    rotating
        .verify_actor(&active_actor, "service:support", 2_000)
        .unwrap();

    let after_rotation = Ed25519DelegatedContextVerifier::from_base64_public_keys([(
        "console:acme",
        "console-key-active",
        public_key(&active),
    )])
    .unwrap();
    assert_eq!(
        after_rotation
            .verify_actor(&old_actor, "service:support", 2_000)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::InvalidProof
    );
    after_rotation
        .verify_actor(&active_actor, "service:support", 2_000)
        .unwrap();
}

#[test]
fn production_verifier_binds_every_actor_and_tenant_field_and_domain() {
    let key = SigningKey::from_bytes(&[11; 32]);
    let verifier = Ed25519DelegatedContextVerifier::from_base64_public_keys([(
        "console:acme",
        "console-key-1",
        public_key(&key),
    )])
    .unwrap();
    let actor = ed25519_actor(&key, "console-key-1", "delegation-1");
    let tenant = ed25519_tenant(&key, "console-key-1", "delegation-1");
    let admitted = policy(ServiceTenancyMode::Required)
        .verify(&verifier, Some(&actor), Some(&tenant), 2_000)
        .unwrap();
    assert_eq!(admitted.actor.subject, "operator:alice");
    assert_eq!(admitted.tenant.unwrap().tenant_id, "tenant_01");

    let mut altered = actor.clone();
    altered.permissions.push("support.admin".to_owned());
    assert_eq!(
        verifier
            .verify_actor(&altered, "service:support", 2_000)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::InvalidProof
    );

    let mut cross_domain = tenant;
    cross_domain.proof.signature = actor.proof.signature;
    assert_eq!(
        verifier
            .verify_tenant(&cross_domain, "service:support", 2_000)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::InvalidProof
    );
}

#[test]
fn production_signing_payloads_are_stable_and_domain_separated() {
    let key = SigningKey::from_bytes(&[13; 32]);
    let actor = ed25519_actor(&key, "console-key-1", "delegation-1");
    let tenant = ed25519_tenant(&key, "console-key-1", "delegation-1");

    assert_eq!(
        String::from_utf8(delegated_actor_signing_bytes(&actor).unwrap()).unwrap(),
        r#"{"audiences":["service:support"],"delegationId":"delegation-1","expiresAtUnixMs":31000,"intent":"support.ticket.update","issuer":"console:acme","permissions":["support.tickets.update"],"protocol":"lenso.delegated-actor-context.ed25519.v1","subject":"operator:alice"}"#
    );
    assert_eq!(
        String::from_utf8(tenant_context_signing_bytes(&tenant).unwrap()).unwrap(),
        r#"{"actorSubject":"operator:alice","audiences":["service:support"],"claimId":"tenant-claim-1","delegationId":"delegation-1","expiresAtUnixMs":30000,"issuer":"console:acme","protocol":"lenso.tenant-context.ed25519.v1","tenantId":"tenant_01"}"#
    );
}

#[test]
fn production_verifier_checks_audience_and_expiry_after_valid_signature() {
    let key = SigningKey::from_bytes(&[17; 32]);
    let verifier = Ed25519DelegatedContextVerifier::from_base64_public_keys([(
        "console:acme",
        "console-key-1",
        public_key(&key),
    )])
    .unwrap();
    let actor = ed25519_actor(&key, "console-key-1", "delegation-1");

    assert_eq!(
        verifier
            .verify_actor(&actor, "service:billing", 2_000)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::AudienceMismatch
    );
    assert_eq!(
        verifier
            .verify_actor(&actor, "service:support", 31_001)
            .unwrap_err()
            .code,
        DelegatedContextErrorCode::CredentialExpired
    );
}