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