Skip to content

Commit 8ca416c

Browse files
committed
feat: Implement certificate issuance cancellation, ACME account mutation protection, and status synchronization.
1 parent d1f7fae commit 8ca416c

File tree

14 files changed

+551
-142
lines changed

14 files changed

+551
-142
lines changed

landscape-common/src/cert/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,14 @@ pub enum CertError {
3232
#[api_error(id = "cert.staging_not_supported", status = 400)]
3333
StagingNotSupported,
3434

35-
#[error("Operation not allowed: account is currently in '{0}' status")]
35+
#[error("Operation not allowed in current status: {0}")]
3636
#[api_error(id = "cert.invalid_status_transition", status = 409)]
3737
InvalidStatusTransition(String),
3838

39+
#[error("Cannot mutate account while active/renewable certificates exist: {0}")]
40+
#[api_error(id = "cert.account_has_active_certificates", status = 409)]
41+
AccountHasActiveCertificates(String),
42+
3943
#[error("Cannot change ACME account while certificate is valid; revoke it first")]
4044
#[api_error(id = "cert.acme_account_change_requires_revocation", status = 409)]
4145
AcmeAccountChangeRequiresRevocation,

landscape-common/src/cert/order.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub enum CertStatus {
7171
Pending,
7272
Ready,
7373
Processing,
74+
Cancelled,
7475
Valid,
7576
Invalid,
7677
Expired,
Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
use landscape_common::cert::order::CertConfig;
2-
use sea_orm::DatabaseConnection;
1+
use std::collections::HashSet;
2+
3+
use landscape_common::cert::account::AccountStatus;
4+
use landscape_common::cert::order::{CertConfig, CertStatus, CertType};
5+
use landscape_common::error::LdError;
6+
use sea_orm::{
7+
ActiveModelTrait, DatabaseConnection, EntityTrait, TransactionError, TransactionTrait,
8+
};
39

410
use super::entity::{CertActiveModel, CertEntity, CertModel};
511
use crate::DBId;
@@ -9,10 +15,84 @@ pub struct CertRepository {
915
db: DatabaseConnection,
1016
}
1117

18+
pub struct SyncAccountHintTxResult {
19+
pub changed_cert_ids: Vec<DBId>,
20+
pub cancelled_cert_ids: Vec<DBId>,
21+
}
22+
1223
impl CertRepository {
1324
pub fn new(db: DatabaseConnection) -> Self {
1425
Self { db }
1526
}
27+
28+
pub async fn sync_account_status_hint_tx(
29+
&self,
30+
account_id: DBId,
31+
account_status: &AccountStatus,
32+
hint_prefix: &str,
33+
) -> Result<SyncAccountHintTxResult, LdError> {
34+
let account_is_registered = matches!(account_status, AccountStatus::Registered);
35+
let hint = format!("{hint_prefix} {:?}", account_status);
36+
let hint_prefix = hint_prefix.to_string();
37+
let hint_opt = if account_is_registered { None } else { Some(hint) };
38+
39+
self.db
40+
.transaction::<_, SyncAccountHintTxResult, LdError>(|txn| {
41+
let hint_prefix = hint_prefix.clone();
42+
let hint_opt = hint_opt.clone();
43+
Box::pin(async move {
44+
let cert_models = CertEntity::find().all(txn).await?;
45+
let mut changed_cert_ids = Vec::new();
46+
let mut cancelled_cert_ids = HashSet::new();
47+
48+
for cert_model in cert_models {
49+
let mut cert: CertConfig = cert_model.clone().into();
50+
let CertType::Acme(acme) = &cert.cert_type else {
51+
continue;
52+
};
53+
if acme.account_id != account_id {
54+
continue;
55+
}
56+
57+
let mut changed = false;
58+
if let Some(hint) = &hint_opt {
59+
if matches!(cert.status, CertStatus::Processing) {
60+
cert.status = CertStatus::Cancelled;
61+
cancelled_cert_ids.insert(cert.id);
62+
changed = true;
63+
}
64+
if cert.status_message.as_deref() != Some(hint.as_str()) {
65+
cert.status_message = Some(hint.clone());
66+
changed = true;
67+
}
68+
} else if cert
69+
.status_message
70+
.as_deref()
71+
.is_some_and(|m| m.starts_with(&hint_prefix))
72+
{
73+
cert.status_message = None;
74+
changed = true;
75+
}
76+
77+
if changed {
78+
let active: CertActiveModel = cert.into();
79+
active.update(txn).await?;
80+
changed_cert_ids.push(cert_model.id);
81+
}
82+
}
83+
84+
Ok(SyncAccountHintTxResult {
85+
changed_cert_ids,
86+
cancelled_cert_ids: cancelled_cert_ids.into_iter().collect(),
87+
})
88+
})
89+
})
90+
.await
91+
.map_err(|e| match e {
92+
TransactionError::Connection(db_err) => LdError::DatabaseError(db_err),
93+
TransactionError::Transaction(ld_err) => ld_err,
94+
})
95+
}
1696
}
1797

1898
crate::impl_repository!(CertRepository, CertModel, CertEntity, CertActiveModel, CertConfig, DBId);

landscape-webserver/src/cert/accounts.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ async fn create_cert_account(
4545
JsonBody(account): JsonBody<CertAccountConfig>,
4646
) -> LandscapeApiResult<CertAccountConfig> {
4747
account.validate()?;
48+
if account.id != ConfigId::default() {
49+
state.cert_service.ensure_account_mutation_allowed(account.id).await?;
50+
}
4851
let result = state.cert_account_service.checked_set(account).await?;
4952
LandscapeApiResp::success(result)
5053
}
@@ -85,6 +88,7 @@ async fn delete_cert_account(
8588
State(state): State<LandscapeApp>,
8689
Path(id): Path<ConfigId>,
8790
) -> LandscapeApiResult<()> {
91+
state.cert_service.ensure_account_mutation_allowed(id).await?;
8892
state.cert_account_service.delete(id).await;
8993
LandscapeApiResp::success(())
9094
}
@@ -106,6 +110,7 @@ async fn register_cert_account(
106110
Path(id): Path<ConfigId>,
107111
) -> LandscapeApiResult<CertAccountConfig> {
108112
let result = state.cert_account_service.register_account(id).await?;
113+
state.cert_service.sync_account_status_hint(id, &result.status).await;
109114
LandscapeApiResp::success(result)
110115
}
111116

@@ -125,6 +130,7 @@ async fn verify_cert_account(
125130
Path(id): Path<ConfigId>,
126131
) -> LandscapeApiResult<CertAccountConfig> {
127132
let result = state.cert_account_service.verify_account(id).await?;
133+
state.cert_service.sync_account_status_hint(id, &result.status).await;
128134
LandscapeApiResp::success(result)
129135
}
130136

@@ -143,6 +149,8 @@ async fn deactivate_cert_account(
143149
State(state): State<LandscapeApp>,
144150
Path(id): Path<ConfigId>,
145151
) -> LandscapeApiResult<CertAccountConfig> {
152+
state.cert_service.ensure_account_mutation_allowed(id).await?;
146153
let result = state.cert_account_service.deactivate_account(id).await?;
154+
state.cert_service.sync_account_status_hint(id, &result.status).await;
147155
LandscapeApiResp::success(result)
148156
}

landscape-webserver/src/cert/certs.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub fn get_cert_paths() -> OpenApiRouter<LandscapeApp> {
1717
.routes(routes!(get_cert, delete_cert))
1818
.routes(routes!(get_cert_info))
1919
.routes(routes!(issue_cert))
20+
.routes(routes!(cancel_cert))
2021
.routes(routes!(revoke_cert))
2122
.routes(routes!(renew_cert))
2223
}
@@ -125,6 +126,25 @@ async fn issue_cert(
125126
LandscapeApiResp::success(result)
126127
}
127128

129+
#[utoipa::path(
130+
post,
131+
path = "/certs/{id}/cancel",
132+
tag = "Certificates",
133+
params(("id" = Uuid, Path, description = "Certificate ID")),
134+
responses(
135+
(status = 200, body = CommonApiResp<CertConfig>),
136+
(status = 404, description = "Not found"),
137+
(status = 409, description = "Invalid status transition")
138+
)
139+
)]
140+
async fn cancel_cert(
141+
State(state): State<LandscapeApp>,
142+
Path(id): Path<ConfigId>,
143+
) -> LandscapeApiResult<CertConfig> {
144+
let result = state.cert_service.cancel_cert(id).await?;
145+
LandscapeApiResp::success(result)
146+
}
147+
128148
#[utoipa::path(
129149
post,
130150
path = "/certs/{id}/revoke",

