pub struct Order {Show 19 fields
pub id: Uuid,
pub profile: String,
pub account_id: Uuid,
pub status: OrderStatus,
pub identifiers: Vec<Identifier>,
pub expires: i64,
pub not_before: Option<i64>,
pub not_after: Option<i64>,
pub error: Option<Value>,
pub certificate: Option<String>,
pub replaces: Option<String>,
pub cert_serial: Option<String>,
pub cert_pubkey: Option<Vec<u8>>,
pub cert_not_after: Option<i64>,
pub revoked_at: Option<i64>,
pub revocation_reason: Option<i64>,
pub created_at: i64,
pub created_ip: Option<String>,
pub created_ptr: Option<String>,
}Expand description
An ACME order (RFC 8555 §7.1.3). A new order is created in the pending
state with one authorization per identifier; once every authorization is
valid (its http-01 challenge triggered) the order moves to ready and is
finalizable.
§Storage Details
identifiersis persisted as a JSON array of{type, value}objects.erroris a nullable JSON problem document (set if issuance fails).certificateholds the issued PEM chain, null until finalized.cert_serial/cert_pubkeyare populated alongsidecertificate(byOrder::finalize) from the leaf’s own serial (hex) and DER-SPKI public key — the former is how aPOST /revokeCertrequest is looked up (Order::find_by_cert_serial), the latter is how it can be authorized by the certificate’s own key pair (RFC 8555 §7.6’s accountless case).cert_not_afteris populated the same way and from the same leaf, and is notnot_after: that one is the validity the client asked for (§7.4), usually absent and clamped by the signer when it is not, where this is what the certificate says.NULLmeans the row predates the column and nothing has parsed its chain yet; a negative value means the sweep parsed it and could not (see the migration).revoked_at/revocation_reasonare this order’s own revocation bookkeeping (Order::revoke), orthogonal tostatus: RFC 8555 defines no “revoked” order status, so a revoked order’sstatusstaysvalid.- Timestamps are epoch seconds, matching accounts/nonces, and rendered as
RFC3339 datetime strings in
Order::to_json. - The
authorizationsURLs are derived from the order’s authorization ids (looked up separately and passed intoOrder::to_json); thefinalize/certificateURLs are derived from the id + base URL, never stored (likeAccount’sordersURL).
Fields§
§id: Uuid§profile: StringThe ACME endpoint ([profiles.<name>]) this order was placed at. It
always matches the owning account’s own profile — the redundancy is
what lets the two lookups that take no account (find_by_cert_serial,
for revocation, and ARI) stay scoped to one endpoint.
account_id: Uuid§status: OrderStatus§identifiers: Vec<Identifier>§expires: i64§not_before: Option<i64>§not_after: Option<i64>§error: Option<Value>§certificate: Option<String>§replaces: Option<String>The RFC 9773 §5 certID of the certificate this order is meant to
replace, when the client named one. Reflected back in Order::to_json
because §5 requires it: “If the server accepts a newOrder request with a
replaces field, it MUST reflect that field in the response and in
subsequent requests for the corresponding Order object.”
cert_serial: Option<String>§cert_pubkey: Option<Vec<u8>>§cert_not_after: Option<i64>The leaf’s own notAfter, epoch seconds. None on a row finalized before
the column existed (the digest’s backfill stamps those), and
UNPARSABLE_NOT_AFTER once the backfill has looked and failed.
revoked_at: Option<i64>§revocation_reason: Option<i64>§created_at: i64§created_ip: Option<String>Where newOrder was called from, and the reverse name that address had
at the time. Traceability only, never compared, and deliberately never
rendered by Order::to_json — see the schema comment in
migrations/20260725120000_add_orders.sql. There is no update-side
pair: the moment that matters after creation is issuance, which is an
audit_log row carrying its own address.
created_ptr: Option<String>Implementations§
Source§impl Order
impl Order
Sourcepub async fn create(
profile: &str,
account_id: Uuid,
identifiers: Vec<Identifier>,
expires: i64,
not_before: Option<i64>,
not_after: Option<i64>,
database: &Database,
) -> Result<Order, Error>
pub async fn create( profile: &str, account_id: Uuid, identifiers: Vec<Identifier>, expires: i64, not_before: Option<i64>, not_after: Option<i64>, database: &Database, ) -> Result<Order, Error>
Creates a new order in the pending state (its authorizations are created
separately by the caller) and returns it.
pub async fn find_by_id( id: &str, database: &Database, ) -> Result<Option<Order>, Error>
Sourcepub async fn find_by_account(
account_id: Uuid,
database: &Database,
) -> Result<Vec<Order>, Error>
pub async fn find_by_account( account_id: Uuid, database: &Database, ) -> Result<Vec<Order>, Error>
Every order belonging to an account, newest first.
Unfiltered on purpose: the admin CLI counts these to tell an operator
what a DELETE will cascade, and a filtered count would understate it.
The client-facing order-list URL wants
Order::find_active_by_account instead.
Sourcepub async fn find_active_by_account(
account_id: Uuid,
database: &Database,
) -> Result<Vec<Order>, Error>
pub async fn find_active_by_account( account_id: Uuid, database: &Database, ) -> Result<Vec<Order>, Error>
An account’s orders that are still worth a client’s attention, newest first — what the RFC 8555 §7.1.2.1 order-list URL serves.
§7.1.2.1: “The server SHOULD include pending orders and SHOULD NOT
include orders that are invalid in the array of URLs.” Expired orders go
too: load_owned_order refuses one unless it is already valid, so
listing it would hand the client a URL that only ever answers with an
error.
valid orders are kept whatever their expires, since the order
object’s expiry is housekeeping (order.validity_seconds) and the
certificate it points at outlives it — that URL still works.
Sourcepub async fn search(
query: &OrderQuery,
database: &Database,
) -> Result<(Vec<Order>, i64), Error>
pub async fn search( query: &OrderQuery, database: &Database, ) -> Result<(Vec<Order>, i64), Error>
One page of orders matching query, plus the total the same predicate
matches unpaged.
The only cross-account listing this model offers. An unpaged
list_all stood beside it, oldest first, until both front ends took a
window; orders grows a row per issuance forever, so an operator
opening either surface on a year-old deployment would otherwise pull the
whole table into memory and render it.
The filters are in SQL rather than applied afterwards for the same
reason they have to be: filtering a page in memory would make the page
size wrong. It is also the only implementation of that policy — the
CLI’s order list used to hold a second one in Rust over the unpaged
listing, which meant one meaning of --status written twice and a whole
table loaded to filter three fields.
Built with a sqlx::QueryBuilder: sqlx::query takes only
&'static str, and every value below goes through push_bind, so
nothing operator- or client-supplied is ever interpolated into the SQL.
Sourcepub async fn cleanup(
profile: &str,
cutoff: i64,
database: &Database,
) -> Result<u64, Error>
pub async fn cleanup( profile: &str, cutoff: i64, database: &Database, ) -> Result<u64, Error>
Deletes this profile’s orders that expired before cutoff, returning
how many went. The authorizations and challenges beneath them go
with the row, through the schema’s ON DELETE CASCADE.
valid is excluded, whatever the age. A valid order’s row is how
Order::find_by_cert_serial resolves a certificate for revokeCert and
for the CRL, and what RFC 9773 renewal information is derived from —
deleting one would make an issued certificate unrevokable and
unrenewable, which is a far worse outcome than a large table. Everything
else is an order no client can act on any more: invalid is terminal,
and a pending/ready/processing order past its own expires is
refused on read by every handler that loads one.
Scoped to one profile because order.retention_days is a per-profile
key: two endpoints in one process may reasonably keep their history for
different lengths of time.
Sourcepub async fn count_by_account(
account_id: Uuid,
database: &Database,
) -> Result<i64, Error>
pub async fn count_by_account( account_id: Uuid, database: &Database, ) -> Result<i64, Error>
How many orders an account has.
COUNT(*), not find_by_account(..).len(): the only two callers want a
number, and loading every row means deserializing each one’s
identifiers JSON to throw it away.
Sourcepub async fn delete(id: &str, database: &Database) -> Result<bool, Error>
pub async fn delete(id: &str, database: &Database) -> Result<bool, Error>
Hard-deletes the order row — cascading, via ON DELETE CASCADE, to its
authorizations and challenges. Returns whether a row existed to delete.
Sourcepub async fn finalize(
&mut self,
chain: String,
cert_serial: String,
cert_pubkey: Vec<u8>,
cert_not_after: Option<i64>,
database: &Database,
) -> Result<(), Error>
pub async fn finalize( &mut self, chain: String, cert_serial: String, cert_pubkey: Vec<u8>, cert_not_after: Option<i64>, database: &Database, ) -> Result<(), Error>
Records a successful issuance: stores the PEM chain plus the leaf’s
cert_serial (hex), cert_pubkey (DER SPKI) and cert_not_after —
all three populated by the caller from that same chain via
crate::cert::cert_serial_and_spki and crate::cert::cert_validity,
since parsing needs error handling the DB layer doesn’t otherwise deal
in — moves the order to the terminal valid state, and keeps self
in sync so a following Order::to_json reflects the change without
a re-read.
cert_not_after is Option where the other two are not, and the
asymmetry is deliberate: the serial and the public key are what make a
certificate revocable, so a chain they cannot be read from is a failed
issuance, while the expiry is housekeeping for the digest and a leaf
this server cannot read the validity of must still be recorded as
issued. Callers pass None rather than refusing — the rule
LocalCa::revoke already follows when it records a revoked leaf’s
expiry, and the sweep will try the chain again later.
Sourcepub async fn find_expiring(
profile: Option<&str>,
before: i64,
limit: i64,
offset: i64,
database: &Database,
) -> Result<(Vec<Order>, i64), Error>
pub async fn find_expiring( profile: Option<&str>, before: i64, limit: i64, offset: i64, database: &Database, ) -> Result<(Vec<Order>, i64), Error>
The certificates expiring at or before before, soonest first, with the
unpaged total beside the page — the digest’s whole query
([notify.expiry], crate::notify::expiry) and the admin surfaces’
(GET /api/expiring, /ui/expiring, order list --expiring-in).
Three predicates, each carrying its own reason. certificate IS NOT NULL because an order that never issued has nothing to expire;
revoked_at IS NULL because a withdrawn certificate is not something to
go and renew; and cert_not_after >= 0 because a negative value is the
sweep’s sentinel for a chain it could not parse, which is a row to leave
alone rather than to report as expiring in 1970. They are exactly the
partial index’s own predicate.
profile is an Option because the panel lists every endpoint by
default, like every other admin listing, where the digest asks one
profile at a time. The two forms cost different things, and the
difference is the index’s column order:
SomeisSEARCH … USING INDEX idx_orders_cert_not_after (profile=? AND cert_not_after>? AND cert_not_after<?)— a range seek on both columns, and byte for byte the plan the digest’s original query got.NoneisSCAN … USING INDEX idx_orders_cert_not_after: the partial predicate still matches, so the index is still what is read, but with no leading-column equality there is nothing to seek to, and the index is ordered bycert_not_afteronly within a profile — so the ordering below falls to a temp b-tree over the whole result rather than over its last term alone.
That is the price of the unscoped view, and it is stated here rather
than left to be rediscovered from a query plan. A profile-less index on
cert_not_after would buy it back, and is not worth a second index on
a table this one already covers until a deployment says otherwise.
The total is counted rather than derived from the page, so “…and N more”
can be honest without loading a tail nobody will read. id breaks the
ordering tie for Order::search’s reason: two certificates can share
a whole-second expiry, and a stable order is what stops one of them
being dropped between the page and the count.
Sourcepub async fn find_unstamped(
profile: &str,
limit: i64,
database: &Database,
) -> Result<Vec<(Uuid, String)>, Error>
pub async fn find_unstamped( profile: &str, limit: i64, database: &Database, ) -> Result<Vec<(Uuid, String)>, Error>
The issued orders on profile whose cert_not_after has never been
derived — rows finalized before the column existed. At most limit per
call, since this parses an X.509 chain per row and the digest that calls
it is not the only thing the runner has to do.
Returns (id, chain) pairs rather than whole orders: the caller wants
the PEM and nothing else, and a digest running against a long-lived
deployment would otherwise inflate every row it is about to discard.
Sourcepub async fn set_cert_not_after(
id: Uuid,
cert_not_after: i64,
database: &Database,
) -> Result<(), Error>
pub async fn set_cert_not_after( id: Uuid, cert_not_after: i64, database: &Database, ) -> Result<(), Error>
Writes a cert_not_after derived after the fact by the sweep.
Separate from Order::finalize because it is a backfill and not an
issuance: it touches one column, never status, and it is the one
caller that legitimately writes the negative sentinel — a chain that
will not parse has to be recorded as unparsable, or every pass parses
it again for the life of the deployment.
Sourcepub async fn find_by_cert_serial(
profile: &str,
serial: &str,
database: &Database,
) -> Result<Option<Order>, Error>
pub async fn find_by_cert_serial( profile: &str, serial: &str, database: &Database, ) -> Result<Option<Order>, Error>
Looks up the order whose stored certificate carries serial (hex,
matching cert_serial’s format) — indexed, so a POST /revokeCert
request is not a full table scan across every issued order. Not
proof of identity on its own: the caller must additionally compare the
submitted certificate’s DER against the returned order’s stored chain
byte-for-byte (a random-serial collision, or a crafted certificate
reusing a real serial, are not ruled out by the serial alone).
Scoped to profile: revocation and ARI carry no account, so the
endpoint the request arrived at is the only thing that keeps one
profile from answering for — or revoking — another’s certificate.
Sourcepub async fn find_by_replaces(
profile: &str,
cert_id: &str,
database: &Database,
) -> Result<Option<Order>, Error>
pub async fn find_by_replaces( profile: &str, cert_id: &str, database: &Database, ) -> Result<Option<Order>, Error>
Finds any order that already claims to replace cert_id and is not
invalid — RFC 9773 §5’s “the identified certificate has not already
been marked as replaced by a different Order that is not invalid”.
The invalid exclusion is what makes a retry work: an order that failed
validation never produced a replacement, so it must not hold the
predecessor hostage. Scoped by profile like every other request-path
lookup — a certID is only meaningful at the endpoint that issued it.
Sourcepub async fn revoke(
&mut self,
reason: Option<i64>,
database: &Database,
) -> Result<(), Error>
pub async fn revoke( &mut self, reason: Option<i64>, database: &Database, ) -> Result<(), Error>
Records a certificate revocation (RFC 8555 §7.6): stamps revoked_at
(now) and the optional CRLReason reason, and keeps self in sync
(like Order::finalize). Deliberately does not touch status:
revocation is orthogonal to the order state machine (RFC 8555 defines
no “revoked” order status), so a revoked order stays valid.
pub async fn mark_invalid( &mut self, error: Value, database: &Database, ) -> Result<(), Error>
Sourcepub async fn mark_ready(&mut self, database: &Database) -> Result<(), Error>
pub async fn mark_ready(&mut self, database: &Database) -> Result<(), Error>
Moves the order from pending to ready once all its authorizations are
valid, so it can be finalized. Keeps self in sync (like
Order::finalize).
Sourcepub async fn mark_pending(&mut self, database: &Database) -> Result<(), Error>
pub async fn mark_pending(&mut self, database: &Database) -> Result<(), Error>
Moves the order back from ready to pending, after one of its
authorizations stopped being valid — in practice, a client
deactivating one (RFC 8555 §7.5.2).
The only backwards transition in the order state machine, and it exists
because §7.5.2’s “the server MUST NOT treat deactivated authorization
objects as sufficient for issuing certificates” has to hold for an order
that already reached ready — otherwise finalize would still accept
it. RFC 8555 §7.1.6’s diagram draws pending → ready as the state
becoming true rather than a one-way latch, so re-deriving it is in
keeping with the model.
Sourcepub async fn claim_for_finalize(
&mut self,
database: &Database,
) -> Result<bool, Error>
pub async fn claim_for_finalize( &mut self, database: &Database, ) -> Result<bool, Error>
Claims the order for issuance, moving it from ready to processing in
one guarded statement: Ok(true) means this caller won the claim,
Ok(false) that somebody else already holds it. Keeps self in sync
(like Order::mark_ready) only on the winning branch.
The precondition is the whole point. post_finalize reads the order,
checks it is ready, signs, and writes — three steps with no lock
between them, so N concurrent finalize requests on one order all passed
the check, all reached SignerBackend::issue, and all got a certificate
back. Only the last write survived, and the others became valid
CA-signed certificates with no row naming their serial: POST /revokeCert looks an order up by find_by_cert_serial and would answer
“unknown certificate”, so nothing this server offers could ever revoke
them and the CRL would never learn they exist. rows_affected closes it,
the primitive crate::sqlite::nonce::Nonce::verify and
AdminUser::claim_totp_step already rest on.
The relay backend was never exposed, because upstream_orders.order_id
is a primary key and the second insert conflicts — this gives local_ca
and custom, which answer inline, the same guard.
The processing status needed no migration — the orders.status
CHECK has always allowed it; until the relay backend existed
there was simply no asynchronous issuance to use it.
Sourcepub async fn release_finalize_claim(
&mut self,
database: &Database,
) -> Result<(), Error>
pub async fn release_finalize_claim( &mut self, database: &Database, ) -> Result<(), Error>
Gives the claim back, moving processing to ready so the client can
try again — the counterpart to Order::claim_for_finalize for the
refusals RFC 8555 §7.4 says must leave the order finalizable (a rejected
CSR) and for the two arms where issuance succeeded but this server could
not read what it had just been handed.
Guarded on processing for a reason of its own: a §7.5.2 deactivation
racing this claim demotes the order to pending
(Order::mark_pending, unguarded, since it is the authoritative
answer to an authorization that stopped being valid). An unguarded
release would push it back to ready and hand the client a finalizable
order whose authorizations no longer support it.
Sourcepub fn to_json(&self, base_url: &str, authz_ids: &[Uuid]) -> Value
pub fn to_json(&self, base_url: &str, authz_ids: &[Uuid]) -> Value
The RFC 8555 order object. URLs are derived from base_url; datetimes are
rendered RFC3339. authorizations lists one URL per authz_ids entry, the
certificate URL appears only once the order is valid, and notBefore/
notAfter/error appear only when set.
Trait Implementations§
Auto Trait Implementations§
impl Freeze for Order
impl RefUnwindSafe for Order
impl Send for Order
impl Sync for Order
impl Unpin for Order
impl UnsafeUnpin for Order
impl UnwindSafe for Order
Blanket Implementations§
Source§impl<'a, T, E> AsTaggedExplicit<'a, E> for Twhere
T: 'a,
impl<'a, T, E> AsTaggedExplicit<'a, E> for Twhere
T: 'a,
Source§impl<'a, T, E> AsTaggedImplicit<'a, E> for Twhere
T: 'a,
impl<'a, T, E> AsTaggedImplicit<'a, E> for Twhere
T: 'a,
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<A, B, T> HttpServerConnExec<A, B> for Twhere
B: Body,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more