| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | |
|
| 13 | |
|
| 14 | | use crate::api::ApiError; |
| 15 | | use crate::{db, Shared}; |
| 16 | | use axum::extract::Path; |
| 17 | | use axum::extract::State; |
| 18 | | use axum::http::{HeaderMap, StatusCode}; |
| 19 | | use axum::Json; |
| 20 | | use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate, SignedGrant}; |
| 21 | | use oxy_core::identity::{verify_challenge, Identity, Scope, Signature}; |
| 22 | | use serde::{Deserialize, Serialize}; |
| 23 | |
|
| 24 | |
|
| 25 | | pub const LIFETIME_MS: i64 = 10 * 60 * 1000; |
| 26 | |
|
| 27 | | const SKEW_MS: i64 = 30 * 1000; |
| 28 | |
|
| 29 | | pub const GRANT_TOKEN_PREFIX: &str = "oxyagent_"; |
| 30 | |
|
| 31 | |
|
| 32 | 3× | pub fn audience(base_url: &str) -> String { |
| 33 | 3× | format!("{}/keyless", base_url.trim_end_matches('/')) |
| 34 | 3× | } |
| 35 | |
|
| 36 | | #[derive(Deserialize)] |
| 37 | | pub struct CertifyRequest { |
| 38 | | |
| 39 | | pub identity: String, |
| 40 | | |
| 41 | | pub proof: String, |
| 42 | | |
| 43 | | pub scope: String, |
| 44 | | } |
| 45 | |
|
| 46 | | #[derive(Serialize)] |
| 47 | | pub struct CertifyResponse { |
| 48 | | |
| 49 | | pub certificate: String, |
| 50 | | pub subject: String, |
| 51 | | pub expires_ms: i64, |
| 52 | | |
| 53 | | |
| 54 | | |
| 55 | | #[serde(skip_serializing_if = "Option::is_none")] |
| 56 | | pub grantor_identity: Option<String>, |
| 57 | | |
| 58 | | |
| 59 | | |
| 60 | | |
| 61 | | |
| 62 | | #[serde(skip_serializing_if = "Vec::is_empty")] |
| 63 | | pub author_chain: Vec<String>, |
| 64 | | } |
| 65 | |
|
| 66 | |
|
| 67 | |
|
| 68 | |
|
| 69 | 4× | pub async fn author_chain(app: &Shared, account_id: i64, grantor: oxy_core::hash::KeyId) -> Vec<Identity> { |
| 70 | 4× | let linked = db::accounts::identities_of(&app.db, account_id).await.unwrap_or_default(); |
| 71 | 4× | let ids: std::collections::HashMap<oxy_core::hash::KeyId, Identity> = |
| 72 | 6× | linked.iter().filter_map(|l| Identity::decode(&l.object).ok()).map(|i| (i.hash(), i)).collect(); |
| 73 | 4× | let mut best: Vec<Identity> = Vec::new(); |
| 74 | 6× | for head in ids.values() { |
| 75 | 6× | let mut chain = vec![head.clone()]; |
| 76 | 6× | let mut cur = head; |
| 77 | 6× | let reached = loop { |
| 78 | 2× | match cur.previous { |
| 79 | 2× | Some(p) if p == grantor => break true, |
| 80 | 0× | Some(p) => match ids.get(&p) { |
| 81 | 0× | Some(prev) if chain.len() < 64 => { |
| 82 | 0× | chain.push(prev.clone()); |
| 83 | 0× | cur = prev; |
| 84 | 0× | } |
| 85 | 0× | _ => break false, |
| 86 | | }, |
| 87 | 4× | None => break false, |
| 88 | | } |
| 89 | | }; |
| 90 | 6× | if reached && best.first().is_none_or(|b| head.created > b.created) { |
| 91 | 2× | best = chain; |
| 92 | 4× | } |
| 93 | | } |
| 94 | 4× | best |
| 95 | 4× | } |
| 96 | |
|
| 97 | |
|
| 98 | | struct Proven { |
| 99 | | subject: String, |
| 100 | | claims: Vec<(String, String)>, |
| 101 | | |
| 102 | | until_ms: i64, |
| 103 | | target: String, |
| 104 | | |
| 105 | | allowed: Scope, |
| 106 | | |
| 107 | | grant: Option<SignedGrant>, |
| 108 | | } |
| 109 | |
|
| 110 | 6× | fn bad(status: StatusCode, why: impl Into<String>) -> ApiError { |
| 111 | 6× | ApiError(status, why.into()) |
| 112 | 6× | } |
| 113 | |
|
| 114 | |
|
| 115 | 6× | async fn job_proof(app: &Shared, token: &str) -> Result<Proven, ApiError> { |
| 116 | 6× | let invalid = || bad(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)"); |
| 117 | 6× | let hash = crate::auth::token_hash(token); |
| 118 | 6× | let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS) |
| 119 | 6× | .await |
| 120 | 6× | .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 121 | 6× | .ok_or_else(invalid)?; |
| 122 | 5× | let run = db::flow::run_by_id(&app.db, job.run_id) |
| 123 | 5× | .await |
| 124 | 5× | .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 125 | 5× | .ok_or_else(invalid)?; |
| 126 | 5× | let repo = db::repos::get_by_id(&app.db, run.repo_id) |
| 127 | 5× | .await |
| 128 | 5× | .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 129 | 5× | .ok_or_else(invalid)?; |
| 130 | 5× | let mut claims = vec![ |
| 131 | 5× | ("repository".to_string(), format!("{}/{}", repo.owner, repo.name)), |
| 132 | 5× | ("bookmark".to_string(), run.bookmark.clone()), |
| 133 | 5× | ("run".to_string(), run.number.to_string()), |
| 134 | 5× | ("job".to_string(), job.name.clone()), |
| 135 | 5× | ("runner".to_string(), job.runner.clone()), |
| 136 | | ]; |
| 137 | 5× | if !job.environment.is_empty() && !job.approved_by.is_empty() { |
| 138 | 0× | claims.push(("environment".to_string(), job.environment.clone())); |
| 139 | 0× | claims.push(("approved_by".to_string(), job.approved_by.clone())); |
| 140 | 5× | } |
| 141 | 5× | Ok(Proven { |
| 142 | 5× | subject: format!("repo:{}/{}:bookmark:{}", repo.owner, repo.name, run.bookmark), |
| 143 | 5× | claims, |
| 144 | 5× | until_ms: job.started_ms.unwrap_or(0) + crate::flow::JOB_TOKEN_MAX_AGE_MS, |
| 145 | 5× | target: format!("{}/{}", repo.owner, repo.name), |
| 146 | 5× | allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL, |
| 147 | 5× | grant: None, |
| 148 | 5× | }) |
| 149 | 6× | } |
| 150 | |
|
| 151 | |
|
| 152 | 2× | async fn oidc_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> { |
| 153 | 2× | let issuer = app.oidc().await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 154 | 2× | let aud = audience(app.config.base_url()); |
| 155 | 2× | let claims = issuer |
| 156 | 2× | .verify(token, &aud, now) |
| 157 | 2× | .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, format!("the token is not one of this hub's, for audience {aud}, still valid")))?; |
| 158 | 1× | let subject = claims["sub"].as_str().unwrap_or("").to_string(); |
| 159 | 1× | if subject.is_empty() { |
| 160 | 0× | return Err(bad(StatusCode::UNAUTHORIZED, "the token names no subject")); |
| 161 | 1× | } |
| 162 | 1× | let keep = ["repository", "bookmark", "run_number", "job", "runner", "environment", "approved_by"]; |
| 163 | 1× | let mut out = Vec::new(); |
| 164 | 7× | for k in keep { |
| 165 | 7× | match &claims[k] { |
| 166 | 0× | serde_json::Value::String(s) => out.push((k.to_string(), s.clone())), |
| 167 | 0× | serde_json::Value::Number(n) => out.push((k.to_string(), n.to_string())), |
| 168 | 7× | _ => {} |
| 169 | | } |
| 170 | | } |
| 171 | 1× | Ok(Proven { |
| 172 | 1× | target: claims["repository"].as_str().unwrap_or(&subject).to_string(), |
| 173 | 1× | subject, |
| 174 | 1× | claims: out, |
| 175 | 1× | until_ms: claims["exp"].as_i64().unwrap_or(0) * 1000, |
| 176 | 1× | allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL, |
| 177 | 1× | grant: None, |
| 178 | 1× | }) |
| 179 | 2× | } |
| 180 | |
|
| 181 | |
|
| 182 | 4× | async fn grant_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> { |
| 183 | 4× | let invalid = || bad(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)"); |
| 184 | 4× | let g = db::accounts::agent_grant_by_token(&app.db, &crate::auth::token_hash(token)) |
| 185 | 4× | .await |
| 186 | 4× | .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 187 | 4× | .filter(|g| g.live(now)) |
| 188 | 4× | .ok_or_else(invalid)?; |
| 189 | 4× | let signed = SignedGrant::decode(&g.signed).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 190 | 4× | db::accounts::touch_agent_grant(&app.db, g.id).await.ok(); |
| 191 | 4× | Ok(Proven { |
| 192 | 4× | subject: g.subject.clone(), |
| 193 | 4× | claims: vec![("agent".to_string(), g.agent.clone()), ("grant".to_string(), g.id.to_string())], |
| 194 | 4× | until_ms: g.expires_ms, |
| 195 | 4× | target: g.subject, |
| 196 | 4× | allowed: signed.grant.scope, |
| 197 | 4× | grant: Some(signed), |
| 198 | 4× | }) |
| 199 | 4× | } |
| 200 | |
|
| 201 | |
|
| 202 | |
|
| 203 | 12× | pub async fn certify( |
| 204 | 12× | State(app): State<Shared>, |
| 205 | 12× | headers: HeaderMap, |
| 206 | 12× | Json(req): Json<CertifyRequest>, |
| 207 | 12× | ) -> Result<Json<CertifyResponse>, ApiError> { |
| 208 | 12× | let token = oxy_server::bearer_token(&headers) |
| 209 | 12× | .ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "ask with a job token or one of this hub's OIDC tokens as the bearer"))? |
| 210 | 12× | .to_string(); |
| 211 | 12× | let now = db::now_ms(); |
| 212 | 12× | let proven = if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) { |
| 213 | 6× | job_proof(&app, &token).await? |
| 214 | 6× | } else if token.starts_with(GRANT_TOKEN_PREFIX) { |
| 215 | 4× | grant_proof(&app, &token, now).await? |
| 216 | | } else { |
| 217 | 2× | oidc_proof(&app, &token, now).await? |
| 218 | | }; |
| 219 | 20× | let decode = |what: &str, b64: &str| { |
| 220 | 20× | oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("{what} is not base64"))) |
| 221 | 20× | }; |
| 222 | 10× | let identity = |
| 223 | 10× | Identity::decode(&decode("identity", &req.identity)?).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("identity: {e}")))?; |
| 224 | 10× | let proof = oxy_core::cbor::decode(&decode("proof", &req.proof)?) |
| 225 | 10× | .map_err(|e| e.to_string()) |
| 226 | 10× | .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string())) |
| 227 | 10× | .map_err(|e| bad(StatusCode::BAD_REQUEST, format!("proof: {e}")))?; |
| 228 | 10× | verify_challenge(&identity, &proof, &certify_challenge(&token)) |
| 229 | 10× | .map_err(|e| bad(StatusCode::UNAUTHORIZED, format!("the proof does not show this key's holder asked: {e}")))?; |
| 230 | | |
| 231 | | |
| 232 | 9× | let asked = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?; |
| 233 | 9× | let scope = |
| 234 | 19× | asked.names().into_iter().filter_map(|n| Scope::parse(n).ok()).filter(|s| proven.allowed.has(*s)).fold(Scope::NONE, |a, s| a | s); |
| 235 | 9× | if scope.is_empty() { |
| 236 | 0× | return Err(bad(StatusCode::FORBIDDEN, format!("{} may be certified for {} only", proven.subject, proven.allowed))); |
| 237 | 9× | } |
| 238 | 9× | let expires = (now + LIFETIME_MS).min(proven.until_ms); |
| 239 | 9× | if expires <= now { |
| 240 | 0× | return Err(bad(StatusCode::UNAUTHORIZED, "the credential is about to expire: ask with a fresh one")); |
| 241 | 9× | } |
| 242 | 9× | let (key, secret) = |
| 243 | 9× | crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 244 | 9× | let cert = Certificate { |
| 245 | 9× | key: identity, |
| 246 | 9× | issuer: key, |
| 247 | 9× | subject: proven.subject.clone(), |
| 248 | 9× | claims: proven.claims, |
| 249 | 9× | scope, |
| 250 | 9× | not_before: (now - SKEW_MS) as u64, |
| 251 | 9× | expires: expires as u64, |
| 252 | 9× | grant: proven.grant, |
| 253 | 9× | }; |
| 254 | | |
| 255 | | |
| 256 | 9× | let grantor = match &cert.grant { |
| 257 | 4× | Some(g) => db::accounts::identity_by_key(&app.db, &g.grant.grantor.to_hex()).await.ok().flatten(), |
| 258 | 5× | None => None, |
| 259 | | }; |
| 260 | 9× | let grantor_account = grantor.as_ref().map(|l| l.account_id); |
| 261 | 9× | let grantor_identity = grantor.map(|l| oxy_core::dsse::base64(&l.object)); |
| 262 | 9× | let author_chain = match (&cert.grant, &grantor_account) { |
| 263 | 4× | (Some(g), Some(account)) => { |
| 264 | 4× | author_chain(&app, *account, g.grant.grantor).await.iter().map(|i| oxy_core::dsse::base64(&i.encode())).collect() |
| 265 | | } |
| 266 | 5× | _ => Vec::new(), |
| 267 | | }; |
| 268 | 9× | let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret); |
| 269 | 9× | let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires); |
| 270 | 9× | db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok(); |
| 271 | | |
| 272 | 9× | crate::tlog::log( |
| 273 | 9× | &app, |
| 274 | 9× | vec![crate::tlog::Pending { |
| 275 | 9× | kind: "certificate", |
| 276 | 9× | repo: [0; 16], |
| 277 | 9× | repo_row: None, |
| 278 | 9× | object: signed.cert.hash(), |
| 279 | 9× | signers: vec![app.hub_key.key], |
| 280 | 9× | }], |
| 281 | 9× | ) |
| 282 | 9× | .await; |
| 283 | 9× | Ok(Json(CertifyResponse { |
| 284 | 9× | certificate: oxy_core::dsse::base64(&signed.encode()), |
| 285 | 9× | subject: proven.subject, |
| 286 | 9× | expires_ms: expires, |
| 287 | 9× | grantor_identity, |
| 288 | 9× | author_chain, |
| 289 | 9× | })) |
| 290 | 12× | } |
| 291 | |
|
| 292 | | #[derive(Deserialize)] |
| 293 | | pub struct GrantRequest { |
| 294 | | |
| 295 | | pub grant: String, |
| 296 | | } |
| 297 | |
|
| 298 | | #[derive(Serialize)] |
| 299 | | pub struct GrantView { |
| 300 | | pub id: i64, |
| 301 | | pub agent: String, |
| 302 | | pub subject: String, |
| 303 | | pub grantor: String, |
| 304 | | pub scope: String, |
| 305 | | pub created_ms: i64, |
| 306 | | pub expires_ms: i64, |
| 307 | | pub revoked_ms: Option<i64>, |
| 308 | | pub last_used_ms: Option<i64>, |
| 309 | | |
| 310 | | #[serde(skip_serializing_if = "Option::is_none")] |
| 311 | | pub token: Option<String>, |
| 312 | | } |
| 313 | |
|
| 314 | | impl From<db::accounts::AgentGrant> for GrantView { |
| 315 | 1× | fn from(g: db::accounts::AgentGrant) -> Self { |
| 316 | 1× | GrantView { |
| 317 | 1× | id: g.id, |
| 318 | 1× | agent: g.agent, |
| 319 | 1× | subject: g.subject, |
| 320 | 1× | grantor: g.grantor, |
| 321 | 1× | scope: g.scope, |
| 322 | 1× | created_ms: g.created_ms, |
| 323 | 1× | expires_ms: g.expires_ms, |
| 324 | 1× | revoked_ms: g.revoked_ms, |
| 325 | 1× | last_used_ms: g.last_used_ms, |
| 326 | 1× | token: None, |
| 327 | 1× | } |
| 328 | 1× | } |
| 329 | | } |
| 330 | |
|
| 331 | |
|
| 332 | |
|
| 333 | 6× | async fn owner(app: &Shared, headers: &HeaderMap) -> Result<db::accounts::Account, ApiError> { |
| 334 | 6× | let c = crate::api::caller(app, headers).await?; |
| 335 | | |
| 336 | 6× | if c.agent { |
| 337 | 1× | return Err(bad(StatusCode::FORBIDDEN, "an agent's token cannot manage agent grants: use your own")); |
| 338 | 5× | } |
| 339 | 5× | c.account.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "a personal access token is required")) |
| 340 | 6× | } |
| 341 | |
|
| 342 | |
|
| 343 | 2× | fn agent_name_ok(name: &str) -> bool { |
| 344 | 12× | !name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') |
| 345 | 2× | } |
| 346 | |
|
| 347 | |
|
| 348 | |
|
| 349 | 3× | pub async fn grants_create( |
| 350 | 3× | State(app): State<Shared>, |
| 351 | 3× | headers: HeaderMap, |
| 352 | 3× | Json(req): Json<GrantRequest>, |
| 353 | 3× | ) -> Result<(StatusCode, Json<GrantView>), ApiError> { |
| 354 | 3× | let account = owner(&app, &headers).await?; |
| 355 | 3× | let bytes = oxy_core::dsse::base64_decode(req.grant.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, "the grant is not base64"))?; |
| 356 | 3× | let signed = SignedGrant::decode(&bytes).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("grant: {e}")))?; |
| 357 | 3× | let g = &signed.grant; |
| 358 | 3× | let suffix = format!("@{}", account.username); |
| 359 | 3× | let agent = g |
| 360 | 3× | .subject |
| 361 | 3× | .strip_prefix("agent:") |
| 362 | 3× | .and_then(|r| r.strip_suffix(&suffix)) |
| 363 | 3× | .filter(|n| agent_name_ok(n)) |
| 364 | 3× | .ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("a grant here is for agent:NAME{suffix}, not {}", g.subject)))? |
| 365 | 2× | .to_string(); |
| 366 | 2× | if g.issuer != app.hub_key.key { |
| 367 | 0× | return Err(bad(StatusCode::BAD_REQUEST, "the grant names another issuer than this hub's key (/.well-known/oxyhub-key)")); |
| 368 | 2× | } |
| 369 | 2× | let grantor = g.grantor.to_hex(); |
| 370 | 2× | let linked = db::accounts::identity_by_key(&app.db, &grantor) |
| 371 | 2× | .await |
| 372 | 2× | .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 373 | 2× | .filter(|i| i.account_id == account.id) |
| 374 | 2× | .ok_or_else(|| bad(StatusCode::FORBIDDEN, format!("key {} is not linked to your account: link it first", g.grantor.short(12))))?; |
| 375 | 1× | let identity = Identity::decode(&linked.object).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 376 | 1× | signed.verify(&identity).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("the grant does not verify: {e}")))?; |
| 377 | 1× | let now = db::now_ms(); |
| 378 | 1× | if (g.expires as i64) <= now { |
| 379 | 0× | return Err(bad(StatusCode::BAD_REQUEST, "the grant has already ended")); |
| 380 | 1× | } |
| 381 | 1× | let token = format!("{GRANT_TOKEN_PREFIX}{}", crate::auth::random_hex(32)); |
| 382 | 1× | let hash = crate::auth::token_hash(&token); |
| 383 | 1× | let scope = g.scope.to_string(); |
| 384 | 1× | let id = db::accounts::create_agent_grant( |
| 385 | 1× | &app.db, |
| 386 | 1× | db::accounts::NewAgentGrant { |
| 387 | 1× | account_id: account.id, |
| 388 | 1× | agent: &agent, |
| 389 | 1× | subject: &g.subject, |
| 390 | 1× | grantor: &grantor, |
| 391 | 1× | scope: &scope, |
| 392 | 1× | signed: &bytes, |
| 393 | 1× | token_hash: &hash, |
| 394 | 1× | expires_ms: g.expires as i64, |
| 395 | 1× | }, |
| 396 | 1× | ) |
| 397 | 1× | .await |
| 398 | 1× | .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 399 | 1× | db::accounts::audit(&app.db, Some(&account), "agent.granted", &g.subject, &format!("grant {id}, {scope}, until {}", g.expires)) |
| 400 | 1× | .await |
| 401 | 1× | .ok(); |
| 402 | 1× | Ok(( |
| 403 | 1× | StatusCode::CREATED, |
| 404 | 1× | Json(GrantView { |
| 405 | 1× | id, |
| 406 | 1× | agent, |
| 407 | 1× | subject: g.subject.clone(), |
| 408 | 1× | grantor, |
| 409 | 1× | scope, |
| 410 | 1× | created_ms: now, |
| 411 | 1× | expires_ms: g.expires as i64, |
| 412 | 1× | revoked_ms: None, |
| 413 | 1× | last_used_ms: None, |
| 414 | 1× | token: Some(token), |
| 415 | 1× | }), |
| 416 | 1× | )) |
| 417 | 3× | } |
| 418 | |
|
| 419 | |
|
| 420 | 2× | pub async fn grants_list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<GrantView>>, ApiError> { |
| 421 | 2× | let account = owner(&app, &headers).await?; |
| 422 | 1× | let grants = |
| 423 | 1× | db::accounts::agent_grants_of(&app.db, account.id).await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 424 | 1× | Ok(Json(grants.into_iter().map(GrantView::from).collect())) |
| 425 | 2× | } |
| 426 | |
|
| 427 | |
|
| 428 | 1× | pub async fn grants_revoke(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> { |
| 429 | 1× | let account = owner(&app, &headers).await?; |
| 430 | 1× | let done = db::accounts::revoke_agent_grant(&app.db, account.id, id) |
| 431 | 1× | .await |
| 432 | 1× | .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 433 | 1× | if !done { |
| 434 | 0× | return Err(bad(StatusCode::NOT_FOUND, "no such live grant of yours")); |
| 435 | 1× | } |
| 436 | 1× | db::accounts::audit(&app.db, Some(&account), "agent.revoked", &format!("grant {id}"), "").await.ok(); |
| 437 | 1× | Ok(StatusCode::NO_CONTENT) |
| 438 | 1× | } |