This commit is contained in:
Mateusz Gruszczyński committed 2026-10-12 14:57:37 +02:00
1 parent 180a0e4877
commit d23fa5e154
39 files changed
+1521 -376

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiRotate {
#[serde(default)]
key_id: Option<String>,
#[serde(default)]
token: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
certificate_ids: Vec<String>,
#[serde(default)]
allow_deploy: bool,
}
fn valid_certificate_api_comment(value: &str) -> bool {
value.len() <= 256 && !value.chars().any(char::is_control)
}
fn certificate_api_status_value(values: Vec<crate::db::CertificateApiCredential>) -> Value {
let legacy = values.first();
json!({
"configured": !values.is_empty(),
"key_id": legacy.map(|value| value.key_id.clone()),
"created_at": legacy.map(|value| value.created_at),
"last_used_at": legacy.and_then(|value| value.last_used_at),
"credentials": values,
"max_credentials": crate::db::CERTIFICATE_API_CREDENTIAL_CAPACITY,
"scopes": ["certificates:deploy", "certificates:reload"],
})
}
async fn certificate_api_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
let values = state
.store
.certificate_api_credentials()
.await
.map_err(auth::internal)?;
Ok(Json(certificate_api_status_value(values)))
}
async fn certificate_api_rotate(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Json(input): Json<CertificateApiRotate>,
) -> Result<Json<Value>, ApiError> {
let generated = input.token.is_none();
let token = input.token.unwrap_or_else(auth::random_secret);
if !valid_certificate_api_token(&token) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_token_format".into(),
));
}
let key_id = input.key_id.unwrap_or_else(|| {
let random = auth::random_secret();
format!("certapi-{}", &random[..12])
});
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_key_id".into(),
));
}
let comment = input.comment.unwrap_or_default().trim().to_owned();
if !valid_certificate_api_comment(&comment) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_comment".into(),
));
}
let mut certificate_ids: Vec<String> = input
.certificate_ids
.into_iter()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.collect();
certificate_ids.sort();
certificate_ids.dedup();
if certificate_ids.is_empty()
|| certificate_ids.len() > 32
|| certificate_ids
.iter()
.any(|value| value == "*" || !crate::config::valid_id(value))
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_certificate_ids".into(),
));
}
let token_hash = auth::secret_hash(&token);
let credential = state
.store
.upsert_certificate_api_credential(
&key_id,
&token_hash,
&comment,
input.allow_deploy,
&certificate_ids,
&session.username,
)
.await
.map_err(|error| {
if error.to_string().contains("credential limit reached") {
ApiError(StatusCode::CONFLICT, "certificate_api.limit_reached".into())
} else {
auth::internal(error)
}
})?;
let values = state
.store
.certificate_api_credentials()
.await
.map_err(auth::internal)?;
let mut value = certificate_api_status_value(values);
value["credential"] = serde_json::to_value(&credential).map_err(auth::internal)?;
value["token"] = Value::String(token);
value["generated"] = Value::Bool(generated);
Ok(Json(value))
}
// Per-credential settings are intentionally separate from token rotation.
// The input cannot include token/token_hash, and every read returns only metadata.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiEdit {
comment: String,
certificate_ids: Vec<String>,
allow_deploy: bool,
}
async fn certificate_api_detail(
State(state): State<Arc<App>>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
}
let credential = state
.store
.certificate_api_credential(&key_id)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
Ok(Json(json!({ "credential": credential })))
}
async fn certificate_api_edit(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(key_id): Path<String>,
Json(input): Json<CertificateApiEdit>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
}
let comment = input.comment.trim();
if !valid_certificate_api_comment(comment) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "certificate_api.invalid_comment".into()));
}
let mut certificate_ids: Vec<String> = input
.certificate_ids
.into_iter()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.collect();
certificate_ids.sort();
certificate_ids.dedup();
if certificate_ids.is_empty()
|| certificate_ids.len() > 32
|| certificate_ids.iter().any(|value| value == "*" || !crate::config::valid_id(value))
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_certificate_ids".into(),
));
}
let credential = state
.store
.edit_certificate_api_credential(
&key_id, comment, input.allow_deploy, &certificate_ids, &session.username,
)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
Ok(Json(json!({ "credential": credential })))
}
async fn certificate_api_regenerate(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
}
let token = auth::random_secret();
let hash = auth::secret_hash(&token);
let credential = state
.store
.regenerate_certificate_api_token(&key_id, &hash, &session.username)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
// The only endpoint in this flow that returns the new bearer secret.
Ok(Json(json!({ "credential": credential, "token": token })))
}
async fn certificate_api_revoke(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let revoked = state
.store
.revoke_certificate_api_credential(&key_id, &session.username)
.await
.map_err(auth::internal)?;
if !revoked {
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
Ok(Json(json!({"revoked": true, "key_id": key_id})))
}
async fn certificate_api_revoke_all(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<Value>, ApiError> {
let revoked = state
.store
.revoke_all_certificate_api_credentials(&session.username)
.await
.map_err(auth::internal)?;
Ok(Json(json!({"configured": false, "revoked": revoked})))
}