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