landscape-webui/src/api/cert/order.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createCert,
66
deleteCert,
77
issueCert,
8+
cancelCert,
89
revokeCert,
910
renewCert,
1011
} from "@landscape-router/types/api/certificates/certificates";
@@ -33,6 +34,10 @@ export async function issue_cert(id: string): Promise<CertConfig> {
3334
return issueCert(id);
3435
}
3536

37+
export async function cancel_cert(id: string): Promise<CertConfig> {
38+
return cancelCert(id);
39+
}
40+
3641
export async function revoke_cert(id: string): Promise<CertConfig> {
3742
return revokeCert(id);
3843
}

landscape-webui/src/api/index.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import router from "@/router";
33
import i18n from "@/i18n";
44
import { LANDSCAPE_TOKEN_KEY } from "@/lib/common";
55

6+
function formatApiErrorTemplate(
7+
template: string,
8+
args: Record<string, unknown> | undefined,
9+
): string {
10+
if (!args) return template;
11+
return template.replace(/\{([^}]+)\}/g, (_m: string, key: string) => {
12+
const value = args[key];
13+
return value == null ? `{${key}}` : String(value);
14+
});
15+
}
16+
617
/**
718
* Apply common interceptors (auth token, token refresh, error handling)
819
* to any axios instance.
@@ -43,9 +54,24 @@ export function applyInterceptors(instance: AxiosInstance): AxiosInstance {
4354
});
4455
}
4556

57+
const locale =
58+
typeof i18n.global.locale === "string"
59+
? i18n.global.locale
60+
: i18n.global.locale.value;
61+
const localeMessages = i18n.global.getLocaleMessage(locale) as Record<
62+
string,
63+
unknown
64+
>;
65+
const errorsMap = localeMessages.errors as
66+
| Record<string, string>
67+
| undefined;
68+
const flatTemplate =
69+
error_id && errorsMap ? errorsMap[error_id] : undefined;
70+
4671
const errorKey = error_id ? `errors.${error_id}` : "";
47-
const displayMsg =
48-
errorKey && i18n.global.te(errorKey)
72+
const displayMsg = flatTemplate
73+
? formatApiErrorTemplate(flatTemplate, args || {})
74+
: errorKey && i18n.global.te(errorKey)
4975
? (i18n.global.t(errorKey, args || {}) as string)
5076
: message;
5177

0 commit comments

Comments
 (0)