Service identity

So far, every order stays reserved until it expires. Fans need to pay. Card payments belong to the payments team, which runs its own service. The box office asks it to start a payment, and later the payments service calls back to confirm it. Each side needs to know it's really talking to the other.

Margaret uses SPIFFE for this. Every service gets an identity, like spiffe://example.org/payments, from a SPIRE agent, and services prove it to each other with mutual TLS. Add spiffe (with the spiffe-types feature) to your dependencies.

Calling other services

Ask for a Client marked with #[spiffe_http_client]. It presents the box office's identity, and only trusts servers from your trust domain:

#[singleton]
pub struct PaymentsClient {
    http: Client,
    payments_url: Url,
}

impl PaymentsClient {
    #[constructor]
    pub fn create(
        #[spiffe_http_client] http: Client,
        #[console_argument(from = "payments-url")] payments_url: Url,
    ) -> anyhow::Result<Self> {
        Ok(Self { http, payments_url })
    }

    pub async fn start_payment(&self, order: &Order, total: Decimal) -> anyhow::Result<Payment> {
        Ok(self
            .http
            .post(self.payments_url.join("payments")?)
            .json(&json!({ "order_id": order.id, "amount": total, "currency": "EUR" }))
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?)
    }
}

The order page gets a "Pay now" button for reserved orders. It posts to a route that starts the payment and sends the customer to the payment page:

    #[process]
    pub async fn respond(
        &self,
        #[authenticated_user] customer: Customer,
        #[route_parameter(from = "order")] OrderDetails {
            order,
            concert: ConcertWithVenue { concert, .. },
            tickets,
        }: OrderDetails,
    ) -> anyhow::Result<ResponseContinuation> {
        if order.customer != Key::of(&customer) {
            return Ok(Response::not_found().into());
        }

        if order.status != OrderStatus::Reserved {
            return Ok(Response::text(409, "This order is already paid.").into());
        }

        let total = concert.price * Decimal::from(tickets.records.len());
        let payment = self.payments.start_payment(&order, total).await?;

        Ok(Redirect::see_other(payment.checkout_url).into())
    }

Knowing who's calling

When the payments service confirms a payment, it calls a route on a new internal server. The route asks for the caller's &SpiffeId, and accepts only the payments service:

#[singleton]
#[responds_to_http(
    max_body_bytes = 1_024,
    method = RouteMethod::Post,
    path = "/payments/confirmed",
    server = "internal"
)]
pub struct ConfirmPayment {
    orders: Arc<OrderRepository>,
    payments_service: SpiffeId,
}

impl ConfirmPayment {
    #[constructor]
    pub fn create(
        orders: Arc<OrderRepository>,
        #[console_argument(from = "payments-spiffe-id")] payments_service: SpiffeId,
    ) -> anyhow::Result<Self> {
        Ok(Self {
            orders,
            payments_service,
        })
    }

    #[process]
    pub async fn respond(
        &self,
        peer: &SpiffeId,
        #[form_request(from = RequestInput::Json)] confirmation: PaymentConfirmation,
    ) -> anyhow::Result<Response> {
        if peer != &self.payments_service {
            return Ok(Response::forbidden());
        }

        Ok(match self.orders.mark_paid(confirmation.order_id).await? {
            true => Response::text(200, "Order paid."),
            false => Response::text(409, "This order can't be paid anymore."),
        })
    }
}

Because a route on internal needs to know the caller, Margaret makes sure that server only ever runs with mutual TLS. Callers without a certificate from your trust domain can't even connect.

Updating a row only when it's still valid

A payment can arrive late, after the reservation expired and the seats went to someone else. So the order is marked paid only if it's still reserved and still within its time. .when(...) puts the condition on the update itself, so nothing can change the order between the check and the write:

    pub async fn mark_paid(&self, order_id: Uuid) -> Result<bool, ActiveRecordError> {
        let now = self.clock.now();

        Ok(
            match Order::query()
                .id
                .eq(order_id)
                .when(|order| {
                    order
                        .status
                        .eq(OrderStatus::Reserved)
                        .and(order.reserved_until.above(now))
                })
                .update(self.database.as_ref(), |columns| {
                    columns.status.to(OrderStatus::Paid)
                })
                .await?
            {
                Change::Changed(_) => true,
                Change::Unmatched => false,
            },
        )
    }

update answers Change::Changed with the updated order, or Change::Unmatched when the condition didn't hold.

Running with SPIRE

serve now asks where the SPIRE agent is, and which transport each server uses. internal only accepts spiffe_mtls:

$ box_office serve \
    --public-addr 0.0.0.0:8080 --public-url https://tickets.example.com --public-transport plain \
    --admin-addr 10.0.0.5:9090 --admin-url https://admin.tickets.internal --admin-transport plain \
    --internal-addr 0.0.0.0:9443 --internal-url https://box-office.internal:9443 \
    --internal-transport spiffe_mtls \
    --spiffe-trust-domain example.org \
    --spire-agent-addr unix:///run/spire/agent.sock \
    --payments-url https://payments.internal/ \
    --payments-spiffe-id spiffe://example.org/payments \
    ...

serve waits for the SPIRE agent to hand out the box office's certificate before it starts listening, and certificates rotate automatically while it runs